- VHDL 65.5%
- MATLAB 30.2%
- Makefile 1.9%
- Shell 1.1%
- Python 0.7%
- Other 0.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
The personal-data guard matched the workflow file itself; the directory pattern needs no trailing slash for git archive to skip the tree. |
||
| .forgejo/workflows | ||
| constraints | ||
| docs | ||
| matlab | ||
| mk@7f399b0088 | ||
| scripts | ||
| src | ||
| tb | ||
| .gitattributes | ||
| .gitignore | ||
| .gitmodules | ||
| Makefile | ||
| project.mk | ||
| README.org | ||
fringe-fitting – Fringe Pattern Profilometry Core
- Quick facts
- Repository layout
- Design settings
- Interface summary
- Output: absolute phase per spot
- 2*pi wrap count per spot
- Carrier demodulation (
carrier_demod) - Bandpass filter (
bandpass_filter) - Simulation
- Building and testing
- Release archive
- Documentation
FPGA module implementing classical 1D Fourier Transform Profilometry (FTP) for real-time 3D surface measurement. It processes a 1920 x 128 pixel ROI at 5000 fps (3840 x 128 with a 300 MHz clock) through 10 parallel FTP front-end lanes, followed by a central spot aggregator, a spot normaliser and a single CORDIC atan2. A spot is a host-programmable group of consecutive columns over all rows. For each of N_SPOTS spots per frame (reset value 100, host-configurable 1-256) the core emits the absolute phase of the spot and its 2*pi wrap count, so the phase can be made continuous across the frame (see 2*pi wrap count per spot). It is a self-contained block for integration into an UltraScale+ design.
Quick facts
| Parameter | Value |
|---|---|
| Image input | 1920 x 128 px as configured (3840 at 300 MHz), 16-bit, AXI-Stream, 10 px/beat |
| Frame rate | 5000 fps |
| System clock | 200 MHz at 1920 x 128 (committed configuration); 300 MHz at 3840 x 128, see docs/clock-and-roi.org |
| Settings | One place: the VHDL packages in src/config/, see Design settings |
| Transpose buffer | BRAM ping-pong of two frames (row-major -> column-major): about 1 MB at 1920 columns, 2 MB at 3840 |
| Per-lane pipeline | carrier_demod -> forward FFT -> bandpass -> inverse FFT -> 30->22-bit narrow -> amplitude gate -> col_reducer |
| Central back-end | spot_aggregator -> spot_norm -> CORDIC, with wrap_count beside them (single instances) |
| Processing lanes | 10 parallel, one per pixel slot of the beat |
| FFT / IFFT | 128-point Xilinx xfft IP, pipelined streaming; forward scaled (gain 8), inverse unscaled (30-bit output) |
| Internal arithmetic | 22-bit Q18.3 Re/Im through both FFTs and the lane back end; 30-bit column sums; 36-bit spot accumulator; 18-bit CORDIC input; Q3.21 phase (24-bit) |
| Output | N_SPOTS x 32-bit beats per frame, AXI-Stream: Q3.21 absolute phase in tdata(23:0), signed 2*pi count n in tdata(31:24), four wrap flags in tuser(3:0) |
| Control | AXI-Lite (registers + SPOT_BOUNDS column-index array) |
| Target | AMD Xilinx Kintex / Zynq UltraScale+ (implemented on xczu9eg-ffvb1156-2-e) |
| VHDL style | VHDL-2008, Gaisler two-process method |
Repository layout
docs/
icd/fpp_core_icd.org Interface Control Document (start here)
design/module-summary.org Per-module summary
precision/design.org Precision budget
clock-and-roi.org Clock and ROI width
ip-stub-fidelity-policy.org Test tiers: IP stubs vs the real Xilinx IP
unwrap-eval/ Evaluation of the 2*pi wrap count
report/ Results report at the configured ROI, with its RTL runs (make report)
bench/capture.org A printed fringe through a camera lens as input to fpp_validate (make print-target)
calculus/ Design calculations: Python notebook, module, Calc, Matlab, Octave
next_steps.org Open items
src/config/ Every design setting, as VHDL constants (see Design settings)
fft_config_pkg.vhd FFT length C_N_FFT and what derives from it
demod_config_pkg.vhd Carrier frequency, projected fringe pattern
bpf_config_pkg.vhd Window shape and half-bandwidth
fpp_config_pkg.vhd ROI, lanes, spot layout, register resets, clock and frame rate, IP and ROM widths
spot_layout_pkg.vhd The spot layout formula (spot_bound) and the SPOT_BOUNDS reset value
config_values.txt Every constant evaluated (generated: make regen-config); read by project.mk and matlab/
src/fpp_core/
fpp_core_pkg.vhd Port records, component declaration
fpp_core.vhd Top-level entity (structural wrapper)
transpose_buffer.vhd BRAM ping-pong row -> column transpose
ftp_engine.vhd 10-lane front-end engine (dispatcher + lanes + collector)
lane_dispatcher.vhd Fans transpose-word slot k to lane k
ftp_lane.vhd Per-lane FTP front end
carrier_demod.vhd Pre-FFT time-domain demodulation (Q0.21 complex ROM)
demod_coeffs_pkg.vhd Demodulation ROM (generated: make regen-demod-coeffs)
bandpass_filter.vhd Single-sideband windowed bandpass at DC (Q0.15 window)
bpf_coeffs_pkg.vhd Window coefficients (generated: make regen-bpf-coeffs)
amplitude_gate.vhd L1 amplitude quality gate
col_reducer.vhd Full-column complex accumulator (one complex per column)
result_collector.vhd Round-robin lane merge into column order
spot_aggregator.vhd Column-group accumulator (SPOT_BOUNDS-defined)
spot_norm.vhd Spot scaling into the CORDIC input range
wrap_count.vhd 2*pi wrap count per spot from the column values
result_pack.vhd AXI-Stream output burst (N_SPOTS beats per frame)
axil_regs.vhd AXI-Lite register file + SPOT_BOUNDS memory
<module>_pkg.vhd Port records and component declaration of each module
src/ip_stubs/ Behavioural stand-ins for the Xilinx IP (simulation only)
fft_stub_pkg.vhd Twiddle tables for the FFT stand-ins
xfft_128.vhd Forward FFT
xfft_128_wide.vhd Inverse FFT
cordic_atan2.vhd CORDIC atan2
src/sim_only/
transpose_buffer_check.vhd Scoreboard: every transpose_buffer output word against its input frame
transpose_buffer_check_pkg.vhd Frame and error counters of that check, read by testbenches
src/ooc/
fpp_core_ooc.vhd Out-of-context implementation wrapper (BUFGCE clock)
tb/
sim_file_pkg.vhd VHDL file I/O helpers (hex + binary)
tb_config_pkg.vhd Verification settings: golden grid, tolerances, fixture geometries, testbench clock
config_dump.vhd Writes src/config/config_values.txt (make regen-config)
tb_<module>.vhd Self-checking unit testbenches
tb_fpp_core*.vhd Self-checking integration testbenches (see Simulation)
model/vcd_ifft_extract.py IFFT-field capture from a VCD stream (make ifft-dump, check-ifft)
stim/ Generated test vectors
ramp_abs/ Full-scale ramp frame and goldens (C_RAMP_COLS x 128, C_RAMP_SPOTS spots)
ramp_abs_small/ Small ramp frame and goldens (C_RAMP_SMALL_COLS x 128, C_RAMP_SMALL_SPOTS spots)
image_ci/ Image fixture (synthetic wafer, 200 columns, 10 spots)
image_wrap/ Surface fixture (1920 columns, 100 spots, wraps up to 28 times)
image_lowsig/ Low-signal fixture (12-bit codes, fringe at 150 and 700 DN, 400 columns, 20 spots)
wrap/ wrap_count stimulus and expected n and flags (8 frames)
image/ The fixture matlab/cmd/fpp_validate.m writes (not committed)
regen_all.sh Regenerates the unit and ramp fixtures (make regen-fixtures)
constraints/
fpp_core_timing.xdc I/O timing (the clock constraint is generated: build/fpp_core_clock.xdc)
fpp_core_ooc.xdc Out-of-context constraints
scripts/
check_generated.sh Tier G: the committed generated files regenerate byte-identical; check-ifft; FIGURES=1 the figures
check_literals.sh A setting restated as a literal in a testbench or Octave file fails
check_clock_route.tcl Post-route check: clock on global resources
check_rd_addr_paths.tcl Post-route check: BRAM read address independent of the engine
check_timing.tcl Post-route check: setup and hold met
matlab/ The model, the fixtures and the RTL validation (matlab/README.org)
fpp.m Entry point: fpp('<command>'); every make target that runs Octave calls it
cmd/ One function per command; cmd/figures and cmd/fixtures hold their jobs
lib/ Layers: config, model (the golden and filter seams), unwrap, sources, io, validate, util
figures/ The tracked figures and data files
tests/ test_fpp_config, test_fpp_image, test_fpp_unwrap, check_matlab_compat
mk/ Build framework (Pcsf/makefile_project_template, submodule)
project.mk Build configuration: toolchain, test cases, Vivado IP and flow
Makefile One line: include mk/Makefile
.forgejo/workflows/lib.yml Release archive (see Release archive)
Design settings
Every setting a user may change is a VHDL constant in one of four packages
under src/config/, and nothing else restates it: the RTL and the stubs
read the packages; tb/config_dump.vhd evaluates them (with the
verification settings of tb/tb_config_pkg.vhd) into
src/config/config_values.txt, and project.mk (IP settings, clock
constraint, frame-rate gate) and matlab/lib/config/fpp_config.m (the model and
every fixture generator) read that file. So the procedure for any change is
one edit, then
make regen # config_values.txt, coefficient ROMs, every fixture and golden
make test # Tier A; then the tiers the change calls for
and commit the regenerated files with the edit. make check-generated
proves the committed files are what the committed packages produce, and
make check-literals fails on a setting's value restated as a literal in a
testbench or an Octave source.
| Setting | Constant (package) |
|---|---|
| FFT / IFFT length = ROI rows | C_N_FFT (fft_config_pkg) |
| Carrier, projected fringe pattern | DEMOD_CYCLES_PER_ROW, FRINGE_PERIODS (demod_config_pkg) |
| Band-pass window shape, half-bandwidth | BPF_SHAPE, BPF_BW_CYCLES_PER_ROW (bpf_config_pkg) |
| ROI columns | C_N_COLS (fpp_config_pkg); G_N_COLS defaults to it |
| Lanes = pixels per beat, pixel slot | C_N_LANES, C_PX_WIDTH |
| Sensor code width | C_PX_BITS; G_PX_BITS defaults to it |
| Spots: array size, column index width | C_MAX_SPOTS, C_COL_IDX_W (bounds the ROI: C_MAX_COLS) |
| Default spot layout = register resets | C_DEFAULT_N_SPOTS, C_DEFAULT_SPOT_COL0; spot_layout_pkg.spot_bound |
| Other register resets | C_RST_AMP_THRESHOLD, C_RST_COL_THR |
| Clock, frame rate, frame tail budget | C_CLK_PERIOD_NS, C_FRAME_RATE_HZ, C_FRAME_TAIL_CLK |
| Phase spec | C_PHASE_SPEC_URAD |
| CORDIC IP: widths, iterations, latency | C_CORDIC_IN_W, C_CORDIC_OUT_W, C_CORDIC_ITER, C_CORDIC_LATENCY |
| Coefficient ROM widths | C_DEMOD_COEFF_W, C_BPF_COEFF_W |
| 2*pi count width and flag bits | C_WRAP_N_W, C_WRAP_FLAG_* |
| Verification: golden grid, tolerances | C_GOLD_FRAC_BITS, C_TOL_* (tb_config_pkg) |
| Verification: fixture geometries | C_RAMP_COLS / C_RAMP_SPOTS, C_RAMP_SMALL_*, C_SUSTAINED_SPOT_COLS |
| Verification: fixture code width | C_TB_PX_BITS |
Derived values (widths, shifts, the frame period in clocks, the CORDIC
input range, …) are VHDL expressions in the same packages, and
config_values.txt carries them evaluated, so the Octave side computes no
formula of its own; the one mirror, matlab/lib/config/fpp_spot_bounds.m of
spot_bound, is checked against samples in that file by
matlab/tests/test_fpp_config.m. fpp_config.m refuses a stale file (it
hashes the packages).
The two offered configurations. 1920 columns at 200 MHz is committed. For
3840 columns at 300 MHz, set C_N_COLS := 3840 and C_CLK_PERIOD_NS :=
3.333 in src/config/fpp_config_pkg.vhd, then make regen. The clock must
reach the frame rate at the ROI, C_FRAME_RATE_OK: a frame needs its beats
(C_N_COLS x rows / lanes) plus C_FRAME_TAIL_CLK clocks, and the frame
period is 1 / (C_CLK_PERIOD_NS x C_FRAME_RATE_HZ). 3840 columns at 5 ns
fail that check: make regen stops in config_dump, fpp_core_ooc asserts
at elaboration and make impl refuses to start. docs/clock-and-roi.org
has the numbers.
Fixtures whose geometry is the user's (tb/stim/image_ci from
matlab/cmd/fpp_validate.m, tb/stim/image_wrap, tb/stim/image_lowsig)
carry it in their fixture_cfg.txt; tb_fpp_core_image reads it at
elaboration.
Interface summary
s_axis_pxi/s_axis_pxo– pixel stream in (row order, 10 px/beat, 160-bit tdata; each pixel aG_PX_BITS-bit code in a 16-bit slot)m_axis_resi/m_axis_reso– per-spot result burst out (N_SPOTS beats per frame, tlast on the last)s_axil_ctrli/s_axil_ctrlo– AXI-Lite control, status, error flags, COL_THR, SPOT_BOUNDS array
Ports are VHDL records; synthesis names the pins after the port, e.g.
m_axis_reso_tdata. The register map, reset sequences and timing contracts
are in docs/icd/fpp_core_icd.org.
Output: absolute phase per spot
The core needs no reference frame and no calibration. Per spot:
spot_aggregatorsums the complex column values of the spot's columns (36-bit accumulator) and emits the sum shifted right by 7 with round-half-up, as 32-bit signed Re/Im.spot_normscales the sum into the CORDIC's legal input range, [-2^16, 2^16] per component at 18 bits: one right shift shared by Re and Im brings the wider component to 17 signed bits, rounded half up (no shift if it already fits), so the angle is kept. A scaled spot sets the sticky W1CERROR_FLAGS[5]SPOT_RANGE. 4-clock latency.- A single CORDIC (Translate,
Input_Width=18,Output_Width=24,Iterations=18,Round_Pos_Inf, 26-clock latency) givestheta = atan2(Im, Re)in (-pi, pi], Q3.21 (LSB 2^-21 rad, about 0.48 urad), with no modular wrap in RTL.
The bit-accurate model is matlab/lib/model/fpga_1d_ftp.m;
matlab/lib/model/fpp_spot_norm.m replays step 2. The phase contains everything
that shapes the fringes: the surface, the projector and the optics;
converting it to height is left to the host.
CORDIC input range
The Xilinx CORDIC accepts at most +/-65 536 counts per component and returns
wrong phases beyond that; the CORDIC stub raises severity error on such an
input, so every simulation checks it. spot_norm keeps every spot in range.
The cost of scaling is precision at bright levels: a scaled spot keeps 17
bits. Measured with the real Xilinx IP on a 12-bit synthetic wafer
(400 x 128, 16 spots) through fpp('validate', cfg), with
G_PX_BITS = 15, max phase error against the full-precision golden: 37.6
urad on the codes as they are, 35.7 urad times 4, 19.6 urad times 8, which
is what G_PX_BITS = 12 does (149 015 counts, twice the CORDIC range).
Precision
The spec is 300 urad of phase error per spot against the full-precision
model. On the full-scale ramp (tb_fpp_core_ramp, 3840 x 128, 151 spots):
68.5 urad with the behavioural stubs (Tier A), 65.5 urad with the real
Xilinx IP (Tier C). The error is the rounding of the spot sums, about
1..3 counts over the spot magnitude, so it grows as the fringe gets dimmer
or a spot spans more phase. G_PX_BITS (the sensor's code width, 12 by
default) has the core shift each code to the top of its 15-bit pixel
range, so a 12-bit sensor meets the spec down to about 100 DN
(tb_fpp_core_image_lowsig checks 150 and 700 DN). The budget and the
envelope are in docs/precision/design.org.
2*pi wrap count per spot
The CORDIC phase theta lies in (-pi, pi], so a surface whose phase moves by
more than that between two spots comes out folded. wrap_count supplies,
for every spot, the integer n such that
theta + 2*pi*n
is continuous across the frame, starting from the first tracked column, whose phase is taken in (-pi, pi]. The absolute 2*pi offset of a frame is not observable from one fringe period.
How
Premise: between two neighbouring columns the true phase moves by less
than pi/2. wrap_count follows the column values (the col_reducer
outputs, in column order) instead of the spots:
- A column is tracked when |Re| + |Im| >
COL_THR; it is scaled to 18 signed bits (one shift for Re and Im, as inspot_norm). - For each pair of consecutive tracked columns a -> b, the sign of the cross product Re(a) Im(b) - Im(a) Re(b) gives the direction of the turn, exactly, for any turn below pi. A turn that crosses the negative real axis counts +1 (upper to lower half plane, turning counter-clockwise) or -1 (the reverse).
- Each spot is tied to its middle column, floor((first + last) / 2), its anchor: n is the count at the anchor plus the crossing from the anchor column to the spot vector, whose half plane is the sign of the spot's own CORDIC phase, so n always agrees with the theta emitted.
The count is right for any turn below pi between tracked columns, twice
the premise. Every link is also checked against the premise (dot product
< 0: a turn above pi/2) and against skipped columns. Columns outside the
spots are ignored, as in spot_aggregator; SPOT_BOUNDS and N_SPOTS must not
change during a frame.
Output and flags
| Field | Content |
|---|---|
| tdata(23:0) | theta, Q3.21, (-pi, pi] |
| tdata(31:24) | n, signed, saturated to -128..127 |
| tuser(0) step | a link up to this spot's anchor turned by more than pi/2 |
| tuser(1) gap | a link up to this spot's anchor skipped a column (below COL_THR) |
| tuser(2) anchor | the spot vector is more than pi/2 from its anchor column, or no column of the spot was tracked by then |
| tuser(3) sat | n did not fit 8 bits |
A wrong n shifts every later spot of the frame by 2*pi, so a flag means "n may be wrong from this spot on". A step of 1.5 pi or more between two columns aliases to a turn below pi/2 and is invisible to any single-wavelength method: n is then wrong with no flag.
wrap_count's output follows the CORDIC phase by one clock; fpp_core
delays the phase by one register so the two reach result_pack together.
Setting COL_THR (0x034)
A column with no light is noise, and its direction is random, so it must
not be tracked. COL_THR comes from the camera's dark noise sigma_px
[DN rms] and an accepted rate of dark columns tracked (1e-6):
COL_THR = 7.11 x 32 x sqrt(sigma_px^2 + 1/12)
32 is the gain from a pixel's noise to a column's Re or Im (N = 128 rows),
1/12 DN^2 the ADC's quantisation noise, 7.11 the L1 tail of a complex
Gaussian at 1e-6. matlab/lib/unwrap/fpp_unwrap_col_thr.m computes it (other
rates too), and docs/unwrap-eval/README.org derives each constant and
checks it against dark frames. The reset value 0 tracks every column with
any signal.
Evidence
docs/unwrap-eval/: the evaluation of the method on the bit-accurate model (surfaces within and beyond the premise, an edge-height sweep, a noise sweep), with the figures.tb_wrap_count:wrap_countagainstmatlab/lib/unwrap/fpp_unwrap_xprod.m, bit-exact on 8 frames, 900 spots (tb/stim/wrap/,matlab/cmd/fixtures/gen_wrap_fixture.m): steps, gaps, anchors, noise, saturation in both directions, pre-ROI columns and 100 one-column spots, with columns arriving ten back to back as the lanes deliver them.tb_fpp_core_image_wrap: the whole core on a 1920-column surface that wraps up to 28 times (tb/stim/image_wrap/,matlab/cmd/fixtures/gen_wrap_image_fixture.m): n and flags equal the model's on all 100 spots, and the phase meets the 300 urad tolerance.
Carrier demodulation (carrier_demod)
The carrier is removed in the time domain, before the forward FFT. For each
pixel sample x[m], m = 0..N-1 down a column, the module multiplies the
pixel (sign-extended to 19 bits) by exp(-j*2*pi*k0*m/N) from an N-entry
Q0.21 complex ROM (demod_coeffs_pkg.vhd, generated by
matlab/lib/model/gen_demod_coeffs.m from demod_config_pkg.vhd):
-- two DSP48E2 per lane: 19-bit pixel x 22-bit coefficient = 41-bit product
v.s2_re := r.s1_px * r.s1_c_re;
v.s2_im := r.s1_px * r.s1_c_im;
-- round half up (+2^17), shift right 18, saturate to 22-bit Q18.3
v.out_re := sat22_shr18(r.s3_re);
v.out_im := sat22_shr18(r.s3_im);
Latency is 4 clocks (ROM read, multiply, rounding add, shift and saturate), one sample per clock; the pipeline stalls only on the forward FFT's tready. After demodulation the carrier sits on bin 0, so the bandpass that follows is static.
The carrier is set as a spatial frequency: DEMOD_CYCLES_PER_ROW = 0.25
(fringe period 4 px), so k0 = 0.25 x N = 32 at N = 128. It is a
compile-time constant, not a register: the deployed optics fix the carrier
once projector and camera are aligned, and a constant lets the ROM be a
small read-only table. Changing it means editing demod_config_pkg.vhd,
running make regen-demod-coeffs and rebuilding.
A time-domain mixer accepts any k0, including a non-integer one; a frequency-domain shift of the spectrum could only move it by whole bins. The Q0.21 ROM bounds the coefficient quantisation at 2^-21 (about 0.48 urad on the unit circle), and the Q18.3 output keeps 3 bits below the integer pixel grid into the FFT.
Bandpass filter (bandpass_filter)
bandpass_filter keeps a narrow band around bin 0, which after
demodulation is the positive carrier sideband: single-sideband extraction,
the analytic signal that the 1D-FTP atan2 relies on. Each bin is multiplied
by a Q0.15 window coefficient BPF_COEFFS(bin_idx) (peak 0x7FFF at bin 0),
then rounded half up, shifted right 15 and saturated back to 22-bit Q18.3.
Latency is 4 clocks (window lookup, multiply, rounding add, shift and
saturate), one bin per clock, with one multiplier each for Re and Im.
The window is set in bpf_config_pkg.vhd:
BPF_SHAPE:"hamming"(default; first side lobe about -43 dB) or"gauss"(sigma = 3.5 bins, set inmatlab/lib/model/gen_bpf_coeffs.m).BPF_BW_CYCLES_PER_ROW = 0.046875, so the half-bandwidthBPF_BW = round(0.046875 x N)= 6 bins at N = 128: a 13-bin support, every coefficient outside it zero.
After demodulation the background (DC) term lands at bin -k0 and the
conjugate carrier at -2 k0. The passband must exclude both, so
BPF_BW < k0 must hold; a window wide enough to reach the conjugate
sideband breaks the analytic-signal property and biases every phase. After
editing the package, run make regen-bpf-coeffs, which rewrites both
bpf_coeffs_pkg.vhd and tb/stim/bpf_coeffs.hex from it, so the RTL and the
model cannot drift. Never hand-edit either generated file.
Simulation
The self-checking testbenches run under GHDL with behavioural IP stubs (the
cases are TESTS in project.mk):
| Testbench | Scope | Key check |
|---|---|---|
tb_carrier_demod |
Pre-FFT time-domain demodulation | Bit-exact against an inline oracle (19 x 22 -> 41-bit, round, shift 18, saturate); 4-clock latency; back-pressure without loss; row counter reset per column |
tb_bandpass_filter |
Static-DC windowed bandpass | Bit-exact against the model's goldens; 4-clock latency; bin index and last aligned through input bubbles |
tb_bandpass_compare |
Bandpass against two oracles | Bit-exact against the Hamming oracle; reports the difference from a rectangular passband |
tb_amplitude_gate |
L1 amplitude quality gate | Accept/reject threshold |
tb_col_reducer |
Full-column complex accumulator | One complex per column, gate-aware, columns back to back |
tb_spot_norm |
Spot scaling into the CORDIC input range | Spots back to back: one inside +/-2^16 passes unchanged, one up to +/-2^31 is scaled with its angle kept, spot_range pulses once per scaled spot |
tb_ftp_lane |
Per-lane front end | One complex per column, multi-column run |
tb_transpose_buffer |
Ping-pong row -> column transpose | 2-frame write/read verify |
tb_transpose_buffer_guard |
transpose_buffer overrun and framing guard | Dropped and accepted frames, overflow and framing pulses, minimum inter-frame gap |
tb_lane_dispatcher |
Parallel fan-out to 10 lanes, per-lane stalls | Slot routing, order, one word per clock |
tb_result_collector |
Round-robin merge of 10 skewed lanes | Column ordering + back-pressure |
tb_spot_aggregator |
Column-group accumulator | SPOT_BOUNDS boundaries, N_SPOTS emit |
tb_ftp_engine |
Engine: dispatcher + 10 lanes + collector, 20 columns | Structural multi-column check |
tb_result_pack |
AXI-Stream burst output + overflow | N_SPOTS beats, frame drop, a short frame flagged |
tb_axil_regs |
AXI-Lite register file + SPOT_BOUNDS aperture | Register tests, W1C (an event in the clock of its clear is kept), N_SPOTS clamp, SPOT_BOUNDS R/W, reserved addresses and bits read 0, STATUS.READY follows ENABLE |
tb_xfft_128 |
Forward FFT (9 tests, including continuous back-to-back frames) | Scaling, bin order, flow control, event flags |
tb_cordic_atan2 |
CORDIC | Phase format, quadrants, flow |
tb_wrap_count |
2*pi wrap count, 1920 columns, 8 frames, 900 spots | n and flags bit-exact against matlab/lib/unwrap/fpp_unwrap_xprod.m |
tb_fpp_core_ctrl |
Host control contract, 20 columns, 4 spots | STATUS.BUSY stays 1 from a frame's first beat to the end of its burst; a write to the reserved 0x01C has no effect; SOFT_RESET discards a frame in flight or half-way, a stalled burst completes; every later burst equals the first one after reset |
tb_fpp_core |
Full pipeline, 3840 columns, 151 spots (ramp_abs) |
Absolute phase per spot against the full-precision golden, 16 777 Q4.24 LSB (1 mrad) |
tb_fpp_core_short |
Full pipeline, 20 columns, 4 spots | Flat fringe of phase 0 in every column: every spot within 50 Q3.21 LSB (24 urad) of 0 |
tb_fpp_core_short_ramp |
Phase ramp, 20 columns, 4 spots (ramp_abs_small) |
Absolute phase per spot against the full-precision golden, 16 777 Q4.24 LSB (1 mrad) |
tb_fpp_core_sustained |
Frames back to back at 5000 fps, 1920 x 128, 48 spots (tb_fpp_core_default: 2 frames, fpp_core built without G_N_COLS) |
Output frame f carries the spot phases of input frame f; every frame done within its period; transpose_buffer_check covered every frame; ERROR_FLAGS = 0 |
tb_fpp_core_image |
fpp_core on an image fixture (tb_fpp_core_image_ci: tb/stim/image_ci/; tb_fpp_core_image_wrap: tb/stim/image_wrap/; tb_fpp_core_image_lowsig: tb/stim/image_lowsig/, 12-bit codes, a fringe at 150 and 700 DN, flat and 0.2 rad/column; the bits above each code vary and must be ignored) |
Absolute phase per spot against the full-precision golden, tolerance from fixture_cfg.txt (300 urad); n and flags equal to the model's |
tb_fpp_core_ramp |
Phase ramp, 3840 x 128, 151 spots (ramp_abs) |
Absolute phase per spot against the full-precision golden, 5033 Q4.24 LSB (300 urad); modular-2*pi fold for the atan2 +/-pi edge |
The goldens are never written by hand: they come from the bit-accurate model
(matlab/lib/model/fpga_1d_ftp.m) run on the same frame the testbench streams.
Building and testing
The build runs on makefile_project_template, checked out as the mk/
submodule. project.mk holds everything specific to this design: the
toolchain, the test cases, the Vivado IP settings and the implementation flow.
Clone with git clone --recurse-submodules, or run
git submodule update --init in an existing clone. Nothing else is needed:
the first make target that needs the sources (make test, make analyze,
…) writes the per-directory source lists the framework reads, then runs.
The framework discovers source files, but the compile order within each VHDL
directory is the committed .compile_order file there (packages first). A
file missing from it is left out of the build, so add every new VHDL file to
its directory's .compile_order. The order across directories is
VHDL_SRCS_DIR in project.mk. Run make scan after adding a source
directory, and give a new testbench a TESTS entry.
Choosing the toolchain
Edit TOOLCHAIN in project.mk, then run plain make targets:
TOOLCHAIN |
Use |
|---|---|
ghdl |
The every-change regression (Tier A): GHDL + behavioural IP stubs |
modelsim |
The same suite on Questa-Intel FPGA Starter |
vivado |
Out-of-context implementation; xsim with stubs or with the real IP |
M_TOOL in project.mk picks the numeric runtime behind every make
target that runs the matlab/ side (regen, check-ifft, figures,
report, print-target, and tb/stim/regen_all.sh), the same way: each
of them is one $(M_EVAL) "addpath('matlab'); fpp('<command>', ...)" line,
so M_TOOL := matlab runs the whole flow with Matlab on Linux.
M_TOOL |
Command (M_EVAL) |
Version |
|---|---|---|
octave |
octave --no-gui -q --eval |
GNU Octave 7 or later |
matlab |
matlab -batch |
Matlab R2019a or later |
M_EVAL_octave and M_EVAL_matlab can be overridden from the environment,
for a runtime installed under another name or behind a wrapper. The committed
fixtures and goldens were generated with Octave.
make regen writes every generated file in the repository:
src/config/config_values.txt first (make regen-config, GHDL on the
host), then the coefficient ROMs, the unit-testbench vectors, the
ramp_abs / ramp_abs_small fixtures, tb/stim/wrap/
(matlab/cmd/fixtures/gen_wrap_fixture.m), tb/stim/image_wrap/
(matlab/cmd/fixtures/gen_wrap_image_fixture.m), tb/stim/image_lowsig/
(matlab/cmd/fixtures/gen_lowsig_image_fixture.m) and tb/stim/image_ci/
(matlab/cmd/fpp_validate.m). The generators are deterministic: the same inputs
give the same bytes, and the image fixtures' run id is a hash of their
content. Changing a setting means running make regen and committing the
regenerated files with the change. make check-generated (Tier G) proves the
committed files are what the committed packages and generators produce.
Test cases
make test runs every case in TESTS, each with its own transcript in
build/test/<case>.log and its own verdict. A case passes when its testbench
prints PASS, raises no severity error or failure, and finishes inside
its simulated-time limit, so a hung simulation fails rather than running
forever:
make test # every case
make test TESTS=tb_bandpass_filter # one case
make test TESTS=tb_fpp_core_ramp # the full-scale ramp
make test TESTS="tb_xfft_128 tb_cordic_atan2" # a hand-picked set
tb_fpp_core_image_ci runs tb_fpp_core_image on the committed fixture in
tb/stim/image_ci/, tb_fpp_core_image_wrap on the surface fixture in
tb/stim/image_wrap/ and tb_fpp_core_image_lowsig on the low-signal
fixture in tb/stim/image_lowsig/. tb_fpp_core_image itself is not in TESTS
(make test TESTS=tb_fpp_core_image): it runs on the fixture
matlab/cmd/fpp_validate.m writes from a sensor image (see
Validating on a sensor image).
Every simulation that contains transpose_buffer also runs
src/sim_only/transpose_buffer_check.vhd: each word the ping-pong buffer
outputs must equal the input pixels it transposes, frame by frame, or the case
fails with the frame, column group and row. Synthesis never sees it (it sits
behind synthesis translate_off). tb_fpp_core_sustained and
tb_fpp_core_default tag the low bits of pixels with the frame, row and beat
so that every word is unique, which lets the check see any misread across
both banks, and they fail unless it covered every frame.
With TOOLCHAIN := vivado, make test runs the same cases under xsim with
the stubs; XSIM_REAL_IP=1 simulates the vendor's own IP models instead.
Test tiers
| Tier | When | Command (toolchain) |
|---|---|---|
| A | every change | make test (ghdl): the whole suite on the stubs |
| B | before a release | make tier-b (vivado): tb_xfft_128, tb_cordic_atan2, tb_transpose_buffer and tb_fpp_core_short_ramp on the real IP |
| G | before a release, ahead of Tier C | make check-generated (any): make regen on a temporary worktree of HEAD; fails if a generated file changes, a new file appears, or a generated file has no generator; then make check-ifft there (the inverse-FFT field of every lane, GHDL, within 1 LSB of the model's). make check-generated FIGURES=1 also runs make figures there (about 25 minutes), make report included: every committed figure must be rewritten and every figure data file (the report's results.txt and CSVs among them) come out byte-identical. make check-literals: no setting restated as a literal |
| C | before a release or a bitstream, and early after a back-end change | make tier-c (vivado): tb_fpp_core_ramp, tb_fpp_core_image_wrap and tb_fpp_core_image_lowsig on the real IP |
| T | before a release | make impl (vivado) for every offered configuration, closing setup and hold |
Tier A has one known blind spot: the FFT stubs leave a gap between output
frames, while the real inverse FFT streams columns back to back, so a block
after it that needs a gap passes Tier A and fails on the real IP. Every such
block must accept a new column in the cycle after the previous column's last
sample, and its testbench must drive back-to-back input (tb_col_reducer
does). docs/ip-stub-fidelity-policy.org lists where the stubs differ from
the IP and which changes call for Tier C early.
Implementation
fpp_core has no board wrapper yet, so it is implemented out of context:
synthesis inserts no I/O buffers, and fpp_core_ooc is placed and routed on
the target part. There is no bitstream. make impl then runs the checks in
scripts/, and the build fails unless setup and hold are met, the clock is
routed on global resources, and the BRAM read address has no path from the
FTP engine.
make impl # the configured ROI and clock (src/config/fpp_config_pkg.vhd)
make synth # synthesis only
The wrapper builds the RTL defaults, so the ROI is C_N_COLS; the clock
constraint build/fpp_core_clock.xdc is written from C_CLK_PERIOD_NS
before every run, and the build refuses a configuration whose clock cannot
reach the frame rate (C_FRAME_RATE_OK). Switching between the two offered
configurations is the edit described under Design settings.
Timing closure (Tier T), routed on commit 93ed914 (xczu9eg-ffvb1156-2-e,
out of context, Vivado 2021.2); make impl ends with TIMING_CHECK PASS on
both.
| Configuration | WNS (ns) | WHS (ns) | Setup-critical path |
|---|---|---|---|
| 1920 x 128 @ 200 MHz | +0.366 | +0.009 | an inverse FFT core's internal clock-enable path |
| 3840 x 128 @ 300 MHz | +0.065 | +0.010 | wrap_count: cur_spot -> end_col_n (the SPOT_BOUNDS lookup) |
The 300 MHz configuration closes with 65 ps to spare, on the spot-bounds
lookup in wrap_count (spot_aggregator has the same one). Any change near that
path needs a new 300 MHz route.
Reports and checkpoints land in build/nonproject/:
post_synth_utilization.rpt, post_route_timing_summary.rpt,
post_route.dcp and the rest. The Xilinx IP is created from the
VIVADO_IP_* settings in project.mk on every build; only settings that
differ from the IP's defaults are listed, and nothing generated is committed.
On xczu9eg, which has no UltraRAM, the transpose buffer is held in block RAM
(ram_style = "block" in transpose_buffer.vhd).
Changing the FFT length
C_N_FFT in src/config/fft_config_pkg.vhd is the only edit. Then
regenerate what is derived from it and run the suites:
make regen # config_values.txt, coefficient ROMs, every fixture and golden
make test
Everything else follows N on its own:
| What | Rule | N = 128 | N = 64 |
|---|---|---|---|
ROI rows C_N_ROWS |
= N (the FFT runs down a column) | 128 | 64 |
| Forward config word | FWD_INV + SCALE_SCH "01" per stage group (gain N/2^groups = 8) | x"00AB" | x"2B" |
| IFFT output width | 22 + log2 N + 1 | 30 | 29 |
| IFFT narrow shift | 1 + log2 N - groups (same amplitude at amplitude_gate) |
4 | 4 |
col_reducer width |
22 + log2(rows) + 1 | 30 | 29 |
spot_aggregator accumulator |
column width + 6 | 36 | 35 |
Carrier bin DEMOD_K0 |
DEMOD_CYCLES_PER_ROW x N |
32 | 16 |
BPF half-band BPF_BW |
round(BPF_BW_CYCLES_PER_ROW x N) |
6 | 3 |
| Lit rows, first lit row | FRINGE_PERIODS / cycles per row, centred |
36, 46 | 36, 14 |
The carrier and the BPF band are set as spatial frequencies (cycles per row,
demod_config_pkg and bpf_config_pkg), so the same optics give the right
bins at any N. FRINGE_PERIODS describes the projected pattern the
fixtures reproduce: 9 periods of 4 px, the rows outside them gated to zero.
The spot_aggregator emit shift follows the lit rows, not N:
ceil(log2 36) + 1 = 7, so the CORDIC input level does not depend on N.
matlab/lib/config/fpp_config.m reads the same values, evaluated, from
src/config/config_values.txt for the model and every fixture generator.
The coefficient ROMs stop elaboration if they were built for another N, and
the testbenches take their sizes from the packages and refuse a fixture whose
length does not match.
Where the tools run
The framework calls ghdl, vsim, vivado and xsim by name, so make
runs wherever those are installed. When Vivado or Questa run in a container
that mounts the project at a different path, run make inside the
container, because the framework passes absolute paths:
# Vivado / xsim (TOOLCHAIN := vivado). -f freezes the clock for the
# date-limited licence that covers the xczu9eg.
xilinx-dev-env/vivado_docker.sh --cli -f
make impl
# Questa (TOOLCHAIN := modelsim)
altera-dev-env/altera_docker.sh --cli
make test
# GHDL runs on the host
make test
Dependencies
| Dependency | Version proven | Notes |
|---|---|---|
mk/ build framework |
submodule pin | git submodule update --init |
| GHDL | 6.0.0, mcode backend | host; also make regen (regen-config evaluates the packages) |
| Vivado + xsim | 2021.2 | image vivado-image:2021.2 via xilinx-dev-env/vivado_docker.sh |
| Vivado licence for the xczu9eg | node-locked, date-limited | -f (frozen clock) on the launcher |
| Questa-Intel FPGA Starter | 2021.2 | image quartus-c10lp:latest via altera-dev-env/altera_docker.sh, which mounts the licence |
| GNU Octave | 7 or later (proven on 11.3) | M_TOOL := octave (default): make regen and every script in matlab/; no Octave package |
| Matlab (alternative to Octave) | R2018b or later; R2019a for make |
the matlab/ scripts; M_TOOL := matlab runs make regen through matlab -batch; no toolbox; not yet run on this repository |
| GraphicsMagick, Q16 build | 1.3.49 Q16 | Octave's imread for 16-bit TIFF (matlab/cmd/fpp_validate.m); gm version shows the Q depth |
| Python 3 | any recent (proven on 3.14) | make regen (regen-calc) and make check-ifft (tb/model/vcd_ifft_extract.py), so Tier G |
| GNU Emacs | any recent (proven on 31.1) | make regen-calc runs the Emacs Calc notebook of docs/calculus in batch (skipped without Emacs) |
| GitHub Actions secrets | – | the release archive, see Release archive |
Questa notes
- Time resolution. Questa's default resolution is 1 ns and it rounds
without a warning: the testbenches' 3.333 ns clock period would become
3 ns and drift against their
wait for CLK_PERIODstimulus.project.mksetsVSIM_FLAGS := -t fs, the resolution GHDL uses. - FFT stubs and
math_real. The stubs read their twiddles from a table built once at elaboration (fft_stub_pkg), indexed by(k*j) mod N, so nocos~/~sinargument ever leaves one period. Questa's built-inmath_realreportsXLOCAL <= 0.0 after reductionatseverity erroron an exact multiple of 2*pi, where GHDL's VHDL body clamps the same rounding residue silently. - Package constants. A package must not call its own functions in its
constant declarations: GHDL accepts it, Questa rejects it (vcom-1594). The
packages use
math_realthere instead.
Inverse-FFT field against the model (make check-ifft)
make ifft-dump captures the narrowed inverse-FFT samples of every lane
(the values amplitude_gate and col_reducer see) by streaming a VCD from
GHDL into tb/model/vcd_ifft_extract.py, which writes
build/ifft/<fixture>/ifft_obj_field.hex; the sample width comes from
config_values.txt. make check-ifft then writes the model's field on
the same obj_frame.hex (matlab/lib/validate/run_oracle_ifft_dump.m) and fails unless
every sample is within 1 LSB (matlab/lib/validate/check_ifft_field.m; 215 of 2560
samples differ by 1 LSB on ramp_abs_small, the stubs' rounding). It runs
with GHDL on the host whatever TOOLCHAIN is selected, and Tier G runs it.
make check-ifft # ramp_abs_small, 6 ms
make check-ifft IFFT_TB=tb_fpp_core_ramp IFFT_STIM=ramp_abs IFFT_STOP=300ms # full 3840 x 128
The capture is of the stubs; a capture from the real IP (xsim log_vcd
through the same extractor) is an open item in docs/next_steps.org.
Figures
make figures regenerates every figure the model alone produces, each
with the data file beside it (the numbers the figure shows); the table is
in matlab/README.org. Tier G with FIGURES=1 reruns it and compares the
data files, never the PNGs: their pixels depend on the renderer. The
matlab/figures/cmp_spot_phase_* figures come from a Tier C transcript (the real
IP) and are regenerated by hand after a Tier C run.
make report (the last step of make figures, TOOLCHAIN := ghdl) reruns
the two RTL validations behind docs/report/: the spec ramp and the
synthetic wafer at the configured ROI with the spec ramp's spot count,
through matlab/cmd/fpp_validate.m (matlab/cmd/fpp_report.m). Each run's
figures, per-spot CSV and results.txt land in docs/report/<run>/; the
report text quotes results.txt and is updated by hand when a number
moves, which Tier G with FIGURES=1 flags as a differing data file.
Validating on a sensor image
fpp('validate', cfg) checks the RTL on a real sensor image (.tif, one
page or several) or on a synthetic input. It derives the fixture and the
goldens from matlab/lib/model/fpga_1d_ftp.m on the exact frame the testbench
streams, runs tb_fpp_core_image, and writes
matlab/image_runs/<name>/report.org with the input image, the per-pixel
phase map, the per-spot phases sent downstream and the envelope checks
(carrier position, stage headroom, CORDIC input magnitude). The comparison
is tb_fpp_core_ramp's: 300 urad against the full-precision golden, plus n
and flags equal to the model's. Details: matlab/README.org; the recipe
for a capture from the bench, resampled or at the design's period, is in
docs/bench/capture.org, Running and reading the result.
octave --no-gui -q --eval "addpath('matlab'); fpp('validate', struct('source', 'tif', 'tif', '/data/frame.tif'))" # TOOLCHAIN := ghdl: runs the simulation too
matlab -batch "addpath('matlab'); fpp('validate', struct('source', 'tif', 'tif', '/data/frame.tif'))"
make test TESTS=tb_fpp_core_image # the same case by hand, any toolchain
The image source takes the sensor's code width (px_bits, C_PX_BITS by
default) and refuses a pixel above its largest code at native gain, or
clamps it under a gain. tb/stim/image/ and matlab/image_runs/ are not
committed: they can hold real sensor data.
Release archive
A tag rX.Y or rX.Y.Z runs .forgejo/workflows/lib.yml on the home
Forgejo's runner (it can also be run by hand from the Actions tab, which
then publishes the selected branch); no other tag triggers it. It builds
lib.7z from the tagged commit (7z, AES-256, encrypted file names, one
lib/ folder, mk/ included, .forgejo/ left out) and force-pushes it as
the only file of the separate GitHub repository Pcsf/lib. That repository
holds nothing else, does not name this one, and is meant to become public:
its commits carry the Pcsf noreply identity, and the workflow refuses an
archive that contains a home path or an e-mail address. The source lives
on Forgejo only.
| Dependency | Where |
|---|---|
LIB_PASSPHRASE |
Actions secret on this repository, 20+ characters |
LIB_TOKEN |
Actions secret: a GitHub token with Contents write on Pcsf/lib only |
LIB_REPO |
Actions variable, optional: another owner/name as target for a dry run |
Release numbers are rX.Y.Z, always all three parts (r1.0.0, never r1.0):
| Part | Bumped for |
|---|---|
| X | a change that breaks the interface (ports, register map, output format) |
| Y | a design change: RTL, IP settings, stimulus or precision behaviour |
| Z | no design change: figures, documentation, build and test tooling |
Before a release tag, run Tier B, Tier G, Tier C and Tier T: every configuration this README offers (1920 @ 200 MHz, 3840 @ 300 MHz) must close setup and hold on the release tree, or be declared unsupported here, with its measured WNS, before the tag.
Release: git tag -a r1.0.0 -m r1.0.0 && git push origin r1.0.0. Extract
with 7z x lib.7z (or 7-Zip, Extract Here) and the passphrase.
Documentation
| Document | Description |
|---|---|
| Results | One page: precision, wrap count, timing, with the key figures |
| Results report | 1920 x 128 at 5000 fps with 151 spots: inputs, precision, real-IP evidence, timing, resources, limits |
| Bench capture | A printed fringe through a camera lens as input to fpp_validate: resampling another period, or the design's period with the lens as anti-alias filter; the A4 print targets |
| docs/icd/fpp_core_icd.org | Interface contracts, register map, reset sequences |
| Module summary | Per-module summary of the pipeline |
| Precision budget | Data-path widths and the CORDIC range for the 300 urad spec |
| Clock and ROI | 200 MHz at 1920 columns, 300 MHz at 3840 |
| IP stub fidelity policy | Test tiers: what the stubs can and cannot catch |
| Wrap-count evaluation | The 2*pi wrap count on the bit-accurate model |
| Design calculations | Timing, buffer, lanes, resources, device fit, latency, physics, noise, power, what-ifs |
| Next steps | Open items |
| matlab/README.org | The entry point fpp.m, the layers of lib/ and who calls whom, image validation, figures |