The FT601 USB 3.0 driver was 5 PRs behind the FT2232H driver: still
emitting pre-PR-G 12-byte per-cell streaming packets with 1-bit
cfar_detection, no subframe_enable snapshot (M-8), no MEDIUM PRI
status readback (M-5 / PR-AC.1), no CFAR telemetry (PR-G). Host
parser only speaks v2 bulk, so a 200T+FT601 build couldn't talk to
the GUI. This commit closes that gap by cloning the FT2232H module
body verbatim and swapping the output stage from 8b/cycle to
32b+4b-BE/cycle.
usb_data_interface.v — full rewrite (~1300 LOC):
- 3 internal BRAMs (doppler_mag / range / detect), 3-cycle RMW
pipeline, Manhattan magnitude, all CDC chains, 8-state WR FSM
copied from FT2232H reference verbatim
- status_words[0..7] 34-byte packet (M-5 parity with PR-AC.1
21bf7a0): medium_chirp_cycles + medium_listen_cycles in word 7
- subframe_enable_snapshot in frame byte 2 bits[5:3] (PR-U / M-8)
- 2-bit cfar_detect_class (PR-F)
- PR-G CFAR telemetry (detect_count_cand + detect_threshold_soft
in status word 6)
- AUDIT-C12 wr_done_toggle handshake + frame_drop_count
- PR-Z A6 Bug C fix (detect_clearing on wr_done_pulse)
- PR-AA fix (BRAM addr advance at phase 0 / MSB, not phase 1)
- PR-Z A6 Bug B preload (detect_rd_addr=1 at WR_DETECT entry)
- Output stage: byte_now combinational generator + 4-lane pack-emit
accumulator. Full words BE=1111, partials BE=0001/0011/0111 at
section ends. Byte-order convention: byte 0 -> ft601_data[7:0],
byte N+1 -> next lane up (preserves MSB-first FT2232H wire order)
- ODDR clock forwarding preserved (ft601_clk_out)
- Vestigial ft601_txe_n / ft601_rxf_n outputs kept tied 1'b1
(200T board has physical pins routed; removing breaks the build)
radar_system_top.v — 9 new connections to the FT601 instance
(no new top-level signals — all driven from already-existing internal
wires the FT2232H instance was already using):
- cfar_detection (1-bit) -> cfar_detect_class (2-bit)
- range_bin_in, doppler_bin_in (rx_detect_valid mux)
- frame_complete, subframe_enable
- status_medium_chirp, status_medium_listen, status_cfar_alpha_soft
- status_detect_threshold_soft, status_detect_count_cand
Final cleanup pass for the PR-AB.b expanded bundle.
chirp_scheduler.v / radar_receiver_final.v: delete the unused subframe_pulse
output + its sibling subframe_id port. Both were declared on the scheduler
and bound at the radar_receiver_final.sched instance, but no downstream
module read them -- doppler_processor counts sub-frame boundaries
internally from new_chirp_frame + CHIRPS_PER_SUBFRAME=16. The stale
"// doppler picks up in PR-F" comment was aspirational; PR-F never wired
it. subframe_id demoted from output port to internal reg (still consumed
by the FSM's next_enabled_subframe helper).
tb_chirp_scheduler_handshake.v: drop the matching observer wires + port
bindings.
main.cpp: replace the F-2.1 "host_radar_mode = 2'b01 (auto-scan, FPGA-owned
chirp dispatch)" boot DIAG + 8-line comment block with a short comment
noting the mode register was retired in commit 1. The DIAG was asserting a
register that no longer exists.
Regression:
- FPGA iverilog: 43/0/0 (tb_chirp_scheduler_handshake 16/16)
- MCU: 51/0 (GPS) + 34/0 (AGC/safety/gap-3)
Closes the PR-AB.b expanded bundle (commits 1-6) on feat/dual-range-v2.
Wire a per-frame MCU→FPGA "beam pattern ready" handshake so the chirp
scheduler can stall between 48-chirp frames until the MCU finishes
writing the next ADAR1000 pattern. The legacy unused stm32_new_chirp
input on PD8 is repurposed as stm32_beam_ready; chirp_scheduler.v gets
a new S_BEAM_WAIT state entered after each frame_pulse and an 80 ms
watchdog so a missed MCU toggle degrades to wall-clock cadence with a
sticky telemetry bit rather than stalling the radar.
Cold-reset defaults the handshake off (host_handshake_enable=0, new
opcode 0x1A); the GUI opts in once the MCU PD8 wiring is verified on
the bench. Both the FT601 and FT2232H status word 4 paths get the new
beam_handshake_watchdog_fired sticky at bit [1] (reclaimed from the
range_mode retirement in commit 1).
RTL:
- chirp_scheduler.v: 2-FF ASYNC_REG sync on beam_ready_async; 1-cycle
edge detect (any transition, MCU side uses HAL_GPIO_TogglePin); new
S_BEAM_WAIT state entered at frame_pulse when host_handshake_enable=1;
23-bit beam_watchdog counter with BEAM_WATCHDOG_MAX = 8_000_000 (~80 ms
at 100 MHz, ~10 nominal frames); beam_handshake_watchdog_fired output
sticky across mixers_enable cycles, cleared only by reset_n; mid-wait
disable releases the FSM so dropping the opcode never strands the
radar between frames.
- radar_receiver_final.v: thread stm32_beam_ready_async +
host_handshake_enable + beam_handshake_watchdog_fired through the
scheduler instance.
- radar_system_top.v: rename input port stm32_new_chirp → stm32_beam_ready;
add host_handshake_enable register (cold-reset = 1'b0); opcode 0x1A
dispatch (value[0]); add rx_beam_handshake_watchdog wire; pack into
status_words[4][1] in both USB paths.
- radar_system_top_50t.v: rename wrapper port + sub-instance wiring.
- usb_data_interface.v + usb_data_interface_ft2232h.v: add
status_beam_handshake_watchdog input + 2-FF level CDC (same convention
as F-6.4 / F-1.2 stickies); refresh word-4 layout doc comment; pack
beam_handshake_wd_sync_1 into status_words[4][1].
XDC:
- xc7a50t_ftg256.xdc + xc7a200t_fbg484.xdc: rename stm32_new_chirp port
references to stm32_beam_ready (same PD8 pin, F13 on 50T / L18 on
200T).
MCU:
- main.h: add FPGA_BEAM_READY_Pin = GPIO_PIN_8 + FPGA_BEAM_READY_GPIO_Port
= GPIOD alongside the existing FPGA_FRAME_PULSE alias.
- main.cpp:runRadarPulseSequence: insert HAL_GPIO_TogglePin(GPIOD,
GPIO_PIN_8) after each setCustomBeamPattern16(RX) — once after the
per-azimuth broadside (vector_0), once after matrix1, once after
matrix2 — between the SPI burst completion and waitForFramePulse.
GUI:
- radar_protocol.py: Opcode.HANDSHAKE_ENABLE = 0x1A;
StatusResponse.beam_handshake_watchdog = 0 default; parse word 4 bit
[1] in parse_status_packet; update word-4 layout comment.
- test_GUI_V65_Tk.py: add beam_handshake_watchdog kwarg to
_make_status_packet (sets bit [1] of word 4); refresh
test_parse_status_word4_layout_co_spec to cover the new bit (used+9=32);
add test_parse_status_beam_handshake_watchdog round-trip;
test_handshake_enable_opcode pins 0x1A; defaults / chirps_mismatch /
agc-coexist tests gain a watchdog==0 assertion; bump
test_all_rtl_opcodes_present expected set to include 0x17/0x18/0x19/0x1A.
TB:
- new tb_chirp_scheduler_handshake.v (16 checks): legacy open-loop, edge
exit (rising + falling), 200-cycle idle hold, watchdog auto-advance
via force on dut.beam_watchdog, sticky-survives-mixers_disable,
mid-wait disable release, reset_n clears sticky.
- run_regression.sh: register the new TB in PHASE 1.
- tb_radar_receiver_final.v: tie the 3 new receiver ports off
(beam_ready_async=0, handshake_enable=0, watchdog unconnected).
- tb_system_mechanics.v / tb_system_opcodes.v: explicit
.stm32_beam_ready(1'b0) connection (the cold-reset
host_handshake_enable=0 keeps the FSM out of S_BEAM_WAIT).
- tb_usb_data_interface.v / tb_usb_protocol_v2.v / tb_e2e_dsp_to_host.v /
tb_ft2232h_frame_drop.v: tie .status_beam_handshake_watchdog(1'b0).
Ride-along ruff sweep (14 → 0 across the repo):
- tb/cosim/compare_independent.py: RUF003 — '5×' → 'at least 5x'.
- tb/cosim/gen_e2e_expected.py: noqa: E402 on the post-sys.path import;
drop unused EXPECTED_RANGE_BIN + EXPECTED_DOPPLER_BIN_PER_SF imports;
fold the detect-class slot if/else into a ternary (SIM108).
- tb/cosim/gen_e2e_stimulus.py: drop int() wrapping round() at four
call sites (RUF046 — round() already returns int in Python 3);
rewrite the range-bin derivation comment block from code-like
`# range_bin = ...` to prose (ERA001); strip stray f from
placeholder-free error string (F541).
- tb/cosim/tb_e2e_dsp_to_host_parse.py: open(path, 'r') → open(path)
(UP015).
- v7/dashboard.py: '3×' → '3x' (RUF003); drop quotes from
'StatusResponse | None' annotation (UP037, file already has
`from __future__ import annotations`).
CI summary (all suites green pre-commit):
- ruff: All checks passed!
- FPGA regression (iverilog): 43 / 0 / 0 (incl. new handshake TB 16/16).
- MCU tests: 51 / 0 + 34 / 0 + 13 / 13 ADAR1000_AGC.
- GUI Tk (test_GUI_V65_Tk): 120 / 0.
- GUI v7 (test_v7): 152 / 0.
Production rollout note: bitstream cold-resets with host_handshake_enable=0
so existing flashes keep their open-loop cadence until the GUI sends
opcode 0x1A=1. Once enabled, the per-pattern dwell tracks both the chirp
ladder (PD14 frame_pulse from commit-3 work) and the MCU pattern-write
completion (PD8 toggle from this commit), eliminating drift from the SPI
burst timing.
Strip the host-side parser/dashboard/test references for the FPGA
registers retired in commit 1: host_radar_mode (opcode 0x01),
host_trigger_pulse (opcode 0x02), and host_range_mode (opcode 0x20).
The v7 backend (models.py / software_fpga.py / processing.py) had no
references — only the parser, dashboard, and Tk test file did.
- radar_protocol.py: drop Opcode.RADAR_MODE / TRIGGER_PULSE / RANGE_MODE
enum members; rebuild the doc table around the surviving opcodes; drop
StatusResponse.radar_mode and StatusResponse.range_mode fields; drop
the two parse-status assignments (sr.radar_mode = words[0]>>22 and
sr.range_mode = words[4] & 0x03); update the layout comments on words
0 + 4 to mark the freed bits as reserved-0. Acquisition loop log line
switches from "mode=X stream=Y" to "stream=Y chirps/elev=Z" — radar
mode is no longer a runtime concept.
- v7/dashboard.py: delete the "Radar Mode Off" (0x01) and "Trigger Chirp"
(0x02) QPushButtons from the operations group; remove "Mode:" and
"Range Mode:" fields from _update_status_display.
- test_GUI_V65_Tk.py: drop mode + range_mode kwargs from the
_make_status_packet helper; update the word-4 layout co-spec test
(range_mode entry deleted, reserved span widened from [9:2] to [9:0],
sanity-check sum bumped from used+8 to used+10); delete
test_parse_status_range_mode + test_radar_mode_names; rename
test_agc_and_range_mode_coexist → test_agc_fields_coexist_with_mismatch
with chirps_mismatch replacing range_mode as the coexisting field;
drop 0x01 / 0x02 / 0x20 from test_all_rtl_opcodes_present's expected
set.
GUI tests: 118/0 (test_GUI_V65_Tk) + 152/0 (test_v7). FPGA regression
unchanged at 42/0/0.
Strip the FPGA-side pin constraints and MCU-side GPIO init+toggles for
the two STM32→FPGA beam-step GPIOs that the commit 1 RTL strip rendered
unreachable. The MCU was toggling PD9 once per beam_pos iteration and
PD10 once per azimuth step; both edges fed FPGA edge_detector_enhanced
instances that drove elevation_counter / azimuth_counter regs in
plfm_chirp_controller_v2 — counters that were never consumed (status
pack didn't carry them; on 50T they went to _nc; on 200T to
unconstrained outputs). GUI already uses MCU-side software counters
m/n/y via USB-CDC.
- constraints/xc7a50t_ftg256.xdc: delete PACKAGE_PIN E16 (PD9) +
D16 (PD10); tighten stm32_new_* wildcard to explicit stm32_new_chirp.
- constraints/xc7a200t_fbg484.xdc: delete PACKAGE_PIN N18 (PD9) +
N19 (PD10); tighten wildcard same as 50T.
- main.cpp:633: delete HAL_GPIO_TogglePin(GPIOD, GPIO_PIN_9) inside the
matrix1/matrix2 beam_pos loop.
- main.cpp:655: delete HAL_GPIO_TogglePin(GPIOD, GPIO_PIN_10) at the
azimuth-step / stepper-rotate boundary.
- main.cpp:3118 (MX_GPIO_Init output level): drop PD9 + PD10 from the
GPIOD WritePin OR-mask.
- main.cpp:3172-3174 (MX_GPIO_Init pin config): drop PD9 + PD10 from
the GPIOD pin OR-mask + comment. PD9 + PD10 now default to high-Z
inputs after MCU reset — no leakage path because the FPGA-side ports
are gone.
MCU regression: 51/51 + 34/34 suites green. FPGA regression unchanged
at 42/0/0 (XDC isn't consumed by iverilog).
The remaining DIG_0..DIG_3 bus pins are PD8 stm32_new_chirp (kept until
commit 5 renames it to stm32_beam_ready), PD11 stm32_mixers_enable, and
PD12 reset_n.
Restore regression to green after commit 1's RTL strip. Drop dead-signal
references from 10 TBs to match the post-strip RTL surface:
- chirp TBs: drop clk_100m / reset_100m_n / new_elevation / new_azimuth
port wiring on plfm_chirp_controller_v2; drop elevation/azimuth counter
wires + check() assertions; retire Group 9 (Beam steering counters) in
tb_chirp_controller and C3 (stm32 toggle non-drive) in tb_chirp_contract.
- system TBs: drop stm32_new_chirp / stm32_new_elevation / stm32_new_azimuth
regs + DUT port wiring; drop current_elevation / current_azimuth wires;
delete stm32_chirp_toggle helper task; retire G6.1 (radar_mode opcode
0x01), G6.6 (trigger_pulse opcode 0x02), G7.1 (STM32->FPGA chirp CDC
stress), and G14.1/.2/.3 (range_mode opcode 0x20) test groups; replace
stm32_chirp_toggle calls in G2 with passive waits (scheduler auto-scans).
- receiver TB: drop .host_mode / .host_range_mode / .host_trigger ties.
- usb TBs: drop status_radar_mode / status_range_mode regs + DUT port
wiring + apply_reset init + per-test setup; update word[4][1:0] expected
value from 2'b10 (range_mode=2) to 2'b00 (now reserved) in
tb_usb_data_interface; refresh T3.7 comment in tb_usb_protocol_v2
(assertion value 0x60 unchanged because range_mode was already 2'd0).
- adc pwdn TB: strip host_radar_mode dummy reg + opcode 0x01 dispatch from
the local dispatch_block mirror; rewrite T5 with unknown opcode 0x05 to
preserve the dispatch-isolation case after 0x01 retirement.
Regression: 42/0/0 (was 37/0/6 in the pre-strip baseline where scipy was
unavailable; with radar_venv on PATH all 42 phases run and pass).
Flatten chirp_scheduler.v to single-FSM auto-scan. Mode 00 (STM32 pass-through),
mode 10 (single-chirp debug) and mode 11 (track dwell) FSM branches were all
half-implemented and unreachable in production: MCU dispatcher was deleted in
F-2.1; mode 11 inputs were tied to constants in radar_receiver_final; mode 10
debug_wave_sel was hardcoded to SHORT. The case-switch wrapper, watchdog,
effective_mode mux, and all host_track_* / host_debug_wave_sel / host_trigger
plumbing are removed.
Strip host_radar_mode (opcode 0x01), host_trigger_pulse (opcode 0x02), and
host_range_mode (opcode 0x20). The mode register had no consumer after the
single-mode flatten; the range_mode register was already write-only telemetry
(declared as input in radar_receiver_final but never read). The runtime 3km vs
20km presentation on a 200T build is driven by host_subframe_enable (0x19) +
per-waveform chirp/listen cycles (0x10-0x18) — no separate mode field needed.
Strip stm32_new_elevation / stm32_new_azimuth GPIOs and the elevation_counter /
azimuth_counter regs in plfm_chirp_controller_v2. The FPGA-side counters had no
consumer (status pack never carried them; on 50T they went to _nc; on 200T to
unconstrained outputs). MCU software counters n/y reach the GUI via USB-CDC
on a separate channel.
USB status word 0 bits [23:22] (was radar_mode) and word 4 bits [1:0] (was
range_mode) are now reserved zeros — host parser keeps the same byte offsets.
Files modified:
chirp_scheduler.v - flatten to single FSM (~155 LOC delta)
plfm_chirp_controller_v2.v - strip counter blocks + ports
radar_transmitter.v - strip elev/azim CDC + edge detectors + ports
radar_receiver_final.v - strip host_mode/range_mode/trigger + STM32 toggle ports
radar_system_top.v - strip regs, opcodes 0x01/0x02/0x20, status_*_mode wiring, top-level ports
radar_system_top_50t.v - strip _nc wires + stm32_new_elev/azim + tie-offs
radar_system_top_te0713_umft601x_dev.v - strip status_radar_mode/range_mode ties
usb_data_interface.v - drop status_*_mode ports, reserve word 0 [23:22] + word 4 [1:0]
usb_data_interface_ft2232h.v - same as above
radar_params.vh - strip RP_MODE_* / RP_RANGE_MODE_* / RP_OP_RADAR_MODE / RP_OP_TRIGGER_PULSE / RP_OP_RANGE_MODE / RP_DEF_TRACK_*
Regression will fail at this commit due to TB references to deleted signals
(host_radar_mode, status_range_mode, etc.) — TB cleanup follows in commit 2.
Cleanup of the four cosmetic items flagged in the PR-AB.b layout audit:
- Settings tab: the "Host-Side Signal Processing" group note used DARK_WARNING
(orange) but is purely explanatory, not a caution — converted to italic
DARK_INFO (blue) so it matches the equally-informational Bench-mode note
in the next group.
- AGC group ALWAYS-ON badge: added margin-bottom so it reads as a header
rather than sitting flush against the first AGC Target row.
- Detected Targets table: "Velocity (m/s)" header was clipping its leading
V against the column divider under Stretch resize mode (column width
~92 px, header text ~140 px). Header text shortened to "Vel (m/s)" so
it sits comfortably alongside Range/Confidence/Magnitude/SNR/Track ID;
QHeaderView::section padding bumped 10→12 px for general daylight.
- AGC Monitor strip: Gain/Peak/Total Saturations labels now share a uniform
DARK_FG colour. Only the value text on the AGC mode label still carries
colour (green ALWAYS-ON / AUTO, blue MANUAL), so colour means state
rather than label-decoration.
Regression: GUI v7 150/150.
FPGA (Phase 1+2):
- gpio_dig6 (PD14) now carries chirp_scheduler frame_pulse, FPGA-stretched
to ~100 ns so the STM32 EXTI on PD14 can latch reliably.
- gpio_dig7 (PD15) returns to its pre-PR-AB.b role: control-fault OR
(range_decim_watchdog | CDC overrun); MCU stuck-high sampler unchanged.
- rx_range_decim_watchdog gains a sticky in source clock domain so a slow
status poll cannot miss a 1-cycle assertion (Phase 1).
- New tb_dig6_frame_pulse.v (13 checks); tb_status_words_stickies.v extended
with DIG_7 fault-OR coverage (14 checks); retired tb_audit_s10_gpio_split.v.
- Port comments in radar_system_top.v / _50t.v and XDC roles refreshed.
MCU (Phase 3):
- PD14 reconfigured to GPIO_MODE_IT_RISING + GPIO_PULLDOWN; new
EXTI15_10_IRQHandler in stm32f7xx_it.c dispatches to HAL_GPIO_EXTI_Callback
that bumps a volatile g_frame_pulse_count.
- runRadarPulseSequence dwell loop replaces 3x HAL_Delay(8) with
waitForFramePulse(20) — per-pattern dwell now tracks the actual mask-aware
ladder length (drift-free, mask-aware), with a 20 ms timeout safety net.
- AGC outer loop is ALWAYS-ON in production (compile-time policy); bench
builds compile the body out via -DMCU_AGC_FORCE_DISABLED. The runtime
enable/debounce + DIG_6 polling that previously gated AGC are removed.
- main.h adds FPGA_FRAME_PULSE_* aliases pointing at FPGA_DIG6_*.
GUI (Phase 4):
- Settings tab gains a Bench / Diagnostics group with a BENCH-MODE checkbox
(off by default, persisted via QSettings).
- AGC group header swaps between a green "AGC: ALWAYS-ON" badge (production)
and Enable/Disable AGC buttons (bench), pinned to the top of the group.
The redundant 0/1 spinbox row for opcode 0x28 is removed — buttons send
the same opcode and cannot accept invalid input.
- Both the FPGA Control AGC Status box and the AGC Monitor strip share a
helper that honours bench-mode in production (always shows ALWAYS-ON in
green so the two views never disagree with the badge).
- _add_fpga_param_row uses setFixedWidth on label and Set button + explicit
stretch=1 on the hint, so all rows align column-wise whether they sit
directly in a QVBoxLayout or inside a wrapper QWidget.
Regression: FPGA 42/0/0 (PR-M.4 baseline) - MCU 34/34 - GPS extended 51/51
- GUI v7 150/150 - BENCH-MODE flip behaviorally verified.
Hardware-blocked steps deferred: bench-scope verify (PD14 dwell pulse,
counter advance, PD15 stuck-high recovery still triggers).
Closes#182.
runRadarPulseSequence used to fire vector_0 (broadside reference)
between every matrix1 and matrix2 pattern, i.e. 15 times per azimuth.
That dwell × 8 ms × 15 = 120 ms per azimuth × 50 azimuths = ~6 s of
the 18.4 s revisit time was burned on redundant broadside frames.
Pull vector_0 out of the loop and fire it once per azimuth before the
sweep. Each azimuth now produces 1 broadside frame + 30 steered frames
(matrix1 + matrix2 across 15 beam_pos), down from 15 + 30 = 45 frames.
Revisit time drops from 18.4 s to ~12.8 s (31% improvement).
If multiple per-position broadside frames are ever needed, gate them
behind a runtime switch — the comment block flags this.
test_bug16_runradar_shadows_globals updated to mirror the new
1-outside + 2-inside m-counter pattern; 13/13 PASS, full MCU
regression 51/0 + 34/0.
The WR_DOPPLER_DATA emit advanced mag_rd_addr at end of phase 1 (LSB byte)
but BRAM has 1-cycle read latency, so phase 0 of the next pair re-read
the prior cell. Result: wire pair K = (HIGH(bram[K-1]), LOW(bram[K])) —
adjacent cells silently swapped their high bytes whenever the high byte
differed. Footprint was 30 of 24576 cells (peak rows + high-byte
transitions in the noise floor); max diff 6656 LSB on the target row.
Fix: advance the BRAM read address at end of phase 0 (MSB) so BRAM has
2 cycles between addr-set and the next pair's MSB read. Same pattern
existed in WR_RANGE_DATA — silently broken (regression skips range
stream); fixed for symmetry. After fix, both iverilog and remote
Vivado 2025.2 xsim emit a bit-exact match against the Python golden.
Tighten E12.14 / E12.6.b to strict zero tolerance and rename the
"PR-AA pending" notes to indicate the fix has landed. Target-cell
window check (E12.15) now points at the exact (rb, db) bin.
Verification:
* iverilog A6 in-TB: doppler_mismatches=0/24576 (16/16 PASS)
* iverilog A6 parse strict: 28/28 PASS
* Vivado 2025.2 xsim A6 in-TB: doppler_mismatches=0/24576 (16/16 PASS)
* Vivado 2025.2 xsim A6 parse strict: 28/28 PASS
* Full regression: 41 passed, 0 failed, 0 skipped / 41 total
Bundles audit items unblocked by the AERIS-10 end-to-end audit:
S-1 (radar_system_top.v) — DC notch off-by-one at width=7
Audit S-1: ±W around DC in a 16-bin FFT covers bins {0..W, 16-W..15}
(2W+1 total, bin 8 the only one excluded at W=7). The previous form
`< W || > 15-W+1` missed both boundaries: at W=1 it notched only {0}
(skipping 1 and 15); at W=7 it missed 7 and 9. Replaced with inclusive
comparators against 5-bit limits (`<= notch_lo || >= notch_hi`) which
hit the intended set for all W ∈ {1..7}. Cosim DC golden
(tb/cosim/rtl_bb_dc.csv) regenerated against the corrected behaviour.
S-7 (rx_gain_control.v) — reg→wire for combinational helpers
`wire_frame_sat_incr` / `wire_frame_peak_update` were declared `reg`
and blocking-assigned inside the clocked always block. They are pure
combinational functions of the registered inputs — promoted to
module-scope continuous assigns. Behaviour is bit-identical (the read
inside the always still reflects the prior-cycle latched values) but
the iverilog warnings disappear and the sim/synth correspondence is
unambiguous.
M-9 (formal/fv_radar_mode_controller.sby) — delete orphan
radar_mode_controller.v was retired in PR-D in favour of
chirp_scheduler.v; the .sby was never updated and pointed at a
non-existent module. Deleted.
M-10 (radar_receiver_final.v) — document `data_sync_error` unconnected
In production AD9484 produces a single 8-bit stream that the DDC mixes
into matched I/Q paths with symmetric pipelines, so `ddc_valid_i` and
`ddc_valid_q` rise on the same cycle and `data_sync_error` cannot
fire by construction. The check is retained inside
ddc_input_interface for the standalone tb_ddc_input_interface
unit-test (which intentionally drives valid_i ≠ valid_q). Adds
comments explaining the unconnected port at both call sites; no
functional change.
M-11 (radar_receiver_final.v) — `force_saturation_pulse` symmetric hook
The DDC has a `force_saturation` debug input that previously was tied
1'b0 directly. Routed through a new `force_saturation_pulse` wire
alongside the existing `clear_monitors_pulse` so a future host opcode
surface for "diagnostic force/clear" lands both at the same dispatch
point. Still tied 1'b0 today — RTL change is a placeholder for the
opcode plumbing.
PR-X.3 F-7.5 (formal/fv_cdc_adc.{v,sby}) — retarget to cdc_async_fifo
Prior wrapper instantiated `cdc_adc_to_processing`, retired by
AUDIT-C11 in favour of `cdc_async_fifo` (the production CIC→FIR
boundary CDC, see ddc_400m.v line 646). Wrapper rewritten with
FIFO-shaped equivalents of the original Gray-CDC properties:
P1 reset behaviour, P2 no spurious dst_valid, P3 overrun semantics,
P4 data integrity (cooldown-spaced, FIFO-equivalent of the
original single-element latch property),
P5 bounded liveness (depth 100 gclk),
P6 cover sequences for the basic write→read pipeline.
P4's true multi-in-flight FIFO order proof is left as Option B work;
for the AERIS-10 use case the upstream ddc_400m CIC→FIR consumer
operates below FIFO-fill rate by design, so the cooldown-spacing
assumption is a tight model.
Verification: full FPGA regression 41 / 0 / 0.
The PR-Z A6 e2e test (tb_e2e_dsp_to_host) exposed that the wire-format
cfar_dense map emitted by usb_data_interface_ft2232h was all-zero for
our deterministic single-target stimulus, even though cfar_ca's
in-flight outputs showed CONFIRMED at the expected cells (verified via
in-TB capture, E5/E6 PASS).
Deep instrumented debug (BRAM-WRITE, BRAM-READ, EGRESS-CAP probes)
revealed THREE independent bugs that combined to produce the all-zero
wire output. Each bug alone would have been visible; the way they
compounded made the symptom look like a single coarse failure.
Bug A — stale write address (radar_system_top.v):
usb_inst.range_bin_in/doppler_bin_in were tied to notched_*_bin
(= rx_*_bin = doppler_processor outputs). After doppler returns to
S_IDLE its `output reg`s hold their last-driven values (511, 47).
cfar_ca's CMP-phase emit (cycles ~520..73520 after frame_complete)
fires cfar_valid with detect_range/detect_doppler set to its own
per-cell scan counters, but those outputs were dangling — usb's
RMW saw the doppler stale (511, 47) and slammed every cfar write
to byte_addr {511, 47[5:2]} = bram[8187], past the 6144-byte wire
range entirely.
Fix: register cfar_detect_range/doppler in lockstep with the existing
rx_detect_valid/rx_detect_class registration block (clk_100m_buf
domain), then mux them into usb_inst.range_bin_in/doppler_bin_in on
rx_detect_valid. doppler-magnitude write path is unaffected because
doppler_valid and rx_detect_valid are mutually exclusive (BUFFER vs
CMP phases of cfar_ca).
Bug B — BRAM read pipeline lag (usb_data_interface_ft2232h.v):
The detect_rd_data <= detect_bram[detect_rd_addr] BRAM read port has
1-cycle latency. WR_DETECT_DATA's emit FSM advanced detect_rd_addr
and read detect_rd_data in the SAME edge — so cycle K read bram[K-2]
(the addr from cycle K-1's commit) instead of bram[K-1]. Result:
every cfar wire byte = bram[N-1] instead of bram[N], shifting the
entire 6144-byte detect section +1 byte = +4 doppler bins. Doppler
hides this naturally because its 2-byte-per-cell rhythm gives BRAM a
free settling cycle between addr-set and emit-read.
Fix: pre-load detect_rd_addr <= 1 and det_doppler_byte_idx <= 1 at
every WR_DETECT_DATA entry transition (HDR direct, RANGE direct,
DOPPLER → DETECT). BRAM produces bram[0] for the first emit cycle
(settled since reset because detect_rd_addr was 0 throughout the
preceding section) while the addr advance schedules bram[1] for the
second emit cycle — and from then on the FSM's natural advance
pattern keeps the pipeline aligned, including across the per-range
boundary (det_doppler_byte_idx == DET_BYTE_LAST_PER_RANGE).
Bug C — detect_clearing window overlaps cfar's first 4 columns:
detect_clearing fired 1 cycle after frame_complete and ran for 8192
clk cycles (1 byte/cycle). cfar_valid writes were gated on
`!detect_clearing` (line 512). cfar's CMP-phase emits start at
frame_complete + ~520 cycles and run for ~73000 cycles, so the
first ~7672 cycles (≈ 4 doppler columns) of cfar pulses were
silently dropped. Test stimulus lit (67, 2/3) for sub-frame 0, all
inside the clearing window → bytes lost. (67, 18/19) and (67, 34/35)
for SF1/SF2 fell after clearing → captured correctly. Visible as
one-byte mismatch (0x0A expected, 0x00 captured) at offset 49965
(= cfar byte 804 = range 67, doppler 0..3) once Bugs A and B were
fixed.
Fix: move detect_clearing trigger from "1 cycle after frame_complete"
to wr_done_pulse (USB-transfer-complete edge already CDC'd into clk
via the AUDIT-C12 wr_done_sync chain). Clearing now runs in the dead
zone after USB has finished reading frame N's BRAM, well before
frame N+1's cfar starts CMP (~480k cycles of margin at 178 fps).
First frame after reset relies on BRAM init=0 — added explicit
initial block under `ifdef SIMULATION so iverilog matches Vivado's
synthesis default.
Test infrastructure:
- tb/tb_e2e_dsp_to_host.v new — deterministic single-target stimulus
fed through the back-half of the radar pipeline (range_decim → MTI
→ doppler → DC-notch → cfar → registered sync → usb), 16 in-TB
asserts + bit-exact byte capture.
- tb/cosim/gen_e2e_stimulus.py / gen_e2e_expected.py new — Python
deterministic stim + bit-exact frame golden.
- tb/cosim/tb_e2e_dsp_to_host_parse.py new — parses captured frame
via radar_protocol, runs 12 strict-bit-equality checks plus 16
semantic checks (target == CONFIRMED, neighbors == NONE,
DC-notched bins == NONE, etc).
- run_regression.sh — A6 hookup + retired the two zero-assertion
radar_system_tb USB_MODE=0/1 smoke runs and the 3-liveness-only
tb_system_dataflow (subsumed by A6's stronger checks). Saves
~7 min wall.
Verification:
- Local iverilog: in-TB 16/16 PASS, parser strict 28/28 PASS.
- Remote Vivado 2025.2 xsim (Artix-7 target): in-TB 16/16 PASS,
parser strict 28/28 PASS.
- Full regression: 41 / 0 / 0.
The MODEL_USB_CFAR_BUG bug-model flag (used to keep the regression
green during development against buggy production) is removed — the
test is now strict bit-exact against the post-fix wire format.
Header (line 6, 11): "(IBUFDS, BUFG, IDDR)" → "(IBUFDS, BUFIO, BUFG,
MMCME2_ADV)"; "IDDR Q2 (falling-edge) data capture in
SAME_EDGE_PIPELINED mode" → "Falling-DCO-edge IOB-packed IFF capture
(post-AUDIT-C4 SDR shape; no IDDR, no rise/fall demux)".
$display banner: "(IBUFDS, BUFG, IDDR)" → "(IBUFDS, BUFIO, BUFG, MMCME2)".
Group 8 inline comment: "captures Q2 of the IDDR (falling-edge…)"
→ "captures the IOB-packed IFF on the falling DCO edge".
Test Group 4 retired. The block drove 0xAA on rising DCO and 0x55 on
falling DCO. Post-AUDIT-C4 the IFF only samples the falling edge —
every captured value was 0x55 — so the assertion
`saw_aa > 0 || saw_55 > 0` was trivially true and `cap_count > 0`
duplicates Group 5 / Group 8's stronger checks. Block replaced with
a tombstone comment; group numbering preserved for git-blame
continuity.
Two "IDDR" references intentionally retained inside negations
(line 12, line 186) — they explicitly contrast the current SDR
topology against the broken pre-C-4 shape so a reader who finds the
old vocabulary in git history understands what changed.
Verification: remote Vivado 2025.2 xsim → 15 / 15 PASS
(was 17 / 17; the lost 2 are Group 4's trivial-pass and existence
checks, both now subsumed by Groups 5 / 8).
Constraint values are unchanged — only the explanatory comments are
refreshed to match the post-AUDIT-C4 (2026-05-01) RTL.
- "IDDR outputs" → "IFF Q output"
- "adc_data_{rise,fall}_bufg" → "adc_data_iff_bufg"
- "the IDDR ~1.4ns before the clock" → "the IOB-packed IFF ~1.4ns
before the clock"
- hold-waiver header "BUFIO-clocked IDDR" → "BUFIO-clocked IFF, SDR"
- "BUFIO/IDDR domain" → "BUFIO/IFF domain"
The 3.000 ns set_max_delay (BUFIO ↔ clk_mmcm_out0), the
set_false_path -hold on adc_d_p[*]/adc_or_p, the LOCKED-pin
through-waiver, and the 0.150 ns set_clock_uncertainty all remain
bit-identical — the IFF capture has the exact same source-synchronous
timing argument as the prior IDDR did.
One reference to "IDDR" deliberately kept inside a negation
("no IDDR, no rise/fall demux") — explicitly contrasts the current
SDR topology with the broken pre-C-4 shape so a reader who finds the
old vocabulary in git history understands what changed.
adc_clk_mmcm_integration.md was a 164-line "Step 1: modify
ad9484_interface_400m.v / Replace the BUFG instantiation with
adc_clk_mmcm" walkthrough for an integration that is already
shipped in the production RTL.
ad9484_interface_400m.v lines 68-102 already document the BUFIO/BUFG/
MMCME2 topology inline ("MMCME2 jitter-cleaning wrapper replaces the
direct BUFG. The PLL feedback loop attenuates input jitter from ~50 ps
to ~20-30 ps..."). adc_clk_mmcm.v has the wrapper itself with its own
header. constraints/adc_clk_mmcm.xdc carries the timing rationale.
A future engineer reading the integration recipe could mistake the
already-integrated feature for unfinished work and try to redo the
"Step 1: modify" edits — actively harmful.
Bench-verify the F-7.4 MMCM-lock gating (commit 6738f12) under real
Xilinx UNISIM primitives, not just the iverilog stub.
ad9484_interface_400m.v
Add `timescale 1ns / 1ps. Lone RTL file missing it; xelab refused
to elaborate alongside TB+wrapper that both declared a timescale.
tb/tb_ad9484_xsim.v
Test Group 2 ("adc_dco_bufg toggles") moved below the first
wait_for_adc_ready() — adc_dco_bufg now sources MMCM CLKOUT, which
is gated until the MMCM SIM model locks (~4096 DCO cycles after
reset deassert). Sampling pre-lock saw a stuck output, not a real
BUFG defect.
Test 17 SDR-ramp "no skips" tolerance 0 → 1 — Test 15 already
grants a 6-sample startup-transient window for diff_one_count.
Observed delta-other = 1 of 63 is the same pipeline-startup
transient (first valid sample arrives before ramp launch
aligns), not a demux bug.
scripts/50t/run_ad9484_xsim.sh (new)
xvlog + glbl.v + xelab -L unisims_ver -L secureip + xsim --runall.
Mirrors run_xfft_xsim.sh / run_mf_chain_xsim.sh pattern.
Verification:
remote Vivado 2025.2 xsim → 17 / 17 PASS (** ALL TESTS PASSED **)
local iverilog regression → 43 / 0 / 0 (was 37 / 0 / 6)
Closes PR-N #86 on the real simulator path.
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.
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.
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).
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.