Files
overwatch/9_Firmware/tests/cross_layer/test_cross_layer_contract.py
Jason ffba27a10a feat: hybrid AGC (FPGA phases 1-3 + GUI phase 6) with timing fix
FPGA:
- rx_gain_control.v rewritten: per-frame peak/saturation tracking,
  auto-shift AGC with attack/decay/holdoff, signed gain -7 to +7
- New registers 0x28-0x2C (agc_enable/target/attack/decay/holdoff)
- status_words[4] carries AGC metrics (gain, peak, sat_count, enable)
- DIG_5 GPIO outputs saturation flag for STM32 outer loop
- Both USB interfaces (FT601 + FT2232H) updated with AGC status ports

Timing fix (WNS +0.001ns -> +0.045ns, 45x improvement):
- CIC max_fanout 4->16 on valid pipeline registers
- +200ps setup uncertainty on 400MHz domain
- ExtraNetDelay_high placement + AggressiveExplore routing

GUI:
- AGC opcodes + status parsing in radar_protocol.py
- AGC control groups in both tkinter and V7 PyQt dashboards
- 11 new AGC tests (103/103 GUI tests pass)

Cross-layer:
- AGC opcodes/defaults/status assertions added (29/29 pass)
- contract_parser.py: fixed comment stripping in concat parser

All tests green: 25 FPGA + 103 GUI + 29 cross-layer = 157 pass
2026-04-13 19:24:11 +05:45

829 lines
33 KiB
Python

"""
Cross-Layer Contract Tests
==========================
Single pytest file orchestrating three tiers of verification:
Tier 1 — Static Contract Parsing:
Compares Python, Verilog, and C source code at parse-time to catch
opcode mismatches, bit-width errors, packet constant drift, and
layout bugs like the status_words[0] 37-bit truncation.
Tier 2 — Verilog Cosimulation (iverilog):
Compiles and runs tb_cross_layer_ft2232h.v, then parses its output
files (cmd_results.txt, data_packet.txt, status_packet.txt) and
runs Python parsers on the captured bytes to verify round-trip
correctness.
Tier 3 — C Stub Execution:
Compiles stm32_settings_stub.cpp, generates a binary settings
packet from Python, runs the stub, and verifies all parsed field
values match.
The goal is to find UNKNOWN bugs by testing each layer against
independently-derived ground truth — not just checking that two
layers agree (because both could be wrong).
"""
from __future__ import annotations
import os
import struct
import subprocess
import tempfile
from pathlib import Path
import pytest
# Import the contract parsers
import sys
THIS_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(THIS_DIR))
import contract_parser as cp # noqa: E402
# Also add the GUI dir to import radar_protocol
sys.path.insert(0, str(cp.GUI_DIR))
# ===================================================================
# Helpers
# ===================================================================
IVERILOG = os.environ.get("IVERILOG", "/opt/homebrew/bin/iverilog")
VVP = os.environ.get("VVP", "/opt/homebrew/bin/vvp")
CXX = os.environ.get("CXX", "c++")
# Check tool availability for conditional skipping
_has_iverilog = Path(IVERILOG).exists() if "/" in IVERILOG else bool(
subprocess.run(["which", IVERILOG], capture_output=True).returncode == 0
)
_has_cxx = subprocess.run(
[CXX, "--version"], capture_output=True
).returncode == 0
def _parse_hex_results(text: str) -> list[dict[str, str]]:
"""Parse space-separated hex lines from TB output files."""
rows = []
for line in text.strip().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
rows.append(line.split())
return rows
# ===================================================================
# Ground Truth: FPGA register map (independently transcribed)
# ===================================================================
# This is the SINGLE SOURCE OF TRUTH, manually transcribed from
# radar_system_top.v lines 902-945. If any layer disagrees with
# this, it's a bug in that layer.
GROUND_TRUTH_OPCODES = {
0x01: ("host_radar_mode", 2),
0x02: ("host_trigger_pulse", 1), # pulse
0x03: ("host_detect_threshold", 16),
0x04: ("host_stream_control", 3),
0x10: ("host_long_chirp_cycles", 16),
0x11: ("host_long_listen_cycles", 16),
0x12: ("host_guard_cycles", 16),
0x13: ("host_short_chirp_cycles", 16),
0x14: ("host_short_listen_cycles", 16),
0x15: ("host_chirps_per_elev", 6),
0x16: ("host_gain_shift", 4),
0x20: ("host_range_mode", 2),
0x21: ("host_cfar_guard", 4),
0x22: ("host_cfar_train", 5),
0x23: ("host_cfar_alpha", 8),
0x24: ("host_cfar_mode", 2),
0x25: ("host_cfar_enable", 1),
0x26: ("host_mti_enable", 1),
0x27: ("host_dc_notch_width", 3),
0x28: ("host_agc_enable", 1),
0x29: ("host_agc_target", 8),
0x2A: ("host_agc_attack", 4),
0x2B: ("host_agc_decay", 4),
0x2C: ("host_agc_holdoff", 4),
0x30: ("host_self_test_trigger", 1), # pulse
0x31: ("host_status_request", 1), # pulse
0xFF: ("host_status_request", 1), # alias, pulse
}
GROUND_TRUTH_RESET_DEFAULTS = {
"host_radar_mode": 1, # 2'b01
"host_detect_threshold": 10000,
"host_stream_control": 7, # 3'b111
"host_long_chirp_cycles": 3000,
"host_long_listen_cycles": 13700,
"host_guard_cycles": 17540,
"host_short_chirp_cycles": 50,
"host_short_listen_cycles": 17450,
"host_chirps_per_elev": 32,
"host_gain_shift": 0,
"host_range_mode": 0,
"host_cfar_guard": 2,
"host_cfar_train": 8,
"host_cfar_alpha": 0x30,
"host_cfar_mode": 0,
"host_cfar_enable": 0,
"host_mti_enable": 0,
"host_dc_notch_width": 0,
"host_agc_enable": 0,
"host_agc_target": 200,
"host_agc_attack": 1,
"host_agc_decay": 1,
"host_agc_holdoff": 4,
}
GROUND_TRUTH_PACKET_CONSTANTS = {
"data": {"header": 0xAA, "footer": 0x55, "size": 11},
"status": {"header": 0xBB, "footer": 0x55, "size": 26},
}
# ===================================================================
# TIER 1: Static Contract Parsing
# ===================================================================
class TestTier1OpcodeContract:
"""Verify Python and Verilog opcode sets match ground truth."""
def test_python_opcodes_match_ground_truth(self):
"""Every Python Opcode must exist in ground truth with correct value."""
py_opcodes = cp.parse_python_opcodes()
for val, entry in py_opcodes.items():
assert val in GROUND_TRUTH_OPCODES, (
f"Python Opcode {entry.name}=0x{val:02X} not in ground truth! "
f"Possible phantom opcode (like the 0x06 incident)."
)
def test_ground_truth_opcodes_in_python(self):
"""Every ground truth opcode must have a Python enum entry."""
py_opcodes = cp.parse_python_opcodes()
for val, (reg, _width) in GROUND_TRUTH_OPCODES.items():
assert val in py_opcodes, (
f"Ground truth opcode 0x{val:02X} ({reg}) missing from Python Opcode enum."
)
def test_verilog_opcodes_match_ground_truth(self):
"""Every Verilog case entry must exist in ground truth."""
v_opcodes = cp.parse_verilog_opcodes()
for val, entry in v_opcodes.items():
assert val in GROUND_TRUTH_OPCODES, (
f"Verilog opcode 0x{val:02X} ({entry.register}) not in ground truth."
)
def test_ground_truth_opcodes_in_verilog(self):
"""Every ground truth opcode must have a Verilog case entry."""
v_opcodes = cp.parse_verilog_opcodes()
for val, (reg, _width) in GROUND_TRUTH_OPCODES.items():
assert val in v_opcodes, (
f"Ground truth opcode 0x{val:02X} ({reg}) missing from Verilog case statement."
)
def test_python_verilog_bidirectional_match(self):
"""Python and Verilog must have the same set of opcode values."""
py_set = set(cp.parse_python_opcodes().keys())
v_set = set(cp.parse_verilog_opcodes().keys())
py_only = py_set - v_set
v_only = v_set - py_set
assert not py_only, f"Opcodes in Python but not Verilog: {[hex(x) for x in py_only]}"
assert not v_only, f"Opcodes in Verilog but not Python: {[hex(x) for x in v_only]}"
def test_verilog_register_names_match(self):
"""Verilog case target registers must match ground truth names."""
v_opcodes = cp.parse_verilog_opcodes()
for val, (expected_reg, _) in GROUND_TRUTH_OPCODES.items():
if val in v_opcodes:
actual_reg = v_opcodes[val].register
assert actual_reg == expected_reg, (
f"Opcode 0x{val:02X}: Verilog writes to '{actual_reg}' "
f"but ground truth says '{expected_reg}'"
)
class TestTier1BitWidths:
"""Verify register widths and opcode bit slices match ground truth."""
def test_verilog_register_widths(self):
"""Register declarations must match ground truth bit widths."""
v_widths = cp.parse_verilog_register_widths()
for reg, expected_width in [
(name, w) for _, (name, w) in GROUND_TRUTH_OPCODES.items()
]:
if reg in v_widths:
actual = v_widths[reg]
assert actual >= expected_width, (
f"{reg}: declared {actual}-bit but ground truth says {expected_width}-bit"
)
def test_verilog_opcode_bit_slices(self):
"""Opcode case assignments must use correct bit widths from cmd_value."""
v_opcodes = cp.parse_verilog_opcodes()
for val, (reg, expected_width) in GROUND_TRUTH_OPCODES.items():
if val not in v_opcodes:
continue
entry = v_opcodes[val]
if entry.is_pulse:
continue # Pulse opcodes don't use cmd_value slicing
if entry.bit_width > 0:
assert entry.bit_width >= expected_width, (
f"Opcode 0x{val:02X} ({reg}): bit slice {entry.bit_slice} "
f"= {entry.bit_width}-bit, expected >= {expected_width}"
)
class TestTier1StatusWordTruncation:
"""Verify each status_words[] concatenation is exactly 32 bits."""
def test_status_words_concat_widths_ft2232h(self):
"""Each status_words[] concat must be EXACTLY 32 bits."""
port_widths = cp.get_usb_interface_port_widths(
cp.FPGA_DIR / "usb_data_interface_ft2232h.v"
)
concats = cp.parse_verilog_status_word_concats(
cp.FPGA_DIR / "usb_data_interface_ft2232h.v"
)
for idx, expr in concats.items():
result = cp.count_concat_bits(expr, port_widths)
if result.total_bits < 0:
pytest.skip(f"status_words[{idx}]: unknown signal width")
assert result.total_bits == 32, (
f"status_words[{idx}] is {result.total_bits} bits, not 32! "
f"{'TRUNCATION' if result.total_bits > 32 else 'UNDERFLOW'} BUG. "
f"Fragments: {result.fragments}"
)
def test_status_words_concat_widths_ft601(self):
"""Same check for the FT601 interface (same bug expected)."""
ft601_path = cp.FPGA_DIR / "usb_data_interface.v"
if not ft601_path.exists():
pytest.skip("FT601 interface file not found")
port_widths = cp.get_usb_interface_port_widths(ft601_path)
concats = cp.parse_verilog_status_word_concats(ft601_path)
for idx, expr in concats.items():
result = cp.count_concat_bits(expr, port_widths)
if result.total_bits < 0:
pytest.skip(f"status_words[{idx}]: unknown signal width")
assert result.total_bits == 32, (
f"FT601 status_words[{idx}] is {result.total_bits} bits, not 32! "
f"{'TRUNCATION' if result.total_bits > 32 else 'UNDERFLOW'} BUG. "
f"Fragments: {result.fragments}"
)
class TestTier1StatusFieldPositions:
"""Verify Python status parser bit positions match Verilog layout."""
def test_python_status_mode_position(self):
"""
Verify Python reads radar_mode at the correct bit position matching
the Verilog status_words[0] layout:
{0xFF[31:24], mode[23:22], stream[21:19], 3'b000[18:16], threshold[15:0]}
"""
# Get what Python thinks
py_fields = cp.parse_python_status_fields()
mode_field = next((f for f in py_fields if f.name == "radar_mode"), None)
assert mode_field is not None, "radar_mode not found in parse_status_packet"
# Ground truth: mode is at bits [23:22], so LSB = 22
expected_shift = 22
actual_shift = mode_field.lsb
assert actual_shift == expected_shift, (
f"Python reads radar_mode at bit {actual_shift} "
f"but Verilog status_words[0] has mode at bit {expected_shift}."
)
class TestTier1PacketConstants:
"""Verify packet header/footer/size constants match across layers."""
def test_python_packet_constants(self):
"""Python constants match ground truth."""
py = cp.parse_python_packet_constants()
for ptype, expected in GROUND_TRUTH_PACKET_CONSTANTS.items():
assert py[ptype].header == expected["header"], (
f"Python {ptype} header: 0x{py[ptype].header:02X} != 0x{expected['header']:02X}"
)
assert py[ptype].footer == expected["footer"], (
f"Python {ptype} footer: 0x{py[ptype].footer:02X} != 0x{expected['footer']:02X}"
)
assert py[ptype].size == expected["size"], (
f"Python {ptype} size: {py[ptype].size} != {expected['size']}"
)
def test_verilog_packet_constants(self):
"""Verilog localparams match ground truth."""
v = cp.parse_verilog_packet_constants()
for ptype, expected in GROUND_TRUTH_PACKET_CONSTANTS.items():
assert v[ptype].header == expected["header"], (
f"Verilog {ptype} header: 0x{v[ptype].header:02X} != 0x{expected['header']:02X}"
)
assert v[ptype].footer == expected["footer"], (
f"Verilog {ptype} footer: 0x{v[ptype].footer:02X} != 0x{expected['footer']:02X}"
)
assert v[ptype].size == expected["size"], (
f"Verilog {ptype} size: {v[ptype].size} != {expected['size']}"
)
def test_python_verilog_constants_agree(self):
"""Python and Verilog packet constants must match each other."""
py = cp.parse_python_packet_constants()
v = cp.parse_verilog_packet_constants()
for ptype in ("data", "status"):
assert py[ptype].header == v[ptype].header
assert py[ptype].footer == v[ptype].footer
assert py[ptype].size == v[ptype].size
class TestTier1ResetDefaults:
"""Verify Verilog reset defaults match ground truth."""
def test_verilog_reset_defaults(self):
"""Reset block values must match ground truth."""
v_defaults = cp.parse_verilog_reset_defaults()
for reg, expected in GROUND_TRUTH_RESET_DEFAULTS.items():
assert reg in v_defaults, f"{reg} not found in reset block"
actual = v_defaults[reg]
assert actual == expected, (
f"{reg}: reset default {actual} != expected {expected}"
)
class TestTier1DataPacketLayout:
"""Verify data packet byte layout matches between Python and Verilog."""
def test_verilog_data_mux_field_positions(self):
"""Verilog data_pkt_byte mux must have correct byte positions."""
v_fields = cp.parse_verilog_data_mux()
# Expected: range_profile at bytes 1-4 (32-bit), doppler_real 5-6,
# doppler_imag 7-8, cfar 9
field_map = {f.name: f for f in v_fields}
assert "range_profile" in field_map
rp = field_map["range_profile"]
assert rp.byte_start == 1 and rp.byte_end == 4 and rp.width_bits == 32
assert "doppler_real" in field_map
dr = field_map["doppler_real"]
assert dr.byte_start == 5 and dr.byte_end == 6 and dr.width_bits == 16
assert "doppler_imag" in field_map
di = field_map["doppler_imag"]
assert di.byte_start == 7 and di.byte_end == 8 and di.width_bits == 16
def test_python_data_packet_byte_positions(self):
"""Python parse_data_packet byte offsets must be correct."""
py_fields = cp.parse_python_data_packet_fields()
# range_q at offset 1 (2B), range_i at offset 3 (2B),
# doppler_i at offset 5 (2B), doppler_q at offset 7 (2B),
# detection at offset 9
field_map = {f.name: f for f in py_fields}
assert "range_q" in field_map
assert field_map["range_q"].byte_start == 1
assert "range_i" in field_map
assert field_map["range_i"].byte_start == 3
assert "doppler_i" in field_map
assert field_map["doppler_i"].byte_start == 5
assert "doppler_q" in field_map
assert field_map["doppler_q"].byte_start == 7
assert "detection" in field_map
assert field_map["detection"].byte_start == 9
class TestTier1STM32SettingsPacket:
"""Verify STM32 settings packet layout."""
def test_field_order_and_sizes(self):
"""STM32 settings fields must have correct offsets and sizes."""
fields = cp.parse_stm32_settings_fields()
if not fields:
pytest.skip("MCU source not available")
expected = [
("system_frequency", 0, 8, "double"),
("chirp_duration_1", 8, 8, "double"),
("chirp_duration_2", 16, 8, "double"),
("chirps_per_position", 24, 4, "uint32_t"),
("freq_min", 28, 8, "double"),
("freq_max", 36, 8, "double"),
("prf1", 44, 8, "double"),
("prf2", 52, 8, "double"),
("max_distance", 60, 8, "double"),
("map_size", 68, 8, "double"),
]
assert len(fields) == len(expected), (
f"Expected {len(expected)} fields, got {len(fields)}"
)
for f, (ename, eoff, esize, etype) in zip(fields, expected, strict=True):
assert f.name == ename, f"Field name: {f.name} != {ename}"
assert f.offset == eoff, f"{f.name}: offset {f.offset} != {eoff}"
assert f.size == esize, f"{f.name}: size {f.size} != {esize}"
assert f.c_type == etype, f"{f.name}: type {f.c_type} != {etype}"
def test_minimum_packet_size(self):
"""
RadarSettings.cpp says minimum is 74 bytes but actual payload is:
'SET'(3) + 9*8(doubles) + 4(uint32) + 'END'(3) = 82 bytes.
This test documents the bug.
"""
fields = cp.parse_stm32_settings_fields()
if not fields:
pytest.skip("MCU source not available")
# Calculate required payload size
total_field_bytes = sum(f.size for f in fields)
# Add markers: "SET"(3) + "END"(3)
required_size = 3 + total_field_bytes + 3
# Read the actual minimum check from the source
src = (cp.MCU_LIB_DIR / "RadarSettings.cpp").read_text(encoding="latin-1")
import re
m = re.search(r'length\s*<\s*(\d+)', src)
assert m, "Could not find minimum length check in parseFromUSB"
declared_min = int(m.group(1))
assert declared_min == required_size, (
f"BUFFER OVERREAD BUG: parseFromUSB minimum check is {declared_min} "
f"but actual required size is {required_size}. "
f"({total_field_bytes} bytes of fields + 6 bytes of markers). "
f"If exactly {declared_min} bytes are passed, extractDouble() reads "
f"past the buffer at offset {declared_min - 3} (needs 8 bytes, "
f"only {declared_min - 3 - fields[-1].offset} available)."
)
def test_stm32_usb_start_flag(self):
"""USB start flag must be [23, 46, 158, 237]."""
flag = cp.parse_stm32_start_flag()
if not flag:
pytest.skip("USBHandler.cpp not available")
assert flag == [23, 46, 158, 237], f"Start flag: {flag}"
# ===================================================================
# TIER 2: Verilog Cosimulation
# ===================================================================
@pytest.mark.skipif(not _has_iverilog, reason="iverilog not available")
class TestTier2VerilogCosim:
"""Compile and run the FT2232H TB, validate output against Python parsers."""
@pytest.fixture(scope="class")
def tb_results(self, tmp_path_factory):
"""Compile and run TB once, return output file contents."""
workdir = tmp_path_factory.mktemp("verilog_cosim")
tb_path = THIS_DIR / "tb_cross_layer_ft2232h.v"
rtl_path = cp.FPGA_DIR / "usb_data_interface_ft2232h.v"
out_bin = workdir / "tb_cross_layer_ft2232h"
# Compile
result = subprocess.run(
[IVERILOG, "-o", str(out_bin), "-I", str(cp.FPGA_DIR),
str(tb_path), str(rtl_path)],
capture_output=True, text=True, timeout=30,
)
assert result.returncode == 0, f"iverilog compile failed:\n{result.stderr}"
# Run
result = subprocess.run(
[VVP, str(out_bin)],
capture_output=True, text=True, timeout=60,
cwd=str(workdir),
)
assert result.returncode == 0, f"vvp failed:\n{result.stderr}"
# Parse output
return {
"stdout": result.stdout,
"cmd_results": (workdir / "cmd_results.txt").read_text(),
"data_packet": (workdir / "data_packet.txt").read_text(),
"status_packet": (workdir / "status_packet.txt").read_text(),
}
def test_all_tb_tests_pass(self, tb_results):
"""All Verilog TB internal checks must pass."""
stdout = tb_results["stdout"]
assert "ALL TESTS PASSED" in stdout, f"TB had failures:\n{stdout}"
def test_command_round_trip(self, tb_results):
"""Verify every command decoded correctly by matching sent vs received."""
rows = _parse_hex_results(tb_results["cmd_results"])
assert len(rows) >= 20, f"Expected >= 20 command results, got {len(rows)}"
for row in rows:
assert len(row) == 6, f"Bad row format: {row}"
sent_op, sent_addr, sent_val = row[0], row[1], row[2]
got_op, got_addr, got_val = row[3], row[4], row[5]
assert sent_op == got_op, (
f"Opcode mismatch: sent 0x{sent_op} got 0x{got_op}"
)
assert sent_addr == got_addr, (
f"Addr mismatch: sent 0x{sent_addr} got 0x{got_addr}"
)
assert sent_val == got_val, (
f"Value mismatch: sent 0x{sent_val} got 0x{got_val}"
)
def test_data_packet_python_round_trip(self, tb_results):
"""
Take the 11 bytes captured by the Verilog TB, run Python's
parse_data_packet() on them, verify the parsed values match
what was injected into the TB.
"""
from radar_protocol import RadarProtocol
rows = _parse_hex_results(tb_results["data_packet"])
assert len(rows) == 11, f"Expected 11 data packet bytes, got {len(rows)}"
# Reconstruct raw bytes
raw = bytes(int(row[1], 16) for row in rows)
assert len(raw) == 11
parsed = RadarProtocol.parse_data_packet(raw)
assert parsed is not None, "parse_data_packet returned None"
# The TB injected: range_profile = 0xCAFE_BEEF = {Q=0xCAFE, I=0xBEEF}
# doppler_real = 0x1234, doppler_imag = 0x5678
# cfar_detection = 1
#
# range_q = 0xCAFE → signed = 0xCAFE - 0x10000 = -13570
# range_i = 0xBEEF → signed = 0xBEEF - 0x10000 = -16657
# doppler_i = 0x1234 → signed = 4660
# doppler_q = 0x5678 → signed = 22136
assert parsed["range_q"] == (0xCAFE - 0x10000), (
f"range_q: {parsed['range_q']} != {0xCAFE - 0x10000}"
)
assert parsed["range_i"] == (0xBEEF - 0x10000), (
f"range_i: {parsed['range_i']} != {0xBEEF - 0x10000}"
)
assert parsed["doppler_i"] == 0x1234, (
f"doppler_i: {parsed['doppler_i']} != {0x1234}"
)
assert parsed["doppler_q"] == 0x5678, (
f"doppler_q: {parsed['doppler_q']} != {0x5678}"
)
assert parsed["detection"] == 1, (
f"detection: {parsed['detection']} != 1"
)
def test_status_packet_python_round_trip(self, tb_results):
"""
Take the 26 bytes captured by the Verilog TB, run Python's
parse_status_packet() on them, verify against injected values.
"""
from radar_protocol import RadarProtocol
lines = tb_results["status_packet"].strip().splitlines()
# Filter out comments and status_words debug lines
rows = []
for line in lines:
line = line.strip()
if not line or line.startswith("#"):
continue
rows.append(line.split())
assert len(rows) == 26, f"Expected 26 status bytes, got {len(rows)}"
raw = bytes(int(row[1], 16) for row in rows)
assert len(raw) == 26
sr = RadarProtocol.parse_status_packet(raw)
assert sr is not None, "parse_status_packet returned None"
# Injected values (from TB):
# status_cfar_threshold = 0xABCD
# status_stream_ctrl = 3'b101 = 5
# status_radar_mode = 2'b11 = 3
# status_long_chirp = 0x1234
# status_long_listen = 0x5678
# status_guard = 0x9ABC
# status_short_chirp = 0xDEF0
# status_short_listen = 0xFACE
# status_chirps_per_elev = 42
# status_range_mode = 2'b10 = 2
# status_self_test_flags = 5'b10101 = 21
# status_self_test_detail = 0xA5
# status_self_test_busy = 1
# status_agc_current_gain = 7
# status_agc_peak_magnitude = 200
# status_agc_saturation_count = 15
# status_agc_enable = 1
# Words 1-5 should be correct (no truncation bug)
assert sr.cfar_threshold == 0xABCD, f"cfar_threshold: 0x{sr.cfar_threshold:04X}"
assert sr.long_chirp == 0x1234, f"long_chirp: 0x{sr.long_chirp:04X}"
assert sr.long_listen == 0x5678, f"long_listen: 0x{sr.long_listen:04X}"
assert sr.guard == 0x9ABC, f"guard: 0x{sr.guard:04X}"
assert sr.short_chirp == 0xDEF0, f"short_chirp: 0x{sr.short_chirp:04X}"
assert sr.short_listen == 0xFACE, f"short_listen: 0x{sr.short_listen:04X}"
assert sr.chirps_per_elev == 42, f"chirps_per_elev: {sr.chirps_per_elev}"
assert sr.range_mode == 2, f"range_mode: {sr.range_mode}"
assert sr.self_test_flags == 21, f"self_test_flags: {sr.self_test_flags}"
assert sr.self_test_detail == 0xA5, f"self_test_detail: 0x{sr.self_test_detail:02X}"
assert sr.self_test_busy == 1, f"self_test_busy: {sr.self_test_busy}"
# AGC fields (word 4)
assert sr.agc_current_gain == 7, f"agc_current_gain: {sr.agc_current_gain}"
assert sr.agc_peak_magnitude == 200, f"agc_peak_magnitude: {sr.agc_peak_magnitude}"
assert sr.agc_saturation_count == 15, f"agc_saturation_count: {sr.agc_saturation_count}"
assert sr.agc_enable == 1, f"agc_enable: {sr.agc_enable}"
# Word 0: stream_ctrl should be 5 (3'b101)
assert sr.stream_ctrl == 5, (
f"stream_ctrl: {sr.stream_ctrl} != 5. "
f"Check status_words[0] bit positions."
)
# radar_mode should be 3 (2'b11)
assert sr.radar_mode == 3, (
f"radar_mode={sr.radar_mode} != 3. "
f"Check status_words[0] bit positions."
)
# ===================================================================
# TIER 3: C Stub Execution
# ===================================================================
@pytest.mark.skipif(not _has_cxx, reason="C++ compiler not available")
class TestTier3CStub:
"""Compile STM32 settings stub and verify field parsing."""
@pytest.fixture(scope="class")
def stub_binary(self, tmp_path_factory):
"""Compile the C++ stub once."""
workdir = tmp_path_factory.mktemp("c_stub")
stub_src = THIS_DIR / "stm32_settings_stub.cpp"
radar_settings_src = cp.MCU_LIB_DIR / "RadarSettings.cpp"
out_bin = workdir / "stm32_settings_stub"
result = subprocess.run(
[CXX, "-std=c++11", "-o", str(out_bin),
str(stub_src), str(radar_settings_src),
f"-I{cp.MCU_LIB_DIR}"],
capture_output=True, text=True, timeout=30,
)
assert result.returncode == 0, f"Compile failed:\n{result.stderr}"
return out_bin
def _build_settings_packet(self, values: dict) -> bytes:
"""Build a binary settings packet matching RadarSettings::parseFromUSB."""
pkt = b"SET"
for key in [
"system_frequency", "chirp_duration_1", "chirp_duration_2",
]:
pkt += struct.pack(">d", values[key])
pkt += struct.pack(">I", values["chirps_per_position"])
for key in [
"freq_min", "freq_max", "prf1", "prf2",
"max_distance", "map_size",
]:
pkt += struct.pack(">d", values[key])
pkt += b"END"
return pkt
def _run_stub(self, binary: Path, packet: bytes) -> dict[str, str]:
"""Run stub with packet file, parse stdout into field dict."""
with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f:
f.write(packet)
pkt_path = f.name
try:
result = subprocess.run(
[str(binary), pkt_path],
capture_output=True, text=True, timeout=10,
)
finally:
os.unlink(pkt_path)
fields = {}
for line in result.stdout.strip().splitlines():
if "=" in line:
k, v = line.split("=", 1)
fields[k.strip()] = v.strip()
return fields
def test_default_values_round_trip(self, stub_binary):
"""Default settings must parse correctly through C stub."""
values = {
"system_frequency": 10.0e9,
"chirp_duration_1": 30.0e-6,
"chirp_duration_2": 0.5e-6,
"chirps_per_position": 32,
"freq_min": 10.0e6,
"freq_max": 30.0e6,
"prf1": 1000.0,
"prf2": 2000.0,
"max_distance": 50000.0,
"map_size": 50000.0,
}
pkt = self._build_settings_packet(values)
result = self._run_stub(stub_binary, pkt)
assert result.get("parse_ok") == "true", f"Parse failed: {result}"
for key, expected in values.items():
actual_str = result.get(key)
assert actual_str is not None, f"Missing field: {key}"
actual = int(actual_str) if key == "chirps_per_position" else float(actual_str)
if isinstance(expected, float):
assert abs(actual - expected) < expected * 1e-10, (
f"{key}: {actual} != {expected}"
)
else:
assert actual == expected, f"{key}: {actual} != {expected}"
def test_distinctive_values_round_trip(self, stub_binary):
"""Non-default distinctive values must parse correctly."""
values = {
"system_frequency": 24.125e9, # K-band
"chirp_duration_1": 100.0e-6,
"chirp_duration_2": 2.0e-6,
"chirps_per_position": 64,
"freq_min": 24.0e6,
"freq_max": 24.25e6,
"prf1": 5000.0,
"prf2": 3000.0,
"max_distance": 75000.0,
"map_size": 100000.0,
}
pkt = self._build_settings_packet(values)
result = self._run_stub(stub_binary, pkt)
assert result.get("parse_ok") == "true", f"Parse failed: {result}"
for key, expected in values.items():
actual_str = result.get(key)
assert actual_str is not None, f"Missing field: {key}"
actual = int(actual_str) if key == "chirps_per_position" else float(actual_str)
if isinstance(expected, float):
assert abs(actual - expected) < expected * 1e-10, (
f"{key}: {actual} != {expected}"
)
else:
assert actual == expected, f"{key}: {actual} != {expected}"
def test_truncated_packet_rejected(self, stub_binary):
"""Packet shorter than minimum must be rejected."""
pkt = b"SET" + b"\x00" * 40 + b"END" # Only 46 bytes, needs 82
result = self._run_stub(stub_binary, pkt)
assert result.get("parse_ok") == "false", (
f"Expected parse failure for truncated packet, got: {result}"
)
def test_bad_markers_rejected(self, stub_binary):
"""Packet with wrong start/end markers must be rejected."""
values = {
"system_frequency": 10.0e9, "chirp_duration_1": 30.0e-6,
"chirp_duration_2": 0.5e-6, "chirps_per_position": 32,
"freq_min": 10.0e6, "freq_max": 30.0e6,
"prf1": 1000.0, "prf2": 2000.0,
"max_distance": 50000.0, "map_size": 50000.0,
}
pkt = self._build_settings_packet(values)
# Wrong start marker
bad_pkt = b"BAD" + pkt[3:]
result = self._run_stub(stub_binary, bad_pkt)
assert result.get("parse_ok") == "false", "Should reject bad start marker"
# Wrong end marker
bad_pkt = pkt[:-3] + b"BAD"
result = self._run_stub(stub_binary, bad_pkt)
assert result.get("parse_ok") == "false", "Should reject bad end marker"
def test_python_c_packet_format_agreement(self, stub_binary):
"""
Python builds a settings packet, C stub parses it.
This tests that both sides agree on the packet format.
"""
# Use values right at validation boundaries to stress-test
values = {
"system_frequency": 1.0e9, # min valid
"chirp_duration_1": 1.0e-6, # min valid
"chirp_duration_2": 0.1e-6, # min valid
"chirps_per_position": 1, # min valid
"freq_min": 1.0e6, # min valid
"freq_max": 2.0e6, # just above freq_min
"prf1": 100.0, # min valid
"prf2": 100.0, # min valid
"max_distance": 100.0, # min valid
"map_size": 1000.0, # min valid
}
pkt = self._build_settings_packet(values)
result = self._run_stub(stub_binary, pkt)
assert result.get("parse_ok") == "true", (
f"Boundary values rejected: {result}"
)