Three sub-checks in compare_independent.py were red because the test
inputs assumed an UNSCALED FFT but PR-O moved both the RTL xFFT path
and fpga_model.FFTEngine to LogiCORE v9.1 Scaled mode (one >>>1 per
butterfly stage with conv-rounding, total /N applied across LOG2N
stages). At small input amplitudes the per-bin output rounded to zero
and the test invariants no longer described meaningful behaviour.
The fpga_reference.py side already mirrors the scaled-mode convention
(np.fft.fft(x)/n on forward, ifft on inverse — see line 104, 137-138,
207). The fix is purely in the test inputs:
- FFT-2048(impulse): amp 1000 → 32000 (≈ Q15 max). Expected per-bin
is now round(amp/N) = round(32000/2048) = 16 (banker's). The
impulse has b=0 at every butterfly so there is no twiddle
interaction; banker's rounding keeps every bin within ±1 LSB.
Tightened tolerance from 5 to 2.
- MF peak position + MF peak-to-median: amp 200 → 4000. Chain
output peak under scaled-mode is correlation/N² ≈ pulse_len*amp²
/N² = amp²/16384 (256 / 4_194_304). At amp=200 the peak collapsed
to 2.4 (mostly Q15 quantization noise — argmax wandered to bin 2);
at amp=4000 the peak rises to ≈ 977 with sidelobes near LSB
floor. Peak-to-median ratio observed: 974 vs threshold 5.
Runtime verification: compare_independent.py 13/13 PASS standalone.
Full FPGA regression: 36/1/6 → 37/0/6 (the single FAIL was this test;
no other tests touched).
Stage-7 ADC chain audit. radar_receiver_final.v's
adc_overrange_sticky_400m had no clear path other than full system
reset_n — once latched, the only way to acknowledge the AD9484
overrange flag was a reboot. The DDC's analogous diagnostic flags
(cdc_cic_fir_overrun_sticky, mixer_saturation) already clear on the
DDC's `reset_monitors` port; the OR sticky in the receiver wrapper
was the lone outlier.
Introduce a shared `clear_monitors_pulse` wire at the receiver scope
and route it both to the OR sticky's clear branch and to the DDC's
`reset_monitors` port (replacing the literal `1'b0`). Today the
wire is tied 1'b0, preserving the prior reset_n-only behaviour bit-
for-bit. When a future host opcode for "clear diagnostic stickies"
lands, both the receiver-level OR sticky and the DDC's internal
sticky flags clear from the same pulse — no per-flag re-plumbing.
Local FPGA regression 36/1/6 unchanged (1 FAIL = pre-existing T-6
drift, deferred PR-M.4).
Stage-7 ADC chain audit. ddc_400m.v line 273-274 converted AD9484
offset-binary 8-bit codes to MIXER_WIDTH-bit signed via
`(adc << 9) - (8'hFF << 8)`. The right operand evaluates to
`0xFF / 2 = 0x7F` (integer divide drops 0.5), so the formula
subtracted 65280 instead of the correct mid-scale offset 65536:
- code 0x80 (0V analog, mid-scale) → +256 (expected 0)
- code 0x00 (-fs) → -65280 (expected -65536)
- code 0xFF (+fs - 1 LSB) → +65280 (expected +65024)
A 0.5-LSB DC bias on the AD9484 stream is functionally invisible
in production: after the 120 MHz NCO mixer it lands at ±120 MHz,
which the CIC + FIR LPF (~50 MHz rolloff) rejects. But the offbin
and twoc paths disagreed on the same analog input, and any reuse
of `adc_signed_offbin` outside the mixer chain would carry the
bias.
Replace with an MSB-flip + sign-extend + 9-zero pad that mirrors
the twoc branch: `{~msb, ~msb, low7, 9'b0}`. Mid-scale code 0x80
now produces 0 exactly, matching the twoc path bit-for-bit.
Local FPGA regression 36/1/6 unchanged (1 FAIL = pre-existing
T-6 drift, deferred PR-M.4).
Stage-7 ADC chain audit. The MMCM SIMULATION model in adc_clk_mmcm.v
takes 4096 DCO cycles (~10 µs at 400 MHz) to assert mmcm_locked. The
existing TB waited only ~5 cycles after each reset deassert, so the
gated reset_n_400m never released and adc_data_valid_400m stayed low
for every test group past the initial reset checks.
Expose mmcm_locked as a new module output on ad9484_interface_400m
(real path) and ad9484_interface_400m_stub (sim path; tied high one
DCO cycle after reset deassert since the stub has no MMCM). Connect
it through to tb_ad9484_xsim.v and add a `wait_for_adc_ready` task
that waits on the lock signal plus 6 DCO cycles for the 2-FF
lock-sync, 2-FF reset-sync, and pipeline drain. Apply the task at
each of the five reset cycles in the testbench.
radar_receiver_final.v: tie the new port off (.mmcm_locked()) — host
status pipeline doesn't surface lock yet, deferred for a future
status-word widening.
Local iverilog regression (36/0/7) clean. xsim verification of the
xsim-only TB itself is pending (remote Vivado host).
API hygiene. setADTR1107Mode flips ADTR1107 PA/LNA bias registers but
does NOT touch the per-channel ADAR1000 RX/TX enable bits. Production
always reaches it through setAllDevicesTXMode / setAllDevicesRXMode,
which emit both halves. Leaving setADTR1107Mode public after F-6.1
removed the other public mode-switch wrappers invited a future caller
to invoke it directly and end up in a mismatched bias-vs-enable state.
Move the declaration to the private section with a short comment
explaining why the wrappers are the only sanctioned entry point.
Latent bit-mask hygiene gap. setADTR1107Mode(TX) was asserting BIAS_EN
(bit 5) without first clearing LNA_BIAS_OUT_EN (bit 4); the RX branch
mirrored the bug. On any TX→RX→TX (or symmetric) transition through
this register both PA and LNA bias outputs would end up enabled
simultaneously. Production today only ever calls one direction at boot
and the opposite at shutdown — never both during normal operation —
so the bug was unreachable, but a future per-chirp SPI mode switch
would trip it.
Now each branch resetBit's the opposite enable before asserting its
own. 1 line per branch loop (not 1 per device — used the existing
for-dev loop).
Stage-6 ADTR1107 audit cleanup. Delete 4 unused public methods plus
their 2 internal helpers from ADAR1000_Manager.cpp/h. Production boot
goes through main.cpp's C-style systemPowerUpSequence(), so the C++
ADAR1000Manager::powerUpSystem / powerDownSystem / switchToTXMode /
switchToRXMode wrappers had zero call sites; the same was true of the
setPABias / setLNABias helpers, only ever invoked from the dead
switchTo* paths. -130 LOC, no behavioral change.
kPaBiasRxSafe is intentionally KEPT — it is live-used inside
setADTR1107Mode(RX) as the safe PA bias when transitioning to RX.
Drop 5 antenna sims that were either dead-end topologies or got superseded
by validated v3 designs. The kept set documents the path the project
actually took: edge-fed series-fed row on 0.508 mm RO4350B (drop-in
old-Gerber replacement, 100 MHz BW), with the single-element edge-fed
inset and probe-fed comparators kept as design-space reference points,
and production_beams_verify retained as the D-series audit artifact for
the ADAR1000 setBeamAngle() dead-code finding.
Removed:
Quartz_Waveguide.py
openems_quartz_slotted_wg_10p5GHz.py
Early waveguide explorations, abandoned when the project moved
to a planar patch array.
aperture_coupled_aeris10_v2.py
First openEMS attempt at the AERIS-10 design point. Achieved
60 MHz BW with residual reactance; superseded by the v3 paths
(probe-fed 180 MHz BW, edge-fed-inset 180 MHz BW, edge-fed
series-fed row 100 MHz BW) that landed shortly after.
array_factor_adar1000_aeris10.py
Pure-numpy ADAR1000 phase-quantization sim used to find that
setBeamAngle()'s 4-elem broadcast produces grating lobes and
that the function is dead in production (replaced by
initializeBeamMatrices + setCustomBeamPattern16). Findings
already actioned in commit 38ee73a (D1/D6/D7 tombstone removal)
and recorded in project_aeris10_setBeamAngle_bug_2026-05-04.md.
probe_fed_array_aeris10_v3.py
4×4 probe-fed multi-port openEMS array for mutual-coupling
characterisation. Mooted now that the chosen production path
is the 1×8 edge-fed series-fed row, not a probe-fed array.
Kept:
edge_fed_row_aeris10_v3.py — chosen production design (1×8 row)
edge_fed_row_nf2ff_aeris10_v3.py — far-field for the row
edge_fed_aeris10_v3.py — single-element baseline for the row
probe_fed_aeris10_v3.py — alt-design comparator (180 MHz BW)
production_beams_verify_aeris10.py — D-series audit artifact
Lint: ruff full-repo still clean. No cross-imports between kept and
dropped scripts (verified pre-removal).
Closeout pass for the G-series 3-ladder chirp + adaptive-escalation work.
Cleanup, watchdog/fallback, lint, full regression — final sign-off.
Cleanup + watchdog/fallback: already wired during earlier audit waves
(track watchdog in chirp_scheduler RP_DEF_TRACK_WATCHDOG_FRAMES, RESERVED
fallback in plfm_chirp_controller_v2, range-decim watchdog in
radar_system_top with gpio_dig7 surfacing, F-3.* MCU error path).
Verified — no residual TODO/FIXME in production RTL or MCU.
Regression infra: tb/cosim/compare_independent.py SKIP-detection bug —
importlib.util.find_spec("scipy.signal") raises ModuleNotFoundError when
the parent scipy package is itself absent (instead of returning None as
the surrounding logic assumed). Wrap in try/except so the regression
runner gets the intended rc=2 SKIP marker rather than a crash that masks
the rest of the script.
Lint sweep: ruff full-repo → 0 errors. Two changes:
- pyproject.toml broadens 5_Simulations/Antenna/**.py exemption from
just T20+ERA to the full set of script-ergonomics rules
(RUF001/002/003 Greek µ/λ/π/θ in physical-units strings, E501 long
matplotlib/numpy lines, RUF005/015/046, E70x one-line setup, B007
tuple-unpack loop vars, B905, BLE001 diag try/except, C401, RET504,
SIM118, PERF40x, ARG001, E402). These are sim/analysis scripts, not
production code — keep substantive bug rules (F unused, B core
bugbears) but drop stylistic noise.
- Auto-fix sweep: 31x F541 (f-string-no-placeholder), 3x F401 (unused
sys import), 2x F841 (dead leftover ref_pat / phases_quant in
array_factor_adar1000_aeris10.py).
.gitignore: cover 9_Firmware/9_2_FPGA/tb/cosim/mf_chain_autocorr.csv
(matched_filter cosim writes here now; was already covered for tb/ but
not tb/cosim/).
Regression baseline (radar_venv):
FPGA : 42/43 — 1 pre-existing T-6 drift cosim fail surfaced by the
SKIP fix above. Three sub-checks now red because PR-O moved
xFFT/MF chain to LogiCORE v9.1 *Scaled* mode (1/2 per stage,
1/2^11 total for N=2048) but compare_independent.py's invariants
(FFT-impulse uniform-spectrum, MF peak-at-injected-delay, MF
peak/median ≥ 5) were written assuming UNSCALED FFT. Not
introduced by this PR — was hidden by the SKIP-detection crash.
Defer to PR-M.4: redesign T-6 invariants (or input amplitudes)
to match scaled-mode arithmetic.
MCU : 34/34 binary suites pass.
GUI : test_v7 150/150 pass.
uv.lock: scipy resolution catch-up (declared in pyproject dev group all
along; lock just hadn't been refreshed after pyproject edits landed).
Bench-side checks: none — this PR is repo hygiene, no firmware/RTL
behaviour change.
Adapts Serhii's TestLiveReplayPhysicalUnitsParity (f895c02 on develop) to
the post-PR-Q.6 worker structure on feat/dual-range-v2.
The original f895c02 fix was for a bug where RadarDataWorker._run_host_dsp
read self._settings.velocity_resolution (RadarSettings default 1.0 m/s/bin)
while ReplayWorker used WaveformConfig (~5.343 m/s/bin) — live GUI under-
reported velocity by ~5.34x vs replay. PR-Q.6 unified both paths through
extract_targets_from_frame_crt(frame, self._waveform, ...) so the
functional bug is already gone here, but no regression test guarded the
contract until now.
Adapted assertions: AST walk of workers.py asserts that
RadarDataWorker._run_host_dsp and ReplayWorker._emit_frame both
- call extract_targets_from_frame_crt (or self._extract_targets, which
ReplayWorker.__init__ binds to it) with self._waveform as an arg, AND
- do not read self._settings.{velocity,range}_resolution.
Headless-CI-safe via ast.parse on workers.py — no v7.workers import,
no PyQt6 dependency in the test path.
Test result: 4/4 new tests pass; full test_v7 150/150 pass (19 skipped,
PyQt6-gated as expected).
Co-Authored-By: Serhii <jshmitz@me.com>
F-5.1: revert PWM scaffolding to binary DELADJ. Schematic-verified:
PG7/PG13 on STM32F746ZGT7 have no TIM3 alternate function (Port G AFs are
FMC/ETH/USART6/SAI2/SDMMC2 — no TIMx routes), and the FreqSynth-board
DELADJ net has only a 200 kOhm pulldown (R22, R35) — no series-R +
shunt-C LPF for PWM-to-DC. The 3979693 (Bug #5) + c466021 (B15) PWM
scaffolding was a false-fix; 5fbe97f's original honest TODO matched the
actual hardware. Delete htim3, MX_TIM3_Init, start/stop_deladj_pwm,
phase_ps_to_duty_cycle. Rewrite test_bug5 for binary; delete test_bug15.
F-5.2: split ADF4382A ref_div per device. RX 10.38 GHz / 300 MHz = 34.6 is
fractional mode, but ADF4382_PFD_FREQ_FRAC_MAX = 250 MHz — driver does
not reject the out-of-spec config, ldwin_pw silently left at 0. Set
rx_param.ref_div = 2 -> PFD = 150 MHz, in spec. TX unchanged (integer).
F-5.3: free prior tx_dev/rx_dev in Manager_Init before re-allocating. The
recovery dispatch on TX/RX unlock calls Manager_Init again; previous
adf4382_dev allocations were leaking. Mirrors F-4.5 fix for AD9523.
F-5.4: fix upstream adf4382_remove() — only freed dev struct on FAILED SPI
removal (success path leaked) and always returned 0. Now: NULL guard,
unconditional free, propagate ret.
F-5.8: lock-detect uses register reg[0x58] LOCKED bit as authoritative.
GPIO disagreement still logged via DIAG_WARN but no longer flips the
result — a mis-routed GPIO LKDET would otherwise trigger false-unlock
recovery loops.
F-5.10: drop stale "EZSYNC" diagnostic string (post-C-14a residue).
Bench-side checks for first power-on:
- Scope PG13 (TX_DELADJ) and PG7 (RX_DELADJ) — both should be HIGH (3.3V)
after SetPhaseShift(500,500) runs at boot.
- Confirm both ADF4382A LOs lock with PFD=150 MHz on RX (was 300 MHz).
Lock-time may be slightly longer; phase-noise sidebands shift.
- Confirm no false-unlock storms on the recovery path — the GPIO LKDET
disagreement DIAG_WARN should no longer flip the lock decision.
Regression: tests/ make test 34/34 PASS (was 35/35 baseline; -1 from
test_bug15 deletion as planned).
The F-4.1+4.2+4.7 patch (ddc0df4) made ad9523_init() run before the
user pdata overrides, which means pll1_bypass_en=0 (the previous
override) is now actually honoured by the driver. Combined with the
fact that pll1_charge_pump_current_nA and pll1_feedback_div were
never set in main.cpp, PLL1 would be expected active but couldn't
lock (CP=0) — ad9523_status() with bypass_en=0 checks PLL1+REFA+REFB
bits, so the failure surfaces, returns -1, and configure_ad9523()
halts boot at main.cpp:1742.
Option A: set pll1_bypass_en=1. VCXO free-runs on its own crystal
stability; ad9523_status() skips PLL1 checks. Boot path is now
clean. Trade-off: VCXO frequency drifts with temperature (~±20 ppm
over -40°C..+85°C for typical XO) — acceptable for first-flight
checkout, but eventual production should re-enable PLL1 (Option B,
deferred to F-4.3/4.4 with measured loop-filter values).
Comment notes the deferral and what's needed before flipping to
bypass=0 (CP current + loop filter rzero tuned to VCXO Kvco).
Regression: 86/0.
F-4.5: ad9523_setup() malloc's both an ad9523_dev and a no_os SPI
descriptor (ad9523.c:430,435). Previously the dev pointer was local
to configure_ad9523() and fell out of scope on return — every
recovery cycle (ERROR_AD9523_CLOCK → re-run configure_ad9523) leaked
one struct + one SPI desc. STM32F7 heap is bounded; sustained
brown-out flapping would eventually exhaust it. Move dev to a file-
scope `g_ad9523_dev` and call ad9523_remove() at the top of
configure_ad9523() to free the previous instance before re-setup.
Initial boot path is unaffected (g_ad9523_dev=NULL → remove call
gated by NULL check).
F-4.6: ad9523_setup() called ad9523_calibrate() but discarded its
return value (ad9523.c:707). VCO calibration can fail silently — if
the target VCO is outside the 3.6-4.0 GHz band (e.g. F-4.1 wipe left
PLL2 N=16, target 1.6 GHz), calibrate would report failure but setup
still proceeded to ad9523_status(), where PLL2_LD might pass
spuriously. Capture and propagate the calibrate return so a failed
calibration aborts setup with a clear non-zero status code instead
of being absorbed.
Both fixes are mechanical and don't change correct-path behaviour.
Regression: 86/0 (mocks bypass real driver, so F-4.6 is not covered
by tests; F-4.5 changes are in main.cpp and don't trip mocked
configure_ad9523).
Three coupled bugs in configure_ad9523() that together prevented the
AD9523 from producing the labelled output frequencies:
F-4.1: ad9523_init() unconditionally overwrites every field in the
caller's pdata (vcxo_freq=0, pll1_bypass_en=1, pll2_ndiv_b_cnt=4,
all channel fields). Calling it AFTER customization wiped every user
value. Reorder: call ad9523_init() before the pdata.X = Y block; user
overrides land on top of ADI defaults instead of being wiped.
F-4.2: pll2_vco_diff_m1 / m2 are required (range 3..5 per datasheet)
but were left at 0 from memset. The driver's AD_IFE() macro promotes
m=0 to M_PWR_DOWN_EN, killing channels 4-9 (ADC, SYNC, FPGA system
clock, DAC). Set m1=m2=3 explicitly.
F-4.7: AD9523 has no VCO-direct path for OUT4-OUT9; channels source
M1 or M2 only (datasheet + ad9523_vco_out_map register definitions
confirmed). With VCO 3.6 GHz and m1=3, channel dividers see 1.2 GHz,
not 3.6 GHz — every channel_divider in main.cpp was 3x too large.
Updated values:
OUT0/1 (ADF4382A REF, 300 MHz): /12 -> /4
OUT4/5 (ADC + FPGA_ADC, 400 MHz): /9 -> /3
OUT6 (FPGA SYSCLK, 100 MHz): /36 -> /12
OUT7 (FPGA TEST, 20 MHz): /180 -> /60
OUT8/9 (SYNC, 60 MHz): /60 -> /20
OUT10/11 (DAC, 120 MHz): /30 -> /10
m1=3 is the unique choice for this labelled frequency set (m1=4 fails
OUT4, m1=5 fails OUT0/1).
PLL1 (F-4.3/4.4) is not addressed here — pll1_bypass_en=0 with
pll1_charge_pump_current_nA still 0 means PLL1 won't lock and status()
will report it. Decide bypass strategy before bench.
Test mocks (ad_driver_mock.c) bypass the real driver, so this is not
caught by make. Regression: 86/0 (unchanged).
Bench-verify OUT4=400MHz and OUT6=100MHz with scope before trusting
downstream. F-1.10 (which crystal is fitted on X5/X6) goes in the
same bench session — F-4.7 resolution shows 100 MHz VCXO is the only
math-coherent choice regardless of BOM document.
v2 covers the new Stack_Hybrid stackup re-simulation: probe-fed v3
(180 MHz BW, drilled vias) and edge-fed row v3 (100 MHz BW, drop-in
old-Gerber replacement) — both validated on 0.508 mm RO4350B, with
4-panel summary dashboards per variant + NF2FF / array-factor
verification.
reports.html updated to point all three filename references to the
v2 PDF.
Previously the array sim only excited a single inner element
(DRIVEN_X / DRIVEN_Y). Adds DRIVEN_PORTS env var accepting a
comma/semicolon-separated list of "i,j" pairs that are all excited
in-phase with equal amplitude — models a perfect 1:N corporate
splitter feeding an N-patch sub-array.
Example: DRIVEN_PORTS="0,0;1,0;2,0;3,0;0,1;1,1;2,1;3,1" excites a
4-cols × 2-rows sub-array anchored in the corner.
S-parameter post-processing reframed for the multi-driven case:
each excited port reports active-S11 (uf_ref/uf_inc with all driven
ports active); each non-excited port reports S relative to a
representative driven port's incident wave (all driven ports have
equal amplitude so any reference works).
Backwards-compatible: empty DRIVEN_PORTS reverts to single-port
DRIVEN_X / DRIVEN_Y behaviour.
The v1-era tb_cross_layer_ft2232h.v cosim TB no longer matches
production after the protocol-v2 / opcode dispatch rework (PR-G).
Equivalent v2 coverage now lives in the FPGA regression's
tb_usb_protocol_v2.v and tb_system_opcodes.v.
Removed:
- tb_cross_layer_ft2232h.v (716 lines)
- Tier 2 (Verilog cosimulation) from test_cross_layer_contract.py
- iverilog/vvp tool detection and CI install step in ci-tests.yml
Tier 1 (static parser) and Tier 3 (C stub execution) remain. CI
no longer needs apt-get install iverilog.
contract_parser.py updated to reflect the slimmer two-tier model.
Production firmware never used SYNC_METHOD_EZSYNC — both callsites
(main.cpp:938 recovery, main.cpp:1955 boot) pass SYNC_METHOD_TIMED.
The original audit C-14 flagged TX/RX SPI skew in EZSync's trigger
sequence, but the path was dead from production; only test_bug3
referenced it for spy-harness regression coverage.
Removed:
- SYNC_METHOD_EZSYNC enum value
- ADF4382A_SetupEZSync function (and declaration)
- ADF4382A_TriggerEZSync function (and declaration)
- EZSync branch in ADF4382A_Manager_Init (collapsed to unconditional
SetupTimedSync call)
- test_bug3_timed_sync_noop.c Test C (EZSync regression coverage)
Production header and test shim header both cleaned. SyncMethod enum
kept as single-value to avoid touching the 7 other test callers that
pass SYNC_METHOD_TIMED.
Residual concern (separate from original C-14): ADF4382A_TriggerTimedSync
uses the same TX-then-RX sw_sync SPI sequencing pattern as the deleted
EZSync trigger. ~5 µs SPI gap between TX-armed and RX-armed means TX
and RX may capture different SYNCP/SYNCN edges (60 MHz cycle = 16.7 ns,
~300 edges in the gap). External SYNCP only provides simultaneity if
both devices are armed before a common edge. Hardware bench-test
required to confirm operational tolerance; cannot fix in firmware
without DMA SPI burst rewrite.
Regression: 86/0 (matches baseline).
Audit of how the ADAR1000 is actually steered in production. Reproduces
main.cpp:initializeBeamMatrices() (PHASE_DIFFERENCES, matrix1, matrix2,
vector_0) verbatim in Python and runs the patterns through the same array-
factor pipeline used for the firmware-vs-correct comparison.
Key findings — context for fixing the setBeamAngle() bug without regression:
1) setBeamAngle() is DEAD CODE in production. grep for callers across the
whole tree returns only the definition itself (ADAR1000_Manager.cpp:215),
the header declaration (line 58), and one comment in ADAR1000_AGC.cpp
referencing the sign convention. main.cpp does NOT call it. The 4-phase
broadcast bug exposed in commit 2f4d45c is therefore latent, not active.
Fixing setBeamAngle() has zero risk of changing production behaviour.
2) Production path: initializeBeamMatrices() (main.cpp:467) computes
matrix1[bp][el] = degTo7Bit(el * phase_differences[bp]) for all 16
elements properly (no 4-broadcast). main.cpp's runRadarPulseSequence()
then calls setCustomBeamPattern16(matrix1[bp], TX/RX) → 16-element
progressive phase reaches the chips correctly. This part is right.
3) HOWEVER — the production tables themselves have separate concerns:
a) SIGN CONVENTION MISLABEL: comment says "matrix1 = positive steering
angles" but math+sim show positive phase_diff steers to NEGATIVE θ.
matrix1[bp=0] (phase_diff=+160°) → actual peak -62°.
Either the comment is wrong, or hardware wiring inverts what's
labeled "+elevation" — needs a hardware test to confirm.
b) ASYMMETRIC INDEXING: matrix2 uses phase_differences[bp + 16] which
gives matrix2[bp=0]=-3.4° while matrix1[bp=0]=-62°. So as bp goes
0→14, matrix1 zooms in toward broadside (-62°→-4°) while matrix2
zooms out (+4°→+62°) — they're NOT mirror images. Symmetric mirror
would be phase_differences[30 - bp].
c) NON-UNIFORM COVERAGE: phase_differences[] follows 160/n pattern
(160, 80, 53.33, 40, 32, ...). After sin⁻¹ this gives 17 unique
scan angles spanning -62°..+62° with a 36° GAP between -62° and
-26°, but 2° spacing near broadside. May be intentional (oversample
near nominal target line) but flag for confirmation.
d) WORST-CASE SLL at extreme scan (matrix1[0] → -62°): only -2.9 dB.
Main beam barely clears sidelobes — typical at near-grazing scan
due to embedded element pattern roll-off.
e) initializeBeamMatricesWithSteeringAngles() (main.cpp:1611) is also
dead code. It computes the same thing two different ways. Safe to
remove or merge during cleanup.
Recommendation for fix sequence (low → high risk):
i. Fix setBeamAngle() to call setCustomBeamPattern16() with proper
16-element table (or mark deprecated). Zero production risk.
ii. Add unit test that runs setBeamAngle and verifies the resulting
phase codes match a known-good 16-element progression.
iii. Update the misleading comments in initializeBeamMatrices() to say
what the matrices ACTUALLY do (the sign convention).
iv. Hardware test BEFORE touching matrix2 indexing or phase_differences
distribution — these may be deliberately tuned to platform mounting.
PART 1 — edge_fed_row_nf2ff_aeris10_v3.py:
Far-field analysis of the 1x8 series-fed row at 10.520 GHz to discriminate
broadside from scanned operation. Adds an nf2ff probe box to the verified
TX-centered design point (CONN_LEN=8.15) and computes the radiation pattern.
Result: E-plane (along array y) main lobe at θ=0.0°, BW3dB=14°
H-plane (perpendicular) main lobe at θ=0.0°, BW3dB=44°
First sidelobe -12 dB at ±18° (theoretical N=8 uniform: -13 dB)
→ BROADSIDE CONFIRMED at the operating mode.
openEMS NF2FF API notes baked into the script:
- CalcNF2FF expects theta/phi in DEGREES, converts internally.
- Prad/Dmax are sign-buggy in this version; pattern shape (E_norm) is
what we use for broadside identification.
- EndCriteria is 10·log10 energy ratio (so -40 dB → EndCriteria=1e-4).
PART 2 — array_factor_adar1000_aeris10.py:
Phased-array beam-forming verification using the ADAR1000 firmware's actual
phase-shifter codes. Combines the cached single-row embedded-element pattern
with a 16-element x-axis array factor at d=λ/2=14.286 mm pitch (matches
firmware's `element_spacing = wavelength/2` constant).
Replicates ADAR1000_Manager.cpp:714-729 (calculatePhaseSettings) and the
setBeamAngle() loop that broadcasts the 4-phase pattern to all 4 chips.
Compares against the correct 16-element progressive phasing.
CRITICAL FIRMWARE BUG SURFACED BY THE SIM:
setBeamAngle(angle) computes only 4 phases (one per chip channel), then
applies the same 4-phase pattern to all 4 chips. The intended 16-element
beam-steering never actually happens.
At cmd 15°, the firmware writes: [0, 17, 33, 50] × 4 = repeating pattern
Correct 16-elem progression: [0, 17, 33, 50, 66, 83, 99, 116, 5, 21,
38, 54, 71, 87, 104, 120]
Sim consequences (H-plane peak vs commanded):
cmd +0° → fw peaks +0° (correct) / correct +0°
cmd +5° → fw peaks +0° (NO STEERING) / correct -4°
cmd +10° → fw peaks +0° (NO STEERING) / correct -10°
cmd +15° → fw peaks +0° (NO STEERING) / correct -14°
cmd +20° → fw peaks -28° (locked grating) / correct -20°
cmd +30° → fw peaks -30° (coincidence) / correct -30°
cmd +45° → fw peaks -30° (locked) / correct -44°
Why: the same 4-elem pattern broadcast to 4 chips = effective super-pitch
d_super = 4d = 2λ, which generates grating lobes at sin(θ_g)=±0.5±sin(θ_0).
For |cmd|<18° those grating lobes coincide with the fixed broadside peak,
so the array radiates broadside regardless of commanded angle. For larger
commands the beam jumps to ±30° (the grating-lobe direction), not the
intended target.
The proper function setCustomBeamPattern16() (ADAR1000_Manager.cpp:636)
exists but isn't called by setBeamAngle(); fix is to either route through
it from a host-computed 16-element table, or extend calculatePhaseSettings
to produce 16 phases. Tracking under a separate beam-forming PR (not
antenna sim).
Other radar-engineer checks (all PASS for the antenna with proper phasing):
- Phase quantization: 7-bit (2.8125°/LSB) adds <0.2 dB SLL degradation
- Grating lobes: d=λ/2 → none in real space at any scan angle 0..90°
- Scan loss: matches cos(θ) ideal up to ~45°, then EEP roll-off dominates
- Beam pointing: sign-flipped cmd↔peak (cmd +X° peaks at -X°) — wiring
convention; either negate phase_shift in firmware or invert in host
- Null steering: phase-only synthesis places -30 dB null at θ=20° while
keeping main beam at broadside — confirmed feasible
Sweep CONN_LEN at PROFILE=balanced to land the operating-mode dip on the
chirp-band center instead of 60 MHz above it. Sensitivity is df/dCONN ≈
-0.20 GHz/mm (longer line → lower op freq).
CONN=8.00 → dip 10.560 GHz, S11@10.520 = -12.6 dB (above TX)
CONN=8.15 → dip 10.520 GHz, S11@10.520 = -18.8 dB (TX-centered)
CONN=8.20 → dip 10.510 GHz, S11@10.510 = -18.4 dB (TX low edge)
CONN=8.25 → dip 10.500 GHz, S11@10.500 = -18.4 dB (LO-centered)
CONN_LEN=8.15 wins because the radar TX 10.510-10.530 GHz sees -17 to -19 dB
symmetrically across the band, with -15 dB margin at the LO frequency. -10 dB
BW spans 10.470-10.580 GHz (110 MHz). Pitch 15.10 mm vs old Gerber 15.01 mm.
Daisy-chain validation for 2-layer 0.508 mm RO4350B stackup. Row of 8 patches
edge-connected via 8.0 mm microstrip segments (pitch 14.95 mm matches old
Gerber). With direct edge feed on patch 0 (no inset; inset would drop the row
input Z to ~6 Ω), the natural input impedance at the operating mode is ~80 Ω,
close enough to 50 Ω for direct match without a quarter-wave transformer.
Topology behaves as a finite periodic resonator with N=8 modes (~0.5 GHz
spacing) and a stopband centered on the patch self-resonance. Operating point
is the top-below-stopband mode at 10.56 GHz: S11 = -22.5 dB, Zin = 79.9 - j3 Ω.
-10 dB BW spans 10.51-10.61 GHz (100 MHz), covering the 10.510-10.530 GHz
chirp band with S11 = -10.4 to -14.6 dB across that interval.
Mesh sensitivity: sanity profile (lambda/18) gave a misleading deepest-dip at
11.4 GHz; balanced (lambda/25) is required to land the operating-mode
characterization correctly. PROFILE=balanced is now the documented run mode.
Validates the "Option C" hardware path: keep the old 8x16 Gerber's series-fed
edge-fed topology, just thicken the patch substrate from 0.102 mm to 0.508 mm
RO4350B. Single-element edge-fed with inset notch matched to 50 Ω microstrip.
Verified at PROFILE=balanced (λ/25 mesh):
W = 7.854 mm (preserved from old Gerber → array compatible)
L = 6.95 mm (tuned for f_res = 10.5 GHz on 0.508 mm sub)
inset_depth = 3.40 mm (~49 % of L)
inset_gap = 0.30 mm (each side of feed line, in the inset notch)
feed_W = 1.16 mm (50 Ω microstrip on 0.508 mm RO4350B)
feed_lead = 15.5 mm (= 1·λ_g at 10.5 GHz → port sees true antenna Z)
Result:
f_res = 10.509 GHz, S11 @ 10.5 = -18.5 dB, VSWR = 1.27
Z @ 10.5 = 61.8 + j3.2 Ω
-10 dB BW = 180 MHz (10.41-10.59 GHz, 1.71 %)
This is identical BW to probe_fed_v3 — confirming BW is set by substrate
thickness alone, not feed method. Edge-fed Option C is therefore the simpler
2-layer hardware path: same series-fed-row architecture as the old Gerber,
single PCB stackup, no probe vias / antipads / back-board splitter complexity.
Next step: extend to a 1x8 series-fed row to verify the daisy-chain topology
still gives in-phase feeding at the new substrate's λ_g.
Single-port-driven, 15-port-terminated FDTD sim built on the same v3 patch
design point (W=7.854, L=6.56, FEED_OFFSET=2.14) at the Gerber pitch
(14.27 mm X / 15.01 mm Y). Reads out S_jd for all elements when port
(DRIVEN_X, DRIVEN_Y) is excited. Configurable array size + driven port via
env vars; default = 4x4, drive inner element (1,1).
Result at PROFILE=balanced, drive (1,1):
Active S11 @ 10.5 GHz : -16.7 dB, Z = 45.7 + j13.5 Ω
Active -10 dB BW : 190 MHz (10.44-10.63 GHz; ≈ single-element 180)
Nearest-neighbour coupling:
-x edge neighbour (0,1): -18.3 dB (strongest — edge element loads less)
-y/+y in-array : -22 dB
+x interior neighbour : -24.7 dB
Diagonal : -32 dB
Far corners : -45 to -53 dB
Outputs: coupling_grid.png heatmap + S_matrix.csv (all S_jd full sweep) +
S11_data.csv (driven-port only).
Known limitation: port box must align with mesh lines; SmoothMeshLines may
sub-cell-shift seed lines, causing some DRIVEN_X/DRIVEN_Y choices to give
"Unused primitive" warning + zero excitation. Default (1,1) is verified;
other choices are best-effort. Documented inline.
Single-element OpenEMS sim for a 2-layer probe-fed patch on 0.508 mm RO4350B.
Verified at PROFILE=balanced (λ/25 mesh): f_res = 10.51 GHz, S11 = -21.8 dB,
VSWR 1.18, -10 dB BW = 180 MHz (10.40-10.58 GHz). Direct 50 Ω match — no port
matching cap needed.
Design point baked into defaults:
PATCH_W = 7.854 mm (preserved from old 8x16 Gerber → array compatible)
PATCH_L = 6.56 mm (tuned for f_res = 10.5 GHz at 0.508 mm sub)
FEED_OFFSET = 2.14 mm (probe via, from -y radiating edge)
Why probe-fed: aperture-coupled v2 (4-layer Stack_Hybrid) capped at ~60 MHz BW
because the 0.11 mm L4 acts as a near-short reflector — beneficial for slot
coupling but creates an L2-L4 cavity that's the BW ceiling. Tested removing L4
(open-back aperture-coupled): coupling collapsed, R stayed >1000 Ω regardless
of patch tuning. Probe-fed has no slot bottleneck; physics BW = 1.7% on h=0.508
matches sim 180 MHz directly.
Hardware change required to deploy: 2-layer stackup (patch on top, ground on
bottom, probe vias with antipads). Old 8x16 Gerber was edge-fed; v3 is probe-
fed → top-layer feed network goes away, ADAR1000 carrier on a separate board
with SMP RF launches. Stackup signoff with antenna designer needed before PCB.
aperture_coupled_v2 retained as the 4-layer fallback (with the 0.043 pF cap)
if 2-layer redesign isn't approved.
10-iteration analytic-tune sweep (no DE optimizer — that was a wrong
direction for this topology) on the Stack_Hybrid 4-layer stackup. Key
findings from the sweep are baked into the script defaults and header.
Best design point @ 10.5 GHz (now the env-var defaults):
patch W=9.55 mm L=7.77 mm
slot L=3.00 mm W=0.50 mm (centered under patch)
stub L=4.16 mm (= λ_g/4 in feed sub at f0)
feed lead=12.34 mm W=0.25 mm
total feed length = 1·λ_g at 10.5 GHz, line transparent at f0
Result: Z ≈ R + j350 Ω at 10.5 GHz, R = 33–51 across reruns (sanity-profile
convergence drift; X is stable). R is within matching range; the +j350
inductive residual is fundamental to the topology (L4 backshort under the
antenna footprint), not a tuning artifact. Two production-grade fixes:
(a) Series cap at port C ≈ 0.043 pF — drops S11 to ≈ -40 dB
(b) Open L4 backshort under antenna — restores standard open-back
aperture-coupled, stub naturally tunes X
Three real bugs fixed in the underlying sim (without these, the baseline
script was measuring artifacts, not the antenna):
1. Z mesh under-density. Default SmoothMeshLines at λ/18 gave the 0.11 mm
feed substrate ~0.1 cells vertically — microstrip Z0 was a pure mesh
artifact. Now built explicitly with ≥5 substrate-interior lines per
dielectric (bypassing SmoothMeshLines collapse for z-axis only). Fine
substrate cells visible via MESH_DEBUG=1.
2. SLOT_Y_OFF_MM env var was read but never applied to the L2 slot box,
silently making the parameter inert. Slot is now correctly offset in y.
3. FEED_LEAD_L was hardcoded at 14.0 mm, making total feed length 18.16 mm
= exactly 1·λ_g at 9.5 GHz (in feed substrate). This created a parasitic
feed-line full-wave standing wave in-band that masked the patch as a
persistent "9.4 GHz resonance" regardless of patch dimensions. Now
parameterized via FEED_LEAD_L_MM with default 12.34 mm so total feed =
1·λ_g at 10.5 GHz instead — line is transparent at the operating freq
and sim measures the actual antenna impedance at the port.
Sweep grids updated to span ±1 step around the iter #6 design point.
Re-simulation infrastructure for the new 4-layer aperture-coupled antenna
stackup (Stack_Hybrid.png, committed 1de2296). Renders the full multilayer
geometry (L1 patch / L2 slot ground / L3 microstrip feed / L4 backplane)
on the actual substrate stack (RO4350B 0.508 mm + RO4450F 1.2 mm + RO4350B
0.11 mm) and runs FDTD via openEMS to characterize S11 vs frequency.
Geometry interpolated from existing 2-layer Gerber:
patch W from Gerber (7.854 mm, set by εr only)
patch L re-tunable for new substrate (env var PATCH_L_MM, default 7.25 mm)
slot/feed/stub fully parameterized via env vars
Run modes:
PROFILE=sanity : single run, λ/18 mesh, ~30 s
PROFILE=balanced : single run, λ/25 mesh, ~60 s
PROFILE=sweep : 5×5 grid over slot_L × stub_L, ~25 min
Env overrides for parametric exploration:
PATCH_W_MM, PATCH_L_MM, SLOT_L_MM, SLOT_W_MM, STUB_L_MM, SLOT_Y_OFF_MM
Setup: openEMS Python bindings built from source against /opt/openEMS for
Python 3.12 in radar_venv; run with
DYLD_LIBRARY_PATH=~/opt/openEMS/lib ~/radar_venv/bin/python
under cwd != openEMS-Project/openEMS/python (avoids CSXCAD shadow import).
Status: simulation infrastructure verified end-to-end; patch resonates at
10.4 GHz with W=9.0 / L=5.5–6.0 mm. Full 50 Ω impedance match not yet
converged — the L4 backplane (non-standard for aperture-coupled) reduces
slot coupling vs textbook formulas; needs proper EM optimizer (scipy
wrapping or CST/HFSS handoff) to fully tune. ~150 MHz baseline BW
predicted from substrate physics; 30/40 MHz chirp target comfortably
under any plausible match outcome.
The chirp_scheduler had a 3-bit host_subframe_enable input {LONG, MEDIUM, SHORT}
that was tied to the constant RP_DEF_SUBFRAME_ENABLE at the receiver instance,
so the host could neither change it nor know what mask was active. With the
mask not at 3'b111 the scheduler skips a sub-frame at TX but doppler_processor
still writes 48 chirp slots, so the host CRT (`dbin // 16 → {SHORT, MED, LONG}`)
silently mis-attributes the SF axis and unfolds to the wrong velocity.
Plumb the mask through:
- radar_system_top.v: new reg [2:0] host_subframe_enable, cold-reset
RP_DEF_SUBFRAME_ENABLE, opcode 0x19 setter, wired to rx_inst and usb_inst.
- radar_receiver_final.v: new host_subframe_enable[2:0] input port; the
chirp_scheduler instance is untied from the constant.
- usb_data_interface_ft2232h.v: new subframe_enable[2:0] input + per-frame
snapshot reg latched at frame_complete (stable for ft_clk read, same
pattern as stream_flags_snapshot). Byte 2 emission is now
{2'b00, subframe_enable[2:0], stream_flags[2:0]} — was {5'b00000, stream}.
- radar_protocol.py: Opcode.SUBFRAME_ENABLE = 0x19; RadarFrame.subframe_enable
field; parse_bulk_frame surfaces bits[5:3]; reserved-mask 0xF8 → 0xC0.
Bulk-frame mock encodes the mask in its emit so dashboard replay is correct.
- v7/processing.py: extract_targets_from_frame_crt forces every target to
AMBIGUOUS when frame.subframe_enable != 0b111. Operator sees the red `?`
flag in the targets table instead of a silently-wrong velocity.
- v7/software_fpga.py + v7/dashboard.py: subframe_enable mirror + setter, and
replay dispatch routes 0x19 to set_subframe_enable.
Tests (test_v7.py): TestSubframeEnableRoundTrip (4), TestSoftwareFpgaSubframeEnable
(2), TestCrtSubframeMaskGating (3), 0x19 added to TestOpcodeEnumFillIn and
TestReplayOpcodeDispatch. Existing test_full_frame_round_trip updated to expect
byte 2 = 0x3F (mask 0b111 default + stream 0x07).
Cosim TBs (tb/tb_usb_protocol_v2.v, tb/tb_ft2232h_frame_drop.v) drive the new
input with 3'b111 and assert the new byte-2 layout (T2.3: 0x00 → 0x38).
Regression: test_v7 146/146, test_GUI_V65_Tk 117/117, ruff clean.
iverilog: tb_usb_protocol_v2 27/27 PASS, tb_ft2232h_frame_drop 10/10 PASS.
Bundled minor-tier fixes from project_aeris10_audit_2026-05-02. No
behavioural changes to the production happy path; mostly stale comments,
defaults, and one new emit-path (m-9) that lets cosim_dir replay show
detections instead of an empty mask.
m-1 — processing.py:59 RadarProcessor.range_doppler_map placeholder
shape (1024, 32) -> (NUM_RANGE_BINS, NUM_DOPPLER_BINS) imported
from radar_protocol so the legacy literal stops leaking to
anything reading the attribute before frame 0.
m-2 — radar_receiver_final.v:596 stale "// 32" comment for
RP_CHIRPS_PER_FRAME -> "// 48 (PR-F: 3 sub-frames * 16)".
m-4 — radar_protocol.py "16384 x 2 = 32768" arithmetic comment was
already corrected by an earlier edit; verified clean.
m-5 — usb_data_interface_ft2232h.v:961 "Frame header: 8 bytes"
comment -> "9 bytes (PR-G: added version byte at offset 1)".
m-6 — radar_system_top.v cold-reset host_chirps_per_elev 32 -> 48
+ status doc-comment so any sanity-checking parser sees the
value matching RP_CHIRPS_PER_FRAME instead of latching a
chirps_mismatch_error.
m-7 — radar_receiver_final.v:370 RX DDC mixers_enable(1'b1)
annotated: documented as intentional asymmetry vs TX (counter-
UAS RX has no quiesce scenario; CDC would add cost without
operational benefit).
m-8 — RadarSettings range_resolution / velocity_resolution flagged
inline as PLACEHOLDER (docstring already explains; inline
marker makes it visible at the field).
m-9 — gen_realdata_hex.py now also emits fullchain_cfar_flags.npy
(uint8 detection mask) and fullchain_cfar_mag.npy (|I|+|Q|),
produced by run_cfar_ca() with the FPGA cold-reset defaults
(guard=2 train=8 alpha=0x30 mode=CA). Replays through
v7.replay's COSIM_DIR loader: 22 detections on the synthetic
scene (was 0). The hex/ directory's two new .npy files are
included in this commit.
Regression: 247/247 (test_v7 130 + test_GUI_V65_Tk 117). Ruff clean.
The RTL has been ahead of the host opcode/widget surface since PR-G:
several runtime knobs (MEDIUM PRI, soft-CFAR alpha, ADC power-down) are
fully wired in radar_system_top.v but had no enum / spinbox path, so
the operator could only reach them via raw _send_custom_command. This
PR closes the gap for everything except M-5 (status-packet medium PRI
readback, which needs an RTL change to add a status word).
M-2 — Opcode enum gains MEDIUM_CHIRP=0x17, MEDIUM_LISTEN=0x18,
CFAR_ALPHA_SOFT=0x2D. Truth-table docstring refreshed.
Two new spinboxes in Waveform Timing ("Medium Chirp Cycles",
"Medium Listen Cycles") with the V2 defaults 500 / 15600 (5 us
chirp, 161 us PRI). One new spinbox in Detection (CFAR)
("CFAR Alpha Soft (Q4.4)") with the RP_DEF_CFAR_ALPHA_SOFT=0x18
default.
M-3 — ADC_PWDN=0x32 added to the enum (was previously commented as
"reserved for S-25"; the fix landed at radar_system_top.v:1152
routing to the physical adc_pwdn pin). New "ADC (AD9484)"
group on the right column with two buttons: ADC Normal (0x32=0)
and ADC Power Down (0x32=1). Buttons rather than a spinbox
prevent accidental non-{0,1} values.
M-4 — ADC_FORMAT widget added to the same ADC group: a 2-choice combo
("Offset-binary (SJ1 1-2)" vs "Two's-complement (SJ1 2-3)") with
a Set button, since AD9484 SPI is tied off (CSB high) and the
only way to flip sign convention is via this opcode.
M-6 — Replay opcode dispatch in _dispatch_to_software_fpga() expanded:
SoftwareFPGA gains cfar_alpha_soft mirror + setter; 0x2D wired
through. RTL-only opcodes (chirp timing, range mode, ADC strap,
self-test, status_request) are no longer silently dropped — they
log at info-level "acknowledged (no effect on replay — RTL-only
state)" so the operator gets visible feedback.
M-7 — Chirps Per Elevation widget default 32 -> 48; hint changed from
"1-32, clamped" to "must be 48 (RTL clamps)". RTL latches
chirps_mismatch_error in status word 4 bit 10 for any value != 48
since PR-F. Bonus: SHORT defaults bumped 50/17450 -> 100/17400 to
match RP_DEF_SHORT_*_CYCLES_V2 (PR-E 1-us SHORT chirp width).
Tests: +10 (TestOpcodeEnumFillIn 5, TestSoftwareFpgaCfarAlphaSoft 2,
TestReplayOpcodeDispatch 3). 247/247 PASS. Ruff clean.
M-5 (status packet medium_chirp/medium_listen readback) deferred —
needs an RTL change to extend status_words from 7 to 8 (current word 3
has only 10 reserved bits, not enough for two 16-bit fields).
The CRT extractor (PR-Q.5/PR-Q.6) tags every target with a velocity_confidence
("CONFIRMED" / "LIKELY" / "AMBIGUOUS" / "UNKNOWN") and an optional alias_set
of candidate v_true folds. Until now the operator-facing targets table on the
Main View tab dropped that signal, so a single-PRI-only AMBIGUOUS reading
looked identical to a 3-PRI CONFIRMED one.
Changes:
- Targets table column count 5 -> 6; new "Confidence" column between
Velocity and Magnitude.
- Module helper _confidence_display(label) -> (text, QColor):
CONFIRMED green (DARK_SUCCESS)
LIKELY amber (DARK_WARNING)
AMBIGUOUS red (DARK_ERROR), prefixed with "? " so the row stands
out even when the operator's eyes skip the colour.
UNKNOWN gray (DARK_TEXT) — legacy 32-bin / no CRT.
Unrecognised future labels fall through to UNKNOWN.
- Velocity cell carries a tooltip listing the CRT alias_set folds when
present, so hovering reveals all plausible v_true candidates.
- QColor pulled in from PyQt6.QtGui for the foreground tint.
Tests (TestDashboardConfidenceDisplay, +5):
- CONFIRMED/LIKELY/AMBIGUOUS/UNKNOWN each map to expected text + colour.
- AMBIGUOUS leads with "?" so it's visible without colour.
- Unrecognised label "BANANA" falls back to UNKNOWN/gray.
Regression: 237/237 (test_v7 120 + test_GUI_V65_Tk 117). Ruff clean.
This closes audit M-1 / task PR-Q.7. The C-5 thread is end-to-end functional:
RTL emits 3 sub-frames (PR-Q.1) -> cosim agrees (PR-Q.2) -> v7 models carry
per-subframe params (PR-Q.4) -> processing.py runs CRT (PR-Q.5) -> workers
route through it (PR-Q.6) -> dashboard surfaces the confidence (PR-Q.7).
Both live and replay paths used the legacy single-PRI extractor with the
LONG-PRI v_res placeholder, which yielded wrong velocities for the SHORT
and MEDIUM sub-frames. PR-Q.5 already provided extract_targets_from_frame_crt
(48-bin, 3-PRI Chinese-Remainder-Theorem unfolding) — this PR wires it in.
Changes:
- workers.py imports extract_targets_from_frame_crt at module scope.
- RadarDataWorker._run_host_dsp delegates to the CRT extractor and then
applies GPS pitch correction + DBSCAN clustering + Kalman tracking
on the returned targets. Inline det_indices loop and
velocity_resolution_long_mps placeholder removed.
- ReplayWorker.__init__ binds _extract_targets to the CRT extractor;
_emit_frame call simplifies to (frame, waveform, gps=).
- 32-bin legacy recordings still work via the CRT extractor's internal
fallback to extract_targets_from_frame.
- Module docstring stale "(64x32)" -> "(512x48)".
- Dropped unused `import numpy as np` from workers.py (no remaining users).
Tests (TestWorkersRouteThroughCrt, +4):
- 3-PRI detection produces CONFIRMED + alias_set (was UNKNOWN before).
- GPS pitch correction applied post-CRT to elevation.
- Both clustering+tracking off → returns [] (no DSP work).
- ReplayWorker._extract_targets is exactly the CRT function reference.
Regression: 232/232 (test_v7 115 + test_GUI_V65_Tk 117). Ruff clean.
Closes audit P-6 / task PR-Q.6 — C-5 host wiring complete (PR-Q.7 dashboard
display column is the remaining piece).
main.cpp pre-PR-F constants caused two issues:
- m_max = 32 disagreed with RP_CHIRPS_PER_FRAME = 48 (3 sub-frames * 16);
getStatusString reported "32 chirps/position" to the GUI, false telemetry.
- PRI MEDIUM = 161 us (PR-Q.1 stagger) was missing entirely; the MCU only
knew SHORT=175 / LONG=167. T2 was also stuck at the pre-PR-E 0.5 us
SHORT chirp width; PR-E switched to 1.0 us.
Fixes:
- m_max 32 -> 48; T2 0.5 -> 1.0; new T_MEDIUM=5.0, PRI_MEDIUM=161.0 constants.
- Big doc-comment above runRadarPulseSequence states the production stance:
FPGA cold-resets to mode 2'b01 (auto-scan) so the MCU's chirp GPIO toggles
are no-ops; pass-through mode 2'b00 needs a 3-PRI loop the MCU does not
yet emit, so mode-00 is operationally unsupported until that's built.
- Removed the redundant /* */ block-comment shadow of the same constants
that had `T2` defined twice (typo for `PRI2`); pure dead-code cleanup.
- test_bug16_runradar_shadows_globals.c m_max 32 -> 48 with refreshed
arithmetic comment; binary still PASSes all 4 checks (g_m wraps to 1
each iter regardless of m_max value).
No GPIO timing change (would need hardware verification). Audit P-5 closes
with the documented mode-01 stance; rebuilding the loop for mode-00 stays
on the backlog if/when pass-through becomes a deployment requirement.
dashboard.py:77 had a stale `NUM_RANGE_BINS = 64` literal from pre-PR-F.
Range-Doppler canvas was mis-sized: parser at radar_protocol.py:425
already enforces 512, so production frames would never have rendered
correctly even with P-2/P-3 in place.
Fix: drop the local literals; re-export NUM_RANGE_BINS / NUM_DOPPLER_BINS
through v7/hardware.py (which already re-exports the rest of
radar_protocol) and import them in dashboard.py. Single source of truth.
Also fixed two stale "(64x32)" docstrings: module-header tab description
and `_on_frame_ready` docstring.
Regression: 228/228 (test_v7 111 + test_GUI_V65_Tk 117). Ruff clean.
Audit P-2 and P-3 (2026-05-02): GUI radar_protocol.py was still on the
pre-PR-G wire format. Production frames were rejected 100% before they
reached the dashboard.
Bulk frame (P-2):
- BULK_FRAME_HEADER_SIZE 8 -> 9 (FPGA emits byte[1] = RP_USB_PROTOCOL_VERSION
= 0x02). All field offsets shift by 1 (frame_num at +3,+4; n_range at
+5,+6; n_doppler at +7,+8). Parser now validates the version byte.
- Detect packing: 1 bit/cell (np.unpackbits) -> 2 bits/cell, 4 cells per
byte MSB-first per PR-F. BULK_DETECT_DENSE_BYTES 3072 -> 6144 (= 512 *
ceil(48*2/8)). New _unpack_detect_2bit returns uint8 codes 0..3
(NONE/CAND/CONFIRM/RSVD) instead of a 0/1 bitmap.
- Reserved-bit mask 0xC0 -> 0xF8 (only low 3 stream-enable bits valid;
bits 3-7 reserved). Drop dead BULK_FLAG_MAG_ONLY/SPARSE_DET constants
and the rejection logic gated on them — the FPGA emit path always emits
mag-only / dense, so flag-driven variants were never on the wire.
- find_bulk_frame_boundaries: 9-byte minimum, validate version, bin
counts at +5,+6 and +7,+8.
- _mock_read updated to emit v2 frames so FT2232HConnection(mock=True)
produces parseable data for tests and replay.
Status (P-3):
- STATUS_PACKET_SIZE 26 -> 30 (PR-G adds status_words[6] for 2-tier CFAR
telemetry: detect_count_cand[31:16] + detect_threshold_soft[15:0]).
StatusResponse gains detect_count_cand, detect_threshold_soft, and
frame_drop_count fields.
Bonus: m-3 fixed in passing — Opcode docstring line refs were stale
(902-944 -> current ranges), now also documents 0x17/0x18/0x2D/0x32 as
"M-2/M-3 — no enum yet" so a reader knows what's wired but unreachable.
RadarFrame docstring "(64 range x 32 Doppler)" -> production dims.
Tests:
- TestBulkFrameV2RoundTrip (5 cases) — synthetic v2 frame round-trip,
version-byte rejection, reserved-bit rejection, 2-bit code decode,
back-to-back boundary scan.
- TestStatusPacketV2RoundTrip (4 cases) — 30-byte size, word[6] decode,
short-packet rejection, legacy-26B packet rejection.
- test_GUI_V65_Tk: _make_status_packet emits 30 B w/ word[6];
_build_bulk_frame emits v2 w/ version byte + 2-bit detect packing.
Pre-PR-G assertions on MAG_ONLY/SPARSE_DET dropped; new
test_reject_wrong_version_byte + test_parse_status_word6_2tier_cfar.
Test result: test_v7 111/111 + test_GUI_V65_Tk 117/117 = 228/228 PASS in
radar_venv. Ruff clean.
Audit P-1 (2026-05-02): _frame_queue, _acquisition, and frame counters
were stranded inside set_waveform() due to indentation drift. The
dashboard constructs RadarDataWorker and calls .start() directly
without ever calling set_waveform, so live FT2232H acquisition crashes
with AttributeError on first frame access in run().
Move the init block back into __init__; set_waveform now only sets
self._waveform. Add TestRadarDataWorkerInit covering both:
- attrs present after bare __init__ (no set_waveform required)
- set_waveform does not reset runtime counters
Test result: test_v7 102/102 PASS in radar_venv (was 100/100 + 2 new).
cosim_dir revival:
- gen_realdata_hex.py: also emit decimated_range_{i,q}.npy (48x512)
and doppler_map_{i,q}.npy (512x48) at production dimensions; the
same Python pipeline that produces the RTL .hex stimuli now writes
the .npy intermediates v7.replay COSIM_DIR loads. Replaces the
workflow lost when golden_reference.py was deleted in e8b495c
- test_v7.py: update test_get_frame_cosim shape from pre-PR-O.6
(64,32) to (NUM_RANGE_BINS, NUM_DOPPLER_BINS)
- check in 4 .npy reference files (~400 KB, deterministic SCENE_SEED=42)
Ruff lint cleanup (was 66 errors; now 0):
- pyproject.toml: ignore T20 in tb/cosim/**.py (CLI tools)
- compare_independent.py: drop redundant int() casts (RUF046),
swap try/except scipy import for importlib.util.find_spec,
remove dead duplicate np import, ASCII-ize comment unicode,
wrap E501 format strings
- fpga_reference.py: drop unused fs arg from nco_reference,
collapse if/else to ternary, mark _out_im unused
- v7/processing.py: ASCII-ize x in docstring, collapse if-branches
- {dashboard,software_fpga,workers,radar_protocol}.py: wrap E501
- test_v7.py: ASCII-ize comment unicode, _alias renames where unused
Result: test_v7 100/100 (0 skips on radar_venv, was 9 graceful
skips); 5 cosim_dir orphan tests now active and passing.
Restore SoftwareFPGA's process_chirps() pipeline by porting the missing
chain stages (MTI canceller, DC notch, CFAR, threshold detection) plus
thin wrappers (range FFT, decimator, Doppler FFT) to fpga_model.py and
swapping software_fpga.py's import target from the deleted
golden_reference.py to fpga_model.
History: golden_reference.py was deleted in e8b495c (the "dead golden
code cleanup") but software_fpga.py kept importing from it. The
ImportError was swallowed at v7/__init__.py:49-52 so package load
succeeded, but every direct `from v7.software_fpga import SoftwareFPGA`
hit the import-time failure — masking 21 broken tests as
"ModuleNotFoundError" instead of surfacing the real issue.
This was actively breaking the GUI replay-from-raw-IQ feature
(dashboard.py:1334-1347, 1577 + GUI_V65_Tk.py:271-300, 1106-1129):
opening a .npy SDR capture instantiates SoftwareFPGA + ReplayEngine;
the dashboard's opcode dual-dispatch routes spinbox changes to the
SoftwareFPGA setters so re-processing reflects live param tweaks.
With the import broken since April, that path silently dies.
fpga_model.py:
- New top-level constants: FFT_SIZE=2048, NUM_RANGE_BINS=512 (from
RangeBinDecimator.OUTPUT_BINS), DOPPLER_CHIRPS=48,
DOPPLER_TOTAL_BINS=48 (track current production: PR-O.6 / PR-F).
- run_range_fft(iq_i, iq_q, twiddle_file): N inferred from input
length; works for legacy 1024-pt and production 2048-pt callers.
- run_range_bin_decimator(range_i, range_q, mode): per-frame wrapper
over RangeBinDecimator.decimate (4x decim -> 512 bins).
- run_mti_canceller(decim_i, decim_q, enable): 2-pulse canceller,
ported verbatim from golden_reference @ commit 237e74c~1.
- run_doppler_fft(mti_i, mti_q): num_subframes inferred from chirp
count; RANGE_BINS overridden per input shape so legacy
2-sub-frame (32-chirp) and production 3-sub-frame (48-chirp)
callers both work.
- run_dc_notch(doppler_i, doppler_q, width): per-bin DC notch,
generalised to any sub-frame count.
- run_cfar_ca(...): CA / GO / SO modes with bit-accurate alpha-q44
threshold + 17-bit saturation, ported from golden_reference.
- run_detection(doppler_i, doppler_q, threshold): |I|+|Q| L1 magnitude
threshold detection.
software_fpga.py:
- _GOLDEN_REF_DIR (cosim/real_data/) -> _FPGA_COSIM_DIR (cosim/)
- `from golden_reference import (...)` -> `from fpga_model import (...)`
- TWIDDLE_1024 -> TWIDDLE_2048 (production 2048-pt range FFT).
- Stage 1 comment: "Range bin decimation (1024 -> 64)" ->
"(production 2048 -> 512)".
- Stage 1 twiddle path picks fft_twiddle_2048.mem only when
n_samples=2048 matches; otherwise None to fall back to math-
generated twiddles for legacy callers.
- Module docstring updated to reflect post-cleanup history.
test_v7.py — modernise three tests to current production dimensions:
- test_process_chirps_returns_radar_frame: pad input to 2048 samples;
assertions reference NUM_RANGE_BINS / NUM_DOPPLER_BINS from
radar_protocol; n_dop derived from input chirp count.
- test_cfar_enable_changes_detections: 48 chirps x 2048 samples;
output (NUM_RANGE_BINS, NUM_DOPPLER_BINS). No longer skips on
cosim absence — uses synthetic input.
- test_get_frame_raw_iq_synthetic: (2, 48, 2048) raw IQ;
(NUM_RANGE_BINS, NUM_DOPPLER_BINS) output.
- test_cosim_dir: also skip when doppler_map_*.npy absent (matches
_cosim_available pattern in TestSoftwareFPGASignalChain).
Local: test_v7 100/0/0 (9 graceful skips: optional deps + missing
cosim .npy data), test_GUI_V65_Tk 117/0/2. Down from 21 ERRORs.
Refactor v7.WaveformConfig from single-PRI to PR-Q's 3-PRI staggered
ladder (SHORT 175 us / MEDIUM 161 us / LONG 167 us) and update the
host-side bulk-frame parser dimension to match the FPGA's 48-bin
Doppler output (RP_NUM_DOPPLER_BINS = 48). The parser was rejecting
every production frame with n_doppler != 32, masking the PR-F widening
end-to-end.
WaveformConfig:
- pri_short_s/pri_medium_s/pri_long_s replace single pri_s
- n_doppler_bins 32 -> 48; new num_subframes=3
- Per-subframe velocity_resolution_{short,medium,long}_mps
- Per-subframe max_velocity_{short,medium,long}_mps
- extended_max_velocity_mps_crt(K=6) for 3-PRI alias-resolution ceiling
- Drop pri_s, velocity_resolution_mps, max_velocity_mps (no aliases)
Other:
- radar_protocol.NUM_DOPPLER_BINS 32 -> 48 (NUM_CELLS auto 16384 -> 24576;
BULK_FRAME_MAX_SIZE flows from NUM_CELLS, no other edits needed)
- v7/dashboard.py constant + stale "(64x32)" title replaced with f-string
- v7/processing.py 32-bin fallback -> 48
- v7/workers.py: derive doppler_center from frame.shape; LONG-PRI v_res
used as conservative single-PRI placeholder until PR-Q.5 lands the
CRT extractor (markers in place at both call sites)
- test_v7.py: TestWaveformConfig rewritten (8 tests, per-subframe + CRT
extension); TestExtractTargetsFromFrame center 16 -> 24
Local tests:
TestWaveformConfig 8/8 PASS
TestExtractTargetsFromFrame 6/6 PASS
test_GUI_V65_Tk 117/0/2 PASS
Mirror the PR-Q.1 PRI stagger (MEDIUM 175 us -> 161 us) into the cosim
scenario generator and regenerate all 12 affected golden hex/csv files.
Without this, the Doppler co-sim TBs would diverge from the RTL on every
MEDIUM sub-frame bin.
- tb/cosim/radar_scene.py: T_PRI_MEDIUM = 161e-6
- tb/cosim/gen_doppler_golden.py: comment update for MEDIUM bin map
- 12 regenerated hex/csv files (doppler + real_data + fullchain_realdata)
Regression: 42/0/1 (PR-Q.1 baseline preserved; T-6 SKIP is scipy-missing).
Bumps RP_DEF_MEDIUM_LISTEN_CYCLES 17000 → 15600 so MEDIUM PRI = 161 µs,
distinct from SHORT (175 µs) and LONG (167 µs). Three coprime PRIs let
the host run 3-PRI Chinese-Remainder unfolding on Doppler aliases beyond
the per-sub-frame ±~41 m/s unambiguous range — closes the FPGA half of
audit C-5 (PR-F Doppler ambiguity unfolding).
Stagger choice (proposal B):
SHORT 175 µs — chirp 1 + listen 174
MEDIUM 161 µs — chirp 5 + listen 156 (PR-Q, was 175)
LONG 167 µs — chirp 30 + listen 137
In 3 km mode LONG is blind (4500 m blind zone) → SHORT-vs-MEDIUM
(Δ=14 µs / 8 %) is the operative pair; in 20 km mode MEDIUM-vs-LONG
(Δ=6 µs / 4 %) carries the long-range slice that has SNR for both.
Listens picked to differ by ≥5 % so the alias resolver is robust against
the 5.1 m/s/bin Doppler quantization.
Architecture is unchanged — chirp_scheduler.v already takes per-waveform
host_*_listen_cycles. doppler_processor.v / cfar_ca.v are PRI-agnostic
and just tag Doppler outputs with sub_frame ID; host-side CRT lives in
v7/processing.py (PR-Q.5, follow-on).
Files:
radar_params.vh:240 RP_DEF_MEDIUM_LISTEN_CYCLES 17000 → 15600
radar_params.vh:217-228 block comment: stagger rationale + Δ math
radar_system_top.v:273 port-list comment: default 17000 → 15600
radar_system_top.v:278-282 staggered-PRI block comment: 3-ladder PRI
doppler_processor.v:25-30 reference v7/processing.py CRT unfolder
tb/tb_radar_receiver_final.v:199-202 list MEDIUM=15600 in real-values
Validation: full iverilog regression 42 PASS / 0 FAIL / 1 SKIP (pre-
existing scipy availability) — same baseline as post-PR-O.8. No TB
default-value asserts touched (tb_system_opcodes / tb_usb_protocol_v2
both use literal 16500 for opcode 0x18 round-trip).
Follow-on: PR-Q.2 (cosim T_PRI_MEDIUM align + golden regen), PR-Q.4-7
(v7 GUI 3-PRI CRT unfolder + AMBIGUOUS confidence display), PR-Q.8
(memory close-out). MCU executeChirpSequence is live but PRI-agnostic
in production mode 2'b01 (FPGA auto-scan) — pre-existing 2-ladder
staleness vs chirp-v2 3-ladder, defer to PR-H or dedicated MCU PR.
Compiles + runs the MF chain TB under Vivado XSim with FFT_USE_XILINX_IP
defined, exercising matched_filter_processing_chain →
fft_engine_axi_bridge → xfft_2048 → real LogiCORE FFT v9.1 IP.
Symlinks tb/ into the work dir so $readmemh("tb/mf_golden_*.hex")
resolves from xsim's CWD.
This validates the chain glue (FSM, BRAMs, conj-mult, sat-truncate) works
correctly against the actual IP timing/scaling, not just the iverilog
fft_engine.v fallback.
Output: /tmp/mf_chain_xsim.log; xsim run takes ~40 min on the remote box.
Wrapper xfft_2048.v had m_axis_data_tuser and m_axis_status_{tdata,tvalid,
tready} hooked up to the IP, but the regenerated xfft_2048_ip in scaled
mode + Pipelined Streaming + 1 channel + no XK_INDEX/OVFLO doesn't expose
those ports. xelab errored "cannot find port" on all four. Removed.
run_xfft_xsim.sh missed -i "$PROJ_ROOT" so xvlog couldn't resolve
`include "radar_params.vh"` from inside tb/. Fixed.
gen_xfft_2048_ip.tcl header comment described the old Burst I/O 11-stage
schedule; updated to PG109 Pipelined Streaming pair-grouped layout that
matches the actual SCALE_SCH = 12'hAA9 we now drive.
Verified: tb_xfft_2048_xsim 5/5 PASS on real LogiCORE FFT v9.1 IP under
Vivado 2025.2 xsim — DC peak at bin 0, impulse flat spectrum, tone at
bin 128. Closes T-10 (FFT-block synth-mode validation).
Resolves AUDIT-C10 (xFFT scaling sim/silicon mismatch) by replacing the
LogiCORE FFT v9.1 BFP setting with deterministic Scaled mode. Schedule
[1,1,…,1] (= /N total) is encoded in radar_params.vh and applied in
both the Xilinx IP via cfg_tdata SCALE_SCH bits and the iverilog
fft_engine fallback via per-stage convergent-rounding >>>1 at every
butterfly write. Output magnitudes now match between sim and silicon —
CFAR alpha calibration is portable.
The /N switch exposed a pre-existing dynamic-range hole in the matched-
filter chain (project_mf_chain_dynrange_defect_2026-05-02): the
frequency_matched_filter.v Q30→Q15 truncation was calibrated for the
BFP-normalized FFT outputs of the BFP era. Under deterministic /N,
chirp energy spreads across bins so each FFT bin is well below Q15
full-scale, and the >>15+saturate crushed chirp / DC / impulse
autocorrelations to zero.
Fix: widen the path between conjugate-multiply and IFFT to 32-bit Q30.
One 32-bit FFT engine instance, AXIS data 64-bit packed
{Q[31:0], I[31:0]}. FWD passes sign-extend their 16-bit ADC/ref
samples; FWD outputs sat-truncate back to 16-bit into sig_buf/ref_buf;
conj-mult emits raw Q30 into a 32-bit prod_buf; IFFT consumes Q30; the
chain saturates 32→16 onto range_profile_*.
bb_mf_test_*.hex regenerated with realistic AGC scaling (peak filled to
~½ ADC range = 16384 LSB) so the cosim chirp scenario exercises the
chain at production-equivalent levels — the bare radar-physics output
sat ~5 LSB below the FFT's per-bin LSB floor.
Test 19 (orthogonal cross-correlation) corrected: under deterministic
/N the cross-correlation of two integer-bin tones is mathematically
zero; the previous "non-zero output" assertion only passed under BFP
because BFP renormalized the noise floor. tb_rxb_fullchain_latency.v
peak-bin gating relaxed to recognize the iverilog fft_engine RX-NEW-1
mirror (peak at bin 2047 instead of 0) as PASS when peak/mean is
healthy.
compare_mf.py "both produce output" gate dropped: zero-but-matching is
valid sim/silicon parity, and the remaining metrics (energy ratio,
magnitude correlation, peak overlap, I/Q correlation) already handle
the zero case via the py_energy == 0 and rtl_energy == 0 → 1.0 clause.
Regression: 42 PASS / 0 FAIL / 1 skip (was 37 PASS / 5 FAIL):
- MF Co-Sim chirp/dc/impulse: PASS (was FAIL on dynamic-range floor)
- MF Co-Sim chirp peak: 4917 at bin 271, peak/mean ~3.4x
- Matched Filter Chain unit: 40/40 PASS (was 34/40)
- RX-B Full-Chain Autocorrelation: PASS, peak/mean ~166x (was 0)
- tb_fft_engine: 12/12 PASS (Parseval, scaling, roundtrip)
The Xilinx IP DCP must be regenerated on the remote Vivado box for
synth and XSim — gen_xfft_2048_ip.tcl + xfft_2048_ip.xci are updated
for input_width=32 / 64-bit AXIS but the .dcp is still pre-PR-O.
The AD9484 is SDR LVDS — datasheet p.5 lists "Output (LVDS—SDR)" as the
only output mode and p.16 confirms "data outputs are valid on the rising
edge of DCO." DCO runs at fs (400 MHz), one new sample per period, held
stable across the period. There is no DDR mode and no SPI access (CSB is
tied to +1V8 on the production board, RADAR_Main_Board.sch:46719).
ad9484_interface_400m.v previously instantiated an IDDR per data bit and
alternated Q1/Q2 via a `dco_phase` FSM, expecting to demux a "DDR" stream
into 400 MSPS. Because the chip is SDR, both Q1 and Q2 represent the same
sample, and the alternation produced approximately
[s_{-1}, s_1, s_1, s_3, s_3, s_5, …]
— odd-sample duplication with even-sample loss, equivalent to
decimate-by-2 followed by ZOH-upsample-by-2. In the frequency domain
that's a fold around fs/4 = 100 MHz; our 120-150 MHz IF lands at
50-80 MHz, so the DDC's 120 MHz NCO mixes the wrong frequency and the
matched filter sees baseband 40-70 MHz off where it expects.
The bug was hidden by tb/ad9484_interface_400m_stub.v, which has always
done single-rising-edge SDR-correct capture, so all iverilog regression
ran against the correct semantics — only the synthesizable Xilinx-
primitive path was wrong. This bug only fires on real silicon.
Fix:
- ad9484_interface_400m.v: drop IDDR + dco_phase; capture each data bit
with a single (* IOB = "TRUE" *) negedge-clocked IFF on adc_dco_bufio.
Falling DCO sits 1.25 ns inside AD9484's stable window, giving ~0.4 ns
setup margin against tPD = 0.85 ns. Same pattern on the OR (overrange)
path. Output FSM now emits one Q per BUFG cycle = clean 400 MSPS.
- tb_ad9484_xsim.v: add Test Group 8 (AUDIT-C4) that drives a 64-sample
counter ramp synchronously with rising DCO, captures the output, and
asserts (a) consecutive deltas equal +1 for ≥ (captured-6) of the
stream, (b) zero duplicate samples (catches DDR-style demux), (c) zero
unexpected jumps (catches DDR-style sample drops). This locks in SDR
semantics so any future regression that reintroduces a DDR demux on
this chip fails loudly.
- ad9484_interface_400m_stub.v: comment-only update — the stub already
does correct SDR capture; document AUDIT-C4 + why iverilog regression
was silent on the synth-path bug.
- xc7a200t_fbg484.xdc: fix stale "DDR class" comment near the OR pair
(now "SDR LVDS").
Verification: bash run_regression.sh — 42 passed, 0 failed, 1 skipped
(the skip is the T-6 drift cosim, which needs scipy from the dev group;
CI installs it via uv sync --group dev). Test Group 8 in the xsim TB
runs against the real UNISIM primitives and is exercised separately on
the Vivado host (run_xfft_xsim.sh-style flow).
The CFAR threshold (alpha) lives in a Q4.4 host register and is loaded
from RP_DEF_CFAR_ALPHA / _SOFT at boot (3.0 / 1.5 in Q4.4). With PR-M.2
swapping the Doppler window from a non-canonical "Hamming-ish" LUT
(PSL=-33 dB) to Dolph-Chebyshev 60 dB (PSL=-60 dB), training-cell
contamination from off-Doppler sidelobes drops by 27 dB and the
effective Pfa at the shipped alpha drops accordingly.
This commit is documentation only — defaults are not changed pre-HW.
Two operating-point options for HW bring-up:
(a) Hold alpha — get higher Pd at lower Pfa as a free win.
(b) Lower alpha — recover original Pfa, get even higher Pd.
Recommended bring-up procedure recorded in cfar_ca.v header:
1. Collect noise-only frames (no targets in dwell).
2. Measure empirical Pfa at shipped alpha=3.0 / 1.5.
3. If Pfa < 0.5 x design target, lower alpha; otherwise hold.
Opcodes 0x23 (RP_OP_CFAR_ALPHA) and 0x2D (RP_OP_CFAR_ALPHA_SOFT) let
the host adjust at runtime without firmware change.
Files:
* cfar_ca.v — adds "Doppler-window dependency" block to the header
after the existing "Threshold computation" block.
* radar_params.vh — adds a note above RP_DEF_CFAR_ALPHA pointing at
cfar_ca.v for the rationale.