From ef32345b268defe19f1a9309922e121afab54756 Mon Sep 17 00:00:00 2001 From: Jason <83615043+JJassonn69@users.noreply.github.com> Date: Sat, 2 May 2026 17:49:16 +0545 Subject: [PATCH] =?UTF-8?q?feat(rtl,gui):=20PR-U=20/=20M-8=20=E2=80=94=20s?= =?UTF-8?q?ub-frame=20enable=20mask=20routed=20end-to-end=20(C-5=20hardeni?= =?UTF-8?q?ng)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- 9_Firmware/9_2_FPGA/radar_receiver_final.v | 8 +- 9_Firmware/9_2_FPGA/radar_system_top.v | 20 +++ .../9_2_FPGA/tb/tb_ft2232h_frame_drop.v | 4 + 9_Firmware/9_2_FPGA/tb/tb_usb_protocol_v2.v | 10 +- .../9_2_FPGA/usb_data_interface_ft2232h.v | 34 ++++- 9_Firmware/9_3_GUI/radar_protocol.py | 66 ++++++-- 9_Firmware/9_3_GUI/test_v7.py | 144 +++++++++++++++++- 9_Firmware/9_3_GUI/v7/dashboard.py | 4 + 9_Firmware/9_3_GUI/v7/processing.py | 11 ++ 9_Firmware/9_3_GUI/v7/software_fpga.py | 9 ++ 10 files changed, 283 insertions(+), 27 deletions(-) diff --git a/9_Firmware/9_2_FPGA/radar_receiver_final.v b/9_Firmware/9_2_FPGA/radar_receiver_final.v index 6934eb5..3ca50f7 100644 --- a/9_Firmware/9_2_FPGA/radar_receiver_final.v +++ b/9_Firmware/9_2_FPGA/radar_receiver_final.v @@ -51,6 +51,10 @@ module radar_receiver_final ( input wire [15:0] host_medium_chirp_cycles, input wire [15:0] host_medium_listen_cycles, input wire [5:0] host_chirps_per_elev, + // PR-U / M-8: sub-frame enable mask {LONG, MEDIUM, SHORT}. Was tied to + // RP_DEF_SUBFRAME_ENABLE here at the chirp_scheduler instance; routed + // through radar_system_top opcode 0x19 so the host owns the mask. + input wire [2:0] host_subframe_enable, // Digital gain control (Fix 3: between DDC output and matched filter) // [3]=direction: 0=amplify(left shift), 1=attenuate(right shift) @@ -236,7 +240,9 @@ chirp_scheduler sched ( .clk(clk), .reset_n(reset_n), .host_mode(host_mode), - .host_subframe_enable(`RP_DEF_SUBFRAME_ENABLE), + // PR-U / M-8: routed from radar_system_top opcode 0x19 (was the + // RP_DEF_SUBFRAME_ENABLE constant — host had no way to mask sub-frames). + .host_subframe_enable(host_subframe_enable), .host_short_chirp_cycles (host_short_chirp_cycles), .host_short_listen_cycles(host_short_listen_cycles), // PR-G G2: MEDIUM now flows from radar_system_top opcodes 0x17/0x18. diff --git a/9_Firmware/9_2_FPGA/radar_system_top.v b/9_Firmware/9_2_FPGA/radar_system_top.v index 5259562..3e6e87f 100644 --- a/9_Firmware/9_2_FPGA/radar_system_top.v +++ b/9_Firmware/9_2_FPGA/radar_system_top.v @@ -272,6 +272,11 @@ reg [15:0] host_short_listen_cycles; // Opcode 0x14 (default 17400, V2) reg [15:0] host_medium_chirp_cycles; // Opcode 0x17 (default 500, PR-G G2) reg [15:0] host_medium_listen_cycles; // Opcode 0x18 (default 15600, PR-Q staggered PRI) reg [5:0] host_chirps_per_elev; // Opcode 0x15 (default 48 = RP_CHIRPS_PER_FRAME, PR-F) +// PR-U / M-8: per-sub-frame enable mask routed end-to-end so the host knows +// which sub-frames the chirp_scheduler emitted for a given frame. Bit 0 SHORT, +// bit 1 MEDIUM, bit 2 LONG. Default 3'b111 keeps the production 3-PRI ladder. +// Mirrored into v2 frame byte 2 bits[5:3] (usb_data_interface_ft2232h.v). +reg [2:0] host_subframe_enable; // Opcode 0x19 (default RP_DEF_SUBFRAME_ENABLE = 3'b111) reg host_status_request; // Opcode 0xFF (self-clearing pulse) // Fix 4: Doppler/chirps mismatch protection @@ -604,6 +609,9 @@ radar_receiver_final rx_inst ( .host_medium_chirp_cycles(host_medium_chirp_cycles), .host_medium_listen_cycles(host_medium_listen_cycles), .host_chirps_per_elev(host_chirps_per_elev), + // PR-U / M-8: sub-frame enable mask, was tied to RP_DEF_SUBFRAME_ENABLE + // inside radar_receiver_final at the chirp_scheduler instance. + .host_subframe_enable(host_subframe_enable), // Fix 3: digital gain control .host_gain_shift(host_gain_shift), // AGC configuration (opcodes 0x28-0x2C) @@ -933,6 +941,11 @@ end else begin : gen_ft2232h // Stream control .stream_control(host_stream_control), + // PR-U / M-8: per-frame snapshot of host_subframe_enable echoed in + // v2 frame byte 2 bits[5:3]. Lets the host detect when an operator + // disabled a sub-frame and downgrade CRT confidence accordingly. + .subframe_enable(host_subframe_enable), + // Status readback inputs .status_request(host_status_request), .status_cfar_threshold(host_detect_threshold), @@ -1060,6 +1073,8 @@ always @(posedge clk_100m_buf or negedge sys_reset_n) begin // chirps_per_elev register is echoed in status word 3 and used by host // sanity-checking. Keep cold-reset value in lockstep with the truth. host_chirps_per_elev <= 6'd48; + // PR-U / M-8: 3'b111 = SHORT|MEDIUM|LONG all on (production 3-PRI ladder). + host_subframe_enable <= `RP_DEF_SUBFRAME_ENABLE; host_status_request <= 1'b0; chirps_mismatch_error <= 1'b0; host_range_mode <= 2'b00; // Default: 3 km mode (all short chirps) @@ -1111,6 +1126,11 @@ always @(posedge clk_100m_buf or negedge sys_reset_n) begin // PR-G G2: MEDIUM ladder timings 8'h17: host_medium_chirp_cycles <= usb_cmd_value; 8'h18: host_medium_listen_cycles <= usb_cmd_value; + // PR-U / M-8: sub-frame enable mask {LONG, MEDIUM, SHORT} = + // {value[2], value[1], value[0]}. Surfaced in v2 frame byte 2 + // bits[5:3] so host CRT can detect mask != 3'b111 and degrade + // confidence rather than mis-attribute the SF axis. + 8'h19: host_subframe_enable <= usb_cmd_value[2:0]; 8'h15: begin // Fix 4: Clamp chirps_per_elev to the fixed Doppler frame size. // If host requests a different value, clamp and set error flag. diff --git a/9_Firmware/9_2_FPGA/tb/tb_ft2232h_frame_drop.v b/9_Firmware/9_2_FPGA/tb/tb_ft2232h_frame_drop.v index 8dc8f19..cb0e1b0 100644 --- a/9_Firmware/9_2_FPGA/tb/tb_ft2232h_frame_drop.v +++ b/9_Firmware/9_2_FPGA/tb/tb_ft2232h_frame_drop.v @@ -68,6 +68,8 @@ module tb_ft2232h_frame_drop; // PR-G: stream bits [2:0] all off → WR FSM: HDR → FOOTER → DONE // = fast deterministic drain. Bits [5:3] are reserved=0 in v2. reg [5:0] stream_control = 6'b000_000; + // PR-U / M-8: production 3-PRI ladder. + reg [2:0] subframe_enable = 3'b111; // Status inputs (irrelevant for this test) reg status_request = 1'b0; @@ -124,6 +126,8 @@ module tb_ft2232h_frame_drop; .cmd_addr(cmd_addr), .cmd_value(cmd_value), .stream_control(stream_control), + // PR-U / M-8: per-frame snapshot of host_subframe_enable. + .subframe_enable(subframe_enable), .status_request(status_request), .status_cfar_threshold(status_cfar_threshold), .status_stream_ctrl(status_stream_ctrl), diff --git a/9_Firmware/9_2_FPGA/tb/tb_usb_protocol_v2.v b/9_Firmware/9_2_FPGA/tb/tb_usb_protocol_v2.v index de11ed2..77b565d 100644 --- a/9_Firmware/9_2_FPGA/tb/tb_usb_protocol_v2.v +++ b/9_Firmware/9_2_FPGA/tb/tb_usb_protocol_v2.v @@ -66,6 +66,8 @@ module tb_usb_protocol_v2; // PR-G v2: enable all 3 streams (range|doppler|cfar). Bits [5:3] reserved=0. reg [5:0] stream_control = 6'b000_111; reg [5:0] status_stream_ctrl = 6'b000_111; + // PR-U / M-8: production 3-PRI ladder (SHORT|MEDIUM|LONG). + reg [2:0] subframe_enable = 3'b111; // Status inputs (mostly tied off; PR-G additions below) reg status_request = 1'b0; @@ -127,6 +129,9 @@ module tb_usb_protocol_v2; .cmd_addr(cmd_addr), .cmd_value(cmd_value), .stream_control(stream_control), + // PR-U / M-8: per-frame snapshot of host_subframe_enable echoed in + // v2 frame byte 2 bits[5:3]. + .subframe_enable(subframe_enable), .status_request(status_request), .status_cfar_threshold(status_cfar_threshold), .status_stream_ctrl(status_stream_ctrl), @@ -251,7 +256,10 @@ module tb_usb_protocol_v2; wait_clk(150); check_b("T2.1: byte0 = 0xAA", egress_bytes[0] == 8'hAA); check_b("T2.2: byte1 = 0x02 (ver)", egress_bytes[1] == `RP_USB_PROTOCOL_VERSION); - check_b("T2.3: byte2 = stream flags=0", egress_bytes[2] == 8'h00); + // PR-U / M-8: byte 2 = {2'b00, subframe_enable[2:0], stream[2:0]}. + // subframe_enable defaults to 3'b111 → byte 2 = (0b111 << 3) | 0 = 0x38. + check_b("T2.3: byte2 = {00, sf=111, stream=0} = 0x38", + egress_bytes[2] == 8'h38); // Byte 3-4 = frame_number snapshot. snapshot latches OLD frame_number // at frame_complete (NBA), so first frame emitted carries fn=0. check_b("T2.4: byte3 = fn[15:8]=0", egress_bytes[3] == 8'h00); diff --git a/9_Firmware/9_2_FPGA/usb_data_interface_ft2232h.v b/9_Firmware/9_2_FPGA/usb_data_interface_ft2232h.v index 764a673..b943c06 100644 --- a/9_Firmware/9_2_FPGA/usb_data_interface_ft2232h.v +++ b/9_Firmware/9_2_FPGA/usb_data_interface_ft2232h.v @@ -13,7 +13,11 @@ * Frame packet (FPGA→Host): variable length, up to 74,762 bytes * Byte 0: 0xAA (frame start header) * Byte 1: 0x02 (PROTOCOL VERSION — host MUST reject any other value) - * Byte 2: Stream flags {5'b0, stream_cfar, stream_doppler, stream_range} + * Byte 2: Flags byte. Layout (PR-U / M-8 widened bits[5:3]): + * bits[7:6] = 2'b00 reserved + * bits[5:3] = subframe_enable[2:0] = {LONG, MEDIUM, SHORT} + * (host_subframe_enable snapshot at frame_complete) + * bits[2:0] = {stream_cfar, stream_doppler, stream_range} * Bytes 3-4: Frame number (uint16, MSB first) * Bytes 5-6: Range bin count (uint16, MSB first) = `RP_NUM_RANGE_BINS` (512) * Bytes 7-8: Doppler bin count (uint16, MSB first) = `RP_NUM_DOPPLER_BINS` (48) @@ -127,6 +131,13 @@ module usb_data_interface_ft2232h ( // Stream control input (clk domain, CDC'd internally) input wire [5:0] stream_control, + // PR-U / M-8: per-frame sub-frame enable mask (clk domain, CDC'd + // internally, snapshotted at frame_complete). {LONG, MEDIUM, SHORT}. + // Echoed in v2 frame byte 2 bits[5:3] so the host CRT can detect + // when an operator disables a sub-frame and downgrade confidence + // (default 3'b111 keeps the production 3-PRI ladder behavior). + input wire [2:0] subframe_enable, + // Status readback inputs (clk domain, CDC'd internally) input wire status_request, input wire [15:0] status_cfar_threshold, @@ -643,15 +654,21 @@ wire stream_cfar_en = stream_ctrl_sync_1[2]; // --- Frame metadata snapshot (latched in clk domain, stable for ft_clk read) --- reg [15:0] frame_number_snapshot; -reg [2:0] stream_flags_snapshot; // PR-G: 3 bits used (range/doppler/cfar) +reg [2:0] stream_flags_snapshot; // PR-G: 3 bits used (range/doppler/cfar) +// PR-U / M-8: snapshot of host_subframe_enable taken at frame_complete so the +// host parser sees the mask that was active for THIS frame (atomic per-frame). +// Stable when ft_clk reads it via the frame_ready toggle synchronizer. +reg [2:0] subframe_enable_snapshot; // {LONG, MEDIUM, SHORT} always @(posedge clk or negedge reset_n) begin if (!reset_n) begin - frame_number_snapshot <= 16'd0; - stream_flags_snapshot <= 3'b111; // PR-G: all 3 streams on (range|doppler|cfar) + frame_number_snapshot <= 16'd0; + stream_flags_snapshot <= 3'b111; // PR-G: all 3 streams on (range|doppler|cfar) + subframe_enable_snapshot <= 3'b111; // PR-U: all 3 sub-frames on (production default) end else if (frame_complete) begin - frame_number_snapshot <= frame_number; - stream_flags_snapshot <= stream_control[2:0]; // PR-G: ignore reserved [5:3] + frame_number_snapshot <= frame_number; + stream_flags_snapshot <= stream_control[2:0]; // PR-G: ignore reserved [5:3] + subframe_enable_snapshot <= subframe_enable; end end @@ -968,7 +985,10 @@ always @(posedge ft_clk or negedge ft_effective_reset_n) begin case (wr_byte_idx[3:0]) 4'd0: ft_data_out <= HEADER; 4'd1: ft_data_out <= `RP_USB_PROTOCOL_VERSION; // 0x02 - 4'd2: ft_data_out <= {5'b00000, stream_flags_snapshot}; + // PR-U / M-8: byte 2 = {2'b00, subframe_enable[2:0], stream_flags[2:0]}. + // Was {5'b00000, stream_flags_snapshot}; bits[5:3] now carry + // the per-frame sub-frame mask snapshot {LONG, MEDIUM, SHORT}. + 4'd2: ft_data_out <= {2'b00, subframe_enable_snapshot, stream_flags_snapshot}; 4'd3: ft_data_out <= frame_number_snapshot[15:8]; 4'd4: ft_data_out <= frame_number_snapshot[7:0]; 4'd5: ft_data_out <= NUM_RANGE_BINS[15:8]; // 512 >> 8 = 2 diff --git a/9_Firmware/9_3_GUI/radar_protocol.py b/9_Firmware/9_3_GUI/radar_protocol.py index e496008..e001abc 100644 --- a/9_Firmware/9_3_GUI/radar_protocol.py +++ b/9_Firmware/9_3_GUI/radar_protocol.py @@ -99,12 +99,22 @@ BULK_FRAME_MAX_SIZE = (BULK_FRAME_HEADER_SIZE + BULK_RANGE_SECTION_BYTES + BULK_DOPPLER_MAG_BYTES + BULK_DETECT_DENSE_BYTES + BULK_FOOTER_SIZE) # 56330 -# Bulk-frame format flag bits (matches stream_ctrl_sync_1 layout in RTL). -# Only the low 3 bits are used on the wire; bits [7:3] are reserved-zero. +# Bulk-frame format flag bits. +# Layout (PR-U / M-8): +# bits[2:0] = stream flags {cfar, doppler, range} (matches stream_ctrl_sync_1) +# bits[5:3] = subframe_enable mask {LONG, MEDIUM, SHORT} +# snapshot of host_subframe_enable at frame_complete (FPGA opcode 0x19). +# Default 3'b111 keeps the production 3-PRI ladder; mask != 3'b111 +# means an operator disabled a sub-frame and the host should +# downgrade CRT confidence (dbin // 16 attribution would mis-bin). +# bits[7:6] = reserved-zero — any non-zero in this mask rejects the frame. BULK_FLAG_STREAM_RANGE = 0x01 BULK_FLAG_STREAM_DOPPLER = 0x02 BULK_FLAG_STREAM_CFAR = 0x04 -BULK_FLAGS_RESERVED_MASK = 0xF8 # any bit in this mask set → reject frame +BULK_SUBFRAME_ENABLE_MASK = 0x38 # bits[5:3] = subframe_enable[2:0] +BULK_SUBFRAME_ENABLE_SHIFT = 3 +BULK_SUBFRAME_ENABLE_ALL = 0b111 # SHORT | MEDIUM | LONG +BULK_FLAGS_RESERVED_MASK = 0xC0 # any bit in this mask set → reject frame class Opcode(IntEnum): @@ -124,6 +134,7 @@ class Opcode(IntEnum): 0x16 host_gain_shift 0x17 host_medium_chirp_cycles (PR-G G2) 0x18 host_medium_listen_cycles (PR-G G2) + 0x19 host_subframe_enable (PR-U / M-8 — 3-bit {LONG, MED, SHORT} mask) """ # --- Basic control (0x01-0x04) --- RADAR_MODE = 0x01 # 2-bit mode select @@ -146,6 +157,13 @@ class Opcode(IntEnum): MEDIUM_CHIRP = 0x17 MEDIUM_LISTEN = 0x18 + # PR-U / M-8: 3-bit sub-frame enable mask {LONG, MEDIUM, SHORT}. Default + # 3'b111 = all on. Setting != 3'b111 disables a sub-frame at the chirp + # scheduler; the FPGA echoes the mask in v2 frame byte 2 bits[5:3] so the + # host CRT downgrades confidence to UNKNOWN (dbin // 16 attribution would + # otherwise be wrong when the scheduler skips a sub-frame). + SUBFRAME_ENABLE = 0x19 + # --- Signal processing (0x20-0x27) --- RANGE_MODE = 0x20 CFAR_GUARD = 0x21 @@ -209,6 +227,14 @@ class RadarFrame: # mag_only=1 (the only mode FPGA emits today). I/Q arrays will be zero; # `magnitude` carries the per-cell Manhattan magnitude from the FPGA. mag_only: bool = False + # PR-U / M-8: 3-bit sub-frame mask {LONG, MEDIUM, SHORT} snapshot from + # the FPGA at frame_complete (v2 frame byte 2 bits[5:3]). Default 0b111 + # is the production 3-PRI ladder. Anything else means an operator + # disabled a sub-frame and the host CRT must downgrade confidence — + # `dbin // 16 → {SHORT, MED, LONG}` no longer attributes correctly when + # the chirp scheduler runs only the enabled sub-frames into 48 chirp + # slots in the doppler_processor. + subframe_enable: int = 0b111 @dataclass @@ -456,10 +482,14 @@ class RadarProtocol: return None flags = raw[offset + 2] - # Only the low 3 bits are defined (range/doppler/cfar). Any reserved - # bit set means a future revision or corruption — reject and resync. + # bits[2:0] = stream {cfar,doppler,range}; bits[5:3] = subframe_enable; + # bits[7:6] reserved-zero. Any reserved bit set means a future revision + # or corruption — reject and resync. if flags & BULK_FLAGS_RESERVED_MASK: return None + # PR-U / M-8: surface the per-frame sub-frame mask so the host CRT can + # detect mask != 0b111 and degrade rather than mis-attribute the SF axis. + subframe_enable = (flags & BULK_SUBFRAME_ENABLE_MASK) >> BULK_SUBFRAME_ENABLE_SHIFT frame_number = (raw[offset + 3] << 8) | raw[offset + 4] n_range = (raw[offset + 5] << 8) | raw[offset + 6] @@ -497,14 +527,15 @@ class RadarProtocol: cursor += BULK_DETECT_DENSE_BYTES return { - "frame_number": frame_number, - "flags": flags, - "n_range": n_range, - "n_doppler": n_doppler, - "range_profile": range_profile, - "doppler_mag": doppler_mag, - "cfar_dense": cfar_dense, - "frame_size": size, + "frame_number": frame_number, + "flags": flags, + "subframe_enable": subframe_enable, + "n_range": n_range, + "n_doppler": n_doppler, + "range_profile": range_profile, + "doppler_mag": doppler_mag, + "cfar_dense": cfar_dense, + "frame_size": size, } @staticmethod @@ -732,7 +763,11 @@ class FT2232HConnection: buf = bytearray(BULK_FRAME_MAX_SIZE) buf[0] = HEADER_BYTE buf[1] = RP_USB_PROTOCOL_VERSION - buf[2] = flags & 0x07 # only 3 stream-enable bits valid; reserved zero + # PR-U / M-8: byte 2 = bits[2:0] stream + bits[5:3] subframe_enable + + # bits[7:6] reserved-zero. Mock emits the production 3-PRI ladder + # (mask = 0b111) so dashboards see CONFIRMED CRT confidence. + buf[2] = ((BULK_SUBFRAME_ENABLE_ALL << BULK_SUBFRAME_ENABLE_SHIFT) + | (flags & 0x07)) buf[3] = (self._mock_frame_num >> 8) & 0xFF buf[4] = self._mock_frame_num & 0xFF buf[5] = (NUM_RANGE_BINS >> 8) & 0xFF @@ -1120,6 +1155,9 @@ class RadarAcquisition(threading.Thread): # path implemented in the FPGA write FSM), so flag this for downstream # consumers that expect mag-only when reading from bulk. frame.mag_only = True + # PR-U / M-8: per-frame snapshot of host_subframe_enable (FPGA opcode + # 0x19, default 0b111). The CRT extractor uses this to gate confidence. + frame.subframe_enable = int(parsed.get("subframe_enable", 0b111)) & 0x07 rprof = parsed["range_profile"] if rprof is not None: diff --git a/9_Firmware/9_3_GUI/test_v7.py b/9_Firmware/9_3_GUI/test_v7.py index 8a445ee..8b3db7d 100644 --- a/9_Firmware/9_3_GUI/test_v7.py +++ b/9_Firmware/9_3_GUI/test_v7.py @@ -370,16 +370,26 @@ class TestBulkFrameV2RoundTrip(unittest.TestCase): def _build_v2_frame(self, flags: int, frame_num: int = 0, doppler: np.ndarray | None = None, cfar_codes: np.ndarray | None = None, - range_profile: np.ndarray | None = None) -> bytes: - """Construct a v2 frame the way usb_data_interface_ft2232h.v emits.""" + range_profile: np.ndarray | None = None, + subframe_enable: int = 0b111) -> bytes: + """Construct a v2 frame the way usb_data_interface_ft2232h.v emits. + + ``subframe_enable`` lands in byte 2 bits[5:3] (PR-U / M-8). Caller + passes raw stream bits in ``flags`` (low 3 bits); helper composes the + full byte 2 = {2'b00, subframe_enable[2:0], stream[2:0]}. + """ from radar_protocol import ( HEADER_BYTE, FOOTER_BYTE, RP_USB_PROTOCOL_VERSION, NUM_RANGE_BINS, NUM_DOPPLER_BINS, BULK_FLAG_STREAM_RANGE, BULK_FLAG_STREAM_DOPPLER, BULK_FLAG_STREAM_CFAR, BULK_DETECT_BYTES_PER_RANGE, + BULK_SUBFRAME_ENABLE_SHIFT, ) + flags_byte = (((subframe_enable & 0x07) << BULK_SUBFRAME_ENABLE_SHIFT) + | (flags & 0x07) + | (flags & 0xC0)) # preserve reserved bits if caller injects them parts = [ - bytes([HEADER_BYTE, RP_USB_PROTOCOL_VERSION, flags & 0xFF]), + bytes([HEADER_BYTE, RP_USB_PROTOCOL_VERSION, flags_byte & 0xFF]), struct.pack(">H", frame_num), struct.pack(">H", NUM_RANGE_BINS), struct.pack(">H", NUM_DOPPLER_BINS), @@ -431,7 +441,11 @@ class TestBulkFrameV2RoundTrip(unittest.TestCase): parsed = RadarProtocol.parse_bulk_frame(frame) self.assertIsNotNone(parsed) self.assertEqual(parsed["frame_number"], 42) - self.assertEqual(parsed["flags"], flags) + # PR-U / M-8: byte 2 now packs subframe_enable into bits[5:3]; helper + # defaults to 0b111 (production 3-PRI ladder) so the wire flags byte + # is (0b111 << 3) | 0x07 = 0x3F. + self.assertEqual(parsed["flags"], flags | (0b111 << 3)) + self.assertEqual(parsed["subframe_enable"], 0b111) self.assertEqual(parsed["n_range"], NUM_RANGE_BINS) self.assertEqual(parsed["n_doppler"], NUM_DOPPLER_BINS) np.testing.assert_array_equal(parsed["range_profile"], rp) @@ -484,6 +498,56 @@ class TestBulkFrameV2RoundTrip(unittest.TestCase): self.assertEqual(boundaries[1], (len(f1), len(f1) + len(f2), "data")) +class TestSubframeEnableRoundTrip(TestBulkFrameV2RoundTrip): + """PR-U / M-8: byte 2 bits[5:3] carry the per-frame sub-frame mask.""" + + def test_default_mask_round_trip(self): + """Production default 0b111 round-trips and is the helper default.""" + from radar_protocol import ( + RadarProtocol, BULK_FLAG_STREAM_DOPPLER, + ) + frame = self._build_v2_frame(BULK_FLAG_STREAM_DOPPLER, frame_num=1) + parsed = RadarProtocol.parse_bulk_frame(frame) + self.assertIsNotNone(parsed) + self.assertEqual(parsed["subframe_enable"], 0b111) + + def test_short_disabled_mask(self): + """subframe_enable = 0b110 (LONG|MEDIUM, no SHORT) survives the wire.""" + from radar_protocol import ( + RadarProtocol, BULK_FLAG_STREAM_DOPPLER, + ) + frame = self._build_v2_frame(BULK_FLAG_STREAM_DOPPLER, frame_num=1, + subframe_enable=0b110) + parsed = RadarProtocol.parse_bulk_frame(frame) + self.assertIsNotNone(parsed) + self.assertEqual(parsed["subframe_enable"], 0b110) + + def test_short_only_mask(self): + """subframe_enable = 0b001 (SHORT only) survives the wire.""" + from radar_protocol import ( + RadarProtocol, BULK_FLAG_STREAM_DOPPLER, + ) + frame = self._build_v2_frame(BULK_FLAG_STREAM_DOPPLER, frame_num=2, + subframe_enable=0b001) + parsed = RadarProtocol.parse_bulk_frame(frame) + self.assertIsNotNone(parsed) + self.assertEqual(parsed["subframe_enable"], 0b001) + + def test_subframe_bits_no_longer_in_reserved_mask(self): + """Bits[5:3] are now valid SF mask, not reserved — must NOT reject.""" + from radar_protocol import ( + RadarProtocol, BULK_FLAGS_RESERVED_MASK, + BULK_SUBFRAME_ENABLE_MASK, + ) + # The new reserved mask must not overlap the SF-enable bit field. + self.assertEqual(BULK_FLAGS_RESERVED_MASK & BULK_SUBFRAME_ENABLE_MASK, 0) + # And bit 6 (top of new reserved mask) STILL rejects. + from radar_protocol import BULK_FLAG_STREAM_RANGE + frame = self._build_v2_frame(BULK_FLAG_STREAM_RANGE | 0x40) + bad = bytes([frame[0], frame[1], frame[2] | 0x40]) + frame[3:] + self.assertIsNone(RadarProtocol.parse_bulk_frame(bad)) + + class TestStatusPacketV2RoundTrip(unittest.TestCase): """PR-G v2 status packet: 7 status_words / 30 bytes.""" @@ -1492,6 +1556,52 @@ class TestExtractTargetsFromFrameCrt(unittest.TestCase): self.assertGreater(targets[0].longitude, 12.5) +class TestCrtSubframeMaskGating(unittest.TestCase): + """PR-U / M-8: CRT downgrades confidence to AMBIGUOUS when SF mask != 0b111.""" + + def _make_3pri_frame(self, subframe_enable: int): + from radar_protocol import RadarFrame + frame = RadarFrame() + # Detection at rbin=10 in all 3 sub-frames at bin 3 — would normally + # CONFIRM, but a non-default mask must force AMBIGUOUS. + for rbin, dbin, mag in [(10, 3, 1000.0), (10, 19, 800.0), (10, 35, 1200.0)]: + frame.detections[rbin, dbin] = 1 + frame.magnitude[rbin, dbin] = mag + frame.detection_count = int(frame.detections.sum()) + frame.timestamp = 1.0 + frame.subframe_enable = subframe_enable + return frame + + def test_default_mask_keeps_confirmed_path(self): + from v7.processing import extract_targets_from_frame_crt + from v7.models import WaveformConfig + wc = WaveformConfig() + frame = self._make_3pri_frame(0b111) + targets = extract_targets_from_frame_crt(frame, wc) + self.assertEqual(len(targets), 1) + self.assertEqual(targets[0].velocity_confidence, "CONFIRMED") + + def test_short_disabled_forces_ambiguous(self): + """SHORT off → CRT can't trust dbin // 16 attribution → AMBIGUOUS.""" + from v7.processing import extract_targets_from_frame_crt + from v7.models import WaveformConfig + wc = WaveformConfig() + frame = self._make_3pri_frame(0b110) + targets = extract_targets_from_frame_crt(frame, wc) + self.assertEqual(len(targets), 1) + self.assertEqual(targets[0].velocity_confidence, "AMBIGUOUS") + + def test_long_only_forces_ambiguous(self): + """LONG only mask: scheduler skips SHORT+MEDIUM, all targets AMBIGUOUS.""" + from v7.processing import extract_targets_from_frame_crt + from v7.models import WaveformConfig + wc = WaveformConfig() + frame = self._make_3pri_frame(0b100) + targets = extract_targets_from_frame_crt(frame, wc) + self.assertEqual(len(targets), 1) + self.assertEqual(targets[0].velocity_confidence, "AMBIGUOUS") + + # ============================================================================= # Test: PR-Q.6 — workers route through extract_targets_from_frame_crt # RadarDataWorker._run_host_dsp + ReplayWorker._extract_targets must use the @@ -1651,6 +1761,11 @@ class TestOpcodeEnumFillIn(unittest.TestCase): from radar_protocol import Opcode self.assertEqual(Opcode.ADC_FORMAT.value, 0x33) + def test_subframe_enable_opcode(self): + """PR-U / M-8: 0x19 sets host_subframe_enable mask.""" + from radar_protocol import Opcode + self.assertEqual(Opcode.SUBFRAME_ENABLE.value, 0x19) + def test_no_duplicate_opcodes(self): """All Opcode values are unique (catches accidental collisions).""" from radar_protocol import Opcode @@ -1674,6 +1789,21 @@ class TestSoftwareFpgaCfarAlphaSoft(unittest.TestCase): self.assertEqual(fpga.cfar_alpha_soft, 0x34) +class TestSoftwareFpgaSubframeEnable(unittest.TestCase): + """PR-U / M-8: SoftwareFPGA mirrors host_subframe_enable, masks to 3 bits.""" + + def test_default(self): + from v7.software_fpga import SoftwareFPGA + fpga = SoftwareFPGA() + self.assertEqual(fpga.subframe_enable, 0b111) # RP_DEF_SUBFRAME_ENABLE + + def test_setter_masks_to_3_bits(self): + from v7.software_fpga import SoftwareFPGA + fpga = SoftwareFPGA() + fpga.set_subframe_enable(0xFE) + self.assertEqual(fpga.subframe_enable, 0b110) + + @unittest.skipUnless(_pyqt6_available(), "PyQt6 not installed") class TestReplayOpcodeDispatch(unittest.TestCase): """M-6: replay dispatch routes 0x2D to SoftwareFPGA + acknowledges inert opcodes.""" @@ -1695,6 +1825,12 @@ class TestReplayOpcodeDispatch(unittest.TestCase): dispatch(fake, 0x2D, 42) self.assertEqual(fake._software_fpga.cfar_alpha_soft, 42) + def test_0x19_routed_to_set_subframe_enable(self): + """PR-U / M-8: 0x19 lands on SoftwareFPGA.set_subframe_enable.""" + dispatch, fake = self._dashboard_with_replay() + dispatch(fake, 0x19, 0b101) + self.assertEqual(fake._software_fpga.subframe_enable, 0b101) + def test_inert_opcode_does_not_raise(self): """Inert opcodes (e.g. 0x32 ADC_PWDN) accepted without exception.""" dispatch, fake = self._dashboard_with_replay() diff --git a/9_Firmware/9_3_GUI/v7/dashboard.py b/9_Firmware/9_3_GUI/v7/dashboard.py index be475d6..e99aa3a 100644 --- a/9_Firmware/9_3_GUI/v7/dashboard.py +++ b/9_Firmware/9_3_GUI/v7/dashboard.py @@ -1676,6 +1676,10 @@ class RadarDashboard(QMainWindow): 0x2B: lambda v: fpga.set_agc_params(decay=v), 0x2C: lambda v: fpga.set_agc_params(holdoff=v), 0x2D: lambda v: fpga.set_cfar_alpha_soft(v), + # PR-U / M-8: track the operator's sub-frame mask so subsequent + # frames the host parses use the correct CRT confidence rules + # (replay frames carry the mask the FPGA echoed at capture time). + 0x19: lambda v: fpga.set_subframe_enable(v), } # Inert in replay: RTL-only chirp timing / range mode / self-test / # status / ADC strap. The recorded I/Q already reflects whatever diff --git a/9_Firmware/9_3_GUI/v7/processing.py b/9_Firmware/9_3_GUI/v7/processing.py index d693e08..a041efc 100644 --- a/9_Firmware/9_3_GUI/v7/processing.py +++ b/9_Firmware/9_3_GUI/v7/processing.py @@ -698,6 +698,15 @@ def extract_targets_from_frame_crt( gps=gps, ) + # PR-U / M-8: when the operator disabled a sub-frame at the FPGA, the + # chirp_scheduler runs only the enabled SFs but doppler_processor still + # emits 48 chirp slots — `dbin // 16 → {SHORT, MED, LONG}` no longer + # attributes correctly. Force AMBIGUOUS for every target so the dashboard + # column flags it red. Default 0b111 keeps the production happy path on + # the CONFIRMED branch via the normal CRT logic. + sf_mask = getattr(frame, "subframe_enable", 0b111) & 0x07 + sf_mask_invalid = (sf_mask != 0b111) + chirps_per_sf = waveform.chirps_per_subframe # 16 v_res_per_sf_all = [ waveform.velocity_resolution_short_mps, @@ -746,6 +755,8 @@ def extract_targets_from_frame_crt( v_est, confidence, alias_set = unfold_velocity_crt( v_meas_list, v_unamb_list, v_res_list, max_alias_k=max_alias_k, ) + if sf_mask_invalid: + confidence = "AMBIGUOUS" range_m = float(rbin) * range_resolution snr = 10.0 * math.log10(max(peak_mag, 1.0)) if peak_mag > 0 else 0.0 diff --git a/9_Firmware/9_3_GUI/v7/software_fpga.py b/9_Firmware/9_3_GUI/v7/software_fpga.py index 0f19b7b..a925c14 100644 --- a/9_Firmware/9_3_GUI/v7/software_fpga.py +++ b/9_Firmware/9_3_GUI/v7/software_fpga.py @@ -109,6 +109,12 @@ class SoftwareFPGA: self.agc_decay: int = 1 # 0x2B self.agc_holdoff: int = 4 # 0x2C + # PR-U / M-8: 3-bit sub-frame mask {LONG, MEDIUM, SHORT}. Default 0b111 + # = production 3-PRI ladder. Tracked only — replay frames are already + # rendered, so the mask doesn't affect playback math here. Surfaces in + # the parsed RadarFrame from radar_protocol so the CRT extractor sees it. + self.subframe_enable: int = 0b111 # 0x19 + # ------------------------------------------------------------------ # Register setters (same interface as UART commands to real FPGA) # ------------------------------------------------------------------ @@ -133,6 +139,9 @@ class SoftwareFPGA: def set_cfar_alpha_soft(self, val: int) -> None: self.cfar_alpha_soft = int(val) & 0xFF + def set_subframe_enable(self, val: int) -> None: + self.subframe_enable = int(val) & 0x07 + def set_cfar_mode(self, val: int) -> None: self.cfar_mode = int(val) & 0x03