Commit Graph

495 Commits

Author SHA1 Message Date
Jason 98ec9cb6a5 fix(fpga): PR-AA — doppler_mag 1-cell shift in usb emit FSM
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
2026-05-06 10:30:54 +05:45
Jason d9e7a5becf fix(fpga): PR-Y.1 + PR-X.3 — DC notch boundary, audit cleanups, formal retarget
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.
2026-05-06 01:23:21 +05:45
Jason 9c231d85db fix(fpga): PR-Z A6 — usb cfar dense bug end-to-end fix + e2e test
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.
2026-05-06 01:20:19 +05:45
Jason ce869e9e20 docs(fpga): PR-X.2 F-7.3 — refresh tb_ad9484_xsim header + retire Group 4
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).
2026-05-05 13:48:30 +05:45
Jason d6462c6d5b docs(fpga): PR-X.2 F-7.2 — refresh adc_clk_mmcm.xdc comments to SDR IFF
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.
2026-05-05 13:46:12 +05:45
Jason f9e64c420b docs(fpga): PR-X.2 F-7.1 — delete stale MMCM integration recipe
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.
2026-05-05 13:44:43 +05:45
Jason 25e1f76841 fix(fpga): PR-X.1.b — close F-7.4 on real Vivado xsim
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.
2026-05-05 13:33:48 +05:45
Jason bf83d35917 test(fpga): PR-M.4 — redesign T-6 drift invariants for scaled-FFT chain
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).
2026-05-05 12:37:26 +05:45
Jason 0100967eac fix(fpga): PR-X.1 F-7.7 — wire AD9484 OR sticky to shared clear pulse
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).
2026-05-05 12:15:15 +05:45
Jason e2cb7bd2d6 fix(fpga): PR-X.1 F-7.6 — correct offset-binary mid-scale conversion in DDC
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).
2026-05-05 12:12:04 +05:45
Jason 6738f12e54 test(fpga): PR-X.1 F-7.4 — gate tb_ad9484_xsim on MMCM lock (closes PR-N #86)
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).
2026-05-05 11:59:30 +05:45
Jason 83cbc91d8b refactor(mcu): PR-W F-6.7 — privatize setADTR1107Mode
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.
2026-05-05 11:30:46 +05:45
Jason e3bd885be9 fix(mcu): PR-W F-6.3 — clear opposite REG_MISC_ENABLES bit in setADTR1107Mode
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).
2026-05-05 11:30:25 +05:45
Jason f23b35b719 chore(mcu): PR-W F-6.1 — prune dead ADAR1000Manager surface
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.
2026-05-05 11:29:32 +05:45
Jason 40d352ee68 chore(sim): prune superseded antenna scripts — keep edge-fed-row v3 path
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).
2026-05-05 10:47:04 +05:45
Jason 0728d931c4 chore(repo): PR-H — G-series close-out (regression infra + lint sweep)
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.
2026-05-05 10:39:57 +05:45
Jason b3edc7d359 test(v7): port live-vs-replay physical-units parity guard from develop
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>
2026-05-05 09:30:26 +05:45
Jason 00d5d5f220 fix(mcu): PR-V — ADF4382A Stage-5 audit fixes (F-5.1..F-5.10)
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).
2026-05-05 09:20:06 +05:45
Jason e1e5ae464a fix(mcu): F-4.3/4.4 (Option A) — AD9523 PLL1 bypass for first bring-up
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.
2026-05-04 23:39:06 +05:45
Jason 05472c1493 fix(mcu): F-4.5 + F-4.6 — AD9523 heap/lifecycle hygiene
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).
2026-05-04 22:06:08 +05:45
Jason ddc0df464e fix(mcu): F-4.1+4.2+4.7 — AD9523 init order + M1 divider + channel math
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.
2026-05-04 21:52:53 +05:45
Jason f0cff2cda7 docs(reports): replace AERIS_Antenna_Report.pdf with v2
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.
2026-05-04 21:08:57 +05:45
Jason acfbbb1d4d sim(antenna): probe_fed_array v3 — multi-port DRIVEN_PORTS env override
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.
2026-05-04 21:06:46 +05:45
Jason 416601d1d0 chore(tests): retire v1 cross-layer iverilog cosim tier
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.
2026-05-04 21:06:34 +05:45
Jason b84aa6a6f3 fix(mcu): F-3.1 Error_Handler reset + audit cleanup tail
F-3.1 (functional): Error_Handler() now calls NVIC_SystemReset() instead
of __disable_irq(); while(1). Every MX_*_Init() helper invokes
Error_Handler before MX_IWDG_Init() runs, so an infinite spin would brick
the MCU on any transient boot-time glitch with no watchdog to recover.
SystemReset turns a hard-to-debug brick into a visible reboot loop.

F-3.3..F-3.8 (comment hygiene in main.cpp init helpers + post-init):
  - TIM3 init: clarify 1 MHz tick @ 72 MHz timer clock (APB1=36 MHz but
    RCC_TIMPRES_ACTIVATED forces TIMxCLK=HCLK)
  - GPIO init: fix EN_P_3V3_ADAR12EN_P_3V3_VDD_SW_Pin → EN_P_3V3_VDD_SW_Pin
    typo; correct PD8-11 → PD8-12 and PD12-15 → PD13-15 ranges
  - SystemClock_Config: add VOS3 + 72 MHz intent comment
  - MPU_Config: decode SubRegionDisable=0x87 bitmask

D1/D6/D7 (ADAR cleanup tail): code was already deleted in a prior pass;
this strips the residual tombstone comments per the no-tombstone feedback
policy.
  - ADAR1000_Manager.h: 5 tombstone blocks removed (fastTXMode/etc,
    setBeamAngle/4-phase/BeamConfig, setADTR1107Control, Configuration
    section + setSwitchSettlingTime/setFastSwitchMode/setBeamDwellTime,
    setTRSwitchPosition)
  - ADAR1000_Manager.cpp: 6 tombstone comments removed; switchToRXMode
    Step 4→3, Step 5→4 renumbered after Step-3 gap
  - ADAR1000_AGC.cpp: stale "(matching the convention in setBeamAngle)"
    reference removed
  - main.cpp:556-557: redundant "setFastSwitchMode(true) call removed"
    tombstone removed

D2 (comment-only): initializeBeamMatrices() and runRadarPulseSequence
descriptions rewritten to describe array-math peak (matrix1 → NEGATIVE
θ peak, matrix2 → POSITIVE θ peak) instead of the misleading "positive
phase difference" framing. Sky/ground sign vs antenna mount explicitly
flagged unverified — functional sign question remains hardware-blocked
pending calibrated-source bench test.

Regression: 86/0.
2026-05-04 21:06:23 +05:45
Jason 53f7d1e3ee chore(mcu): C-14a — delete dead ADF4382A EZSync surface
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).
2026-05-04 21:05:50 +05:45
Jason 38ee73a05c sim(antenna): verify production beam tables — setBeamAngle() is dead code
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.
2026-05-04 02:05:34 +05:45
Jason 2f4d45caa7 sim(antenna): nf2ff far-field + ADAR1000 array-factor verification
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
2026-05-04 01:55:04 +05:45
Jason abc7e3c66b sim(antenna): center 1x8 row dip on radar TX 10.520 GHz (CONN_LEN 8.0->8.15)
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.
2026-05-04 00:41:28 +05:45
Jason 087a0563c0 sim(antenna): add 1x8 series-fed row — covers radar TX 10.51-10.53 GHz at -10 to -14 dB
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.
2026-05-04 00:22:54 +05:45
Jason 178cb26abd sim(antenna): add edge-fed (inset) single-element on 0.508 mm RO4350B — 180 MHz BW
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.
2026-05-03 18:24:05 +05:45
Jason eb9be337b1 sim(antenna): add 4x4 probe-fed array model — mutual coupling characterisation
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.
2026-05-03 16:18:42 +05:45
Jason 3f3846b514 sim(antenna): add probe-fed 2-layer patch model — 180 MHz BW vs aperture-coupled 60 MHz
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.
2026-05-03 15:28:57 +05:45
Jason e01c2ae424 sim(antenna): tune aperture-coupled v2 to design point — R matched, X residual documented
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.
2026-05-03 00:26:41 +05:45
Jason 42056b8331 sim(antenna): add OpenEMS aperture-coupled patch model for Stack_Hybrid v2
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.
2026-05-02 22:37:25 +05:45
Jason e5d98533ca Merge remote-tracking branch 'origin/main' into feat/dual-range-v2 2026-05-02 19:33:50 +05:45
Jason ef32345b26 feat(rtl,gui): PR-U / M-8 — sub-frame enable mask routed end-to-end (C-5 hardening)
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.
2026-05-02 17:49:16 +05:45
Jason 8ebb7016de chore(repo): PR-S — m-1..m-9 hygiene sweep (audit cleanup)
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.
2026-05-02 17:13:12 +05:45
Jason c2637251b0 feat(gui): PR-R — host control surface fill-in (audit M-2/M-3/M-4/M-6/M-7)
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).
2026-05-02 17:03:09 +05:45
Jason 115c5f0778 feat(gui): M-1 / PR-Q.7 — dashboard CRT confidence column + alias-fold tooltip (C-5)
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).
2026-05-02 16:51:58 +05:45
Jason 3401d05eca fix(gui): P-6 / PR-Q.6 — workers route detections through CRT extractor (C-5)
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).
2026-05-02 16:47:05 +05:45
Jason b505266f33 fix(mcu): P-5 — align radar params with PR-F/PR-Q.1; document mode-01 production stance
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.
2026-05-02 16:40:32 +05:45
Jason 8004c59674 fix(gui): P-4 — dashboard NUM_RANGE_BINS 64 → 512 (import from radar_protocol)
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.
2026-05-02 16:34:20 +05:45
Jason 9fbb7150b0 fix(gui): P-2/P-3 — bulk-frame parser + status packet caught up to PR-G v2
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.
2026-05-02 16:24:51 +05:45
Jason fcbf243aba fix(gui): P-1 — RadarDataWorker __init__ initialises runtime attrs
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).
2026-05-02 16:08:24 +05:45
Jason 3d2ffc3f2c chore(repo): cosim_dir replay revival + ruff lint cleanup
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.
2026-05-02 15:45:56 +05:45
Jason 5a7e8b8689 feat(gui): PR-Q.5 — 3-PRI CRT Doppler unfolder + cluster extractor (C-5)
Add host-side 3-PRI Chinese-Remainder velocity unfolding and a cluster
extractor that reads the 48-bin Doppler frame, splits it into the 3
sub-frames (SHORT/MEDIUM/LONG), and resolves Doppler aliases across
coprime PRIs.  Resolves the algorithm half of audit C-5; the data is
now in extract_targets_from_frame_crt's hands but workers still call
the legacy single-PRI extractor (PR-Q.6 wires it).

v7/processing.py:
- unfold_velocity_crt(v_meas, v_unamb, v_res, max_alias_k=6,
  tol_factor=0.5) -> (v_est, confidence, alias_set).  Brute-force
  candidate search over PRI-0 fold depth, per-PRI half-bin
  tolerance.  Confidence: CONFIRMED (3-PRI unique), LIKELY (3-PRI
  with 2 cands, or 2-PRI with unique cand), AMBIGUOUS (1-PRI, 3+
  cands, 2-PRI multi-cand, or no fold within tol).
- extract_targets_from_frame_crt(frame, waveform, gps, max_alias_k):
  groups detections by range bin, picks strongest bin per
  (rbin, sf), decodes signed Doppler via sub_frame = dbin // 16 /
  bin_in_sf = dbin % 16, calls unfold_velocity_crt, attaches
  velocity_confidence and alias_set to RadarTarget.  Falls back to
  legacy extract_targets_from_frame for non-48-bin frames.

v7/models.py:
- RadarTarget gains velocity_confidence (str default "UNKNOWN") and
  alias_set (list[float] | None).

v7/__init__.py:
- Re-exports unfold_velocity_crt + extract_targets_from_frame_crt.

test_v7.py (16 new tests, 0 failures):
- TestUnfoldVelocityCRT (8): zero-velocity CONFIRMED, below per-PRI
  v_unamb CONFIRMED, above per-PRI (100 m/s) CONFIRMED, near CRT
  ceiling (~261 m/s) CONFIRMED, negative velocity, 1-PRI AMBIGUOUS,
  2-PRI LIKELY, inconsistent measurements AMBIGUOUS+fallback.
- TestExtractTargetsFromFrameCrt (8): 3-PRI CONFIRMED target,
  LONG-only AMBIGUOUS (the 20-km blindspot regime), 2-PRI LIKELY,
  strongest-bin picking, two targets at distinct ranges, legacy
  32-bin frame fallback, no-detections empty, GPS georef.

Local: test_v7 100/0/0 (9 graceful skips), test_GUI_V65_Tk 117/0/2.
2026-05-02 15:23:17 +05:45
Jason 54627bbbe3 fix(gui): software_fpga revival post-e8b495c — port chain helpers to fpga_model
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.
2026-05-02 15:22:54 +05:45
Jason 71afa96d68 fix(gui): PR-Q.4 — per-subframe WaveformConfig + 48-bin parser (C-5)
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
2026-05-02 14:33:43 +05:45
Jason 7ed4d5d405 test(fpga): PR-Q.2 — align cosim T_PRI_MEDIUM 175->161 us + regen goldens
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).
2026-05-02 14:33:23 +05:45