FPGA 1D-FTP fringe-phase fitting core (ABS_PHASE) with per-spot 2*pi wrap count
  • VHDL 65.5%
  • MATLAB 30.2%
  • Makefile 1.9%
  • Shell 1.1%
  • Python 0.7%
  • Other 0.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Paulo Ficagna badc480fd7 Keep .forgejo/ out of the release archive
The personal-data guard matched the workflow file itself; the directory
pattern needs no trailing slash for git archive to skip the tree.
2026-11-03 14:36:23 +01:00
.forgejo/workflows Publish lib from the Forgejo runner instead of GitHub Actions 2026-11-03 14:11:22 +01:00
constraints XDC: constrain the ports that exist 2026-10-29 07:59:24 +01:00
docs docs/bench: the command for a real capture, both runtimes, and the two-stage split without a simulator 2026-10-30 22:12:19 +01:00
matlab docs: M_TOOL := matlab runs every make target of the matlab/ side with Matlab on Linux 2026-10-30 22:06:50 +01:00
mk@7f399b0088 Rewrite the docs to the current design; add Tier G 2026-10-29 06:38:03 +01:00
scripts check_generated: a .m file is a generator, never a figure data file 2026-10-30 22:17:52 +01:00
src matlab: fpp.m entry point, cmd/ commands, lib/ in layers, golden and filter seams 2026-10-30 22:00:51 +01:00
tb matlab: fpp.m entry point, cmd/ commands, lib/ in layers, golden and filter seams 2026-10-30 22:00:51 +01:00
.gitattributes Keep .forgejo/ out of the release archive 2026-11-03 14:36:23 +01:00
.gitignore matlab: fpp.m entry point, cmd/ commands, lib/ in layers, golden and filter seams 2026-10-30 22:00:51 +01:00
.gitmodules Import fpp-core at eval/spot-unwrap (62a4d33) 2026-10-28 21:05:48 +01:00
Makefile Import fpp-core at eval/spot-unwrap (62a4d33) 2026-10-28 21:05:48 +01:00
project.mk docs: M_TOOL := matlab runs every make target of the matlab/ side with Matlab on Linux 2026-10-30 22:06:50 +01:00
README.org Publish lib from the Forgejo runner instead of GitHub Actions 2026-11-03 14:11:22 +01:00

fringe-fitting – Fringe Pattern Profilometry Core

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 a G_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:

  1. spot_aggregator sums 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.
  2. spot_norm scales 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 W1C ERROR_FLAGS[5] SPOT_RANGE. 4-clock latency.
  3. A single CORDIC (Translate, Input_Width=18, Output_Width=24, Iterations=18, Round_Pos_Inf, 26-clock latency) gives theta = 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:

  1. A column is tracked when |Re| + |Im| > COL_THR; it is scaled to 18 signed bits (one shift for Re and Im, as in spot_norm).
  2. 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).
  3. 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_count against matlab/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 in matlab/lib/model/gen_bpf_coeffs.m).
  • BPF_BW_CYCLES_PER_ROW = 0.046875, so the half-bandwidth BPF_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_PERIOD stimulus. project.mk sets VSIM_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 no cos~/~sin argument ever leaves one period. Questa's built-in math_real reports XLOCAL <= 0.0 after reduction at severity error on 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_real there 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