Files
radar_system/python_app/tests/test_run_config_codec.py
T
2026-10-06 19:36:00 +03:00

449 lines
21 KiB
Python

"""Run-config decode/validation tests.
Pins the agreed semantics (not just current behaviour):
* every config integer must be a genuine JSON int — "5", 5.0, true are errors;
an explicit JSON null means "use the default";
* a sweep is a real range — stop_hz must be strictly greater than start_hz, and
points a positive integer;
* combos must index real switch positions and contain no duplicate input:output;
* ring/gpr structural bounds are enforced in Python (so a bad config fails here,
not in the C++ pipeline at boot).
"""
from __future__ import annotations
import unittest
from python_app.models.run_config_schema import (
ComboModel,
GprModel,
GprRxGeometryModel,
GprTxGeometryModel,
RadarSweepModel,
RingEndpointModel,
RunConfigModel,
)
from python_app.models.run_config_validation import (
parse_combos_from_text,
validate_acquisition_config,
validate_combos,
validate_gpr_model,
validate_ring_endpoint,
validate_sweep_model,
)
def _valid_payload() -> dict:
"""A canonical, valid run-config payload (the schema defaults serialized)."""
return RunConfigModel().to_dict()
class StrictNumericTypingTest(unittest.TestCase):
"""Every config integer reads strictly; null falls back to the default."""
def test_switch_driver_is_only_a_guard_for_old_h7992_profiles(self) -> None:
payload = _valid_payload()
for port in ("port1", "port2"):
switch = payload["switches"][port]
self.assertNotIn("driver", switch)
switch["driver"] = "h7992"
self.assertNotIn("driver", RunConfigModel.from_dict(payload).to_dict()["switches"][port])
for unsupported in ("hmc349a", "unknown", "", None, 1, {}, []):
switch["driver"] = unsupported
with self.subTest(port=port, driver=unsupported), self.assertRaisesRegex(ValueError, "driver"):
RunConfigModel.from_dict(payload)
switch.pop("driver")
switch["invert_logic"] = False
self.assertNotIn("invert_logic", RunConfigModel.from_dict(payload).to_dict()["switches"][port])
for unsupported in (True, None, 0, "false", {}, []):
switch["invert_logic"] = unsupported
with self.subTest(port=port, invert_logic=unsupported), self.assertRaisesRegex(ValueError, "invert_logic"):
RunConfigModel.from_dict(payload)
switch.pop("invert_logic")
def _reject_int(self, section: list[str], key: str, value: object) -> None:
payload = _valid_payload()
node = payload
for part in section:
node = node[part]
node[key] = value
with self.assertRaises(ValueError):
RunConfigModel.from_dict(payload)
def test_codec_int_rejects_string_float_bool(self) -> None:
# radar.sweep.points is read by the codec's strict _read_int.
for value in ("201", 201.0, True):
with self.subTest(value=value):
self._reject_int(["radar", "sweep"], "points", value)
def test_validation_int_rejects_string_float_bool(self) -> None:
# switch positions are read by run_config_validation._require_int.
for value in ("4", 4.0, True):
with self.subTest(value=value):
self._reject_int(["switches", "port1"], "positions", value)
def test_genuine_int_accepted(self) -> None:
payload = _valid_payload()
payload["radar"]["sweep"]["points"] = 256
self.assertEqual(RunConfigModel.from_dict(payload).radar.sweep.points, 256)
def test_null_uses_default(self) -> None:
payload = _valid_payload()
payload["radar"]["sweep"]["points"] = None
self.assertEqual(
RunConfigModel.from_dict(payload).radar.sweep.points,
RunConfigModel().radar.sweep.points,
)
class StructuralTypingTest(unittest.TestCase):
"""A present-but-wrong-shaped section fails loudly instead of being dropped."""
def test_old_adc_model_is_rejected_without_alias(self) -> None:
payload = _valid_payload()
payload["radar"]["model"] = "kamil_adc"
with self.assertRaisesRegex(ValueError, "rph_radar"):
RunConfigModel.from_dict(payload)
def test_inactive_old_adc_section_from_saved_profiles_is_ignored(self) -> None:
payload = _valid_payload()
payload["radar"]["kamil_adc"] = {"tty_path": "/tmp/old"}
self.assertEqual(RunConfigModel.from_dict(payload).radar.model, payload["radar"]["model"])
def test_root_must_be_an_object(self) -> None:
for payload in (None, [], 'bad', 42):
with self.subTest(payload=payload), self.assertRaisesRegex(ValueError, 'root'):
RunConfigModel.from_dict(payload)
def test_combos_must_be_an_array(self) -> None:
payload = _valid_payload()
payload["run"]["combos"] = {"input": 0, "output": 0}
with self.assertRaisesRegex(ValueError, "run.combos must be a JSON array"):
RunConfigModel.from_dict(payload)
class AcquisitionValidationTest(unittest.TestCase):
def test_rph_key_normalizes_active_laser_mode(self) -> None:
keys = []
for mode in ("variation", " Variation ", "VARIATION"):
config = self.config("rph_radar")
laser = config.radar.laser_control
laser.enabled, laser.mode = True, mode
original = config.radar_key()
keys.append(original)
laser.variation.max_value += 1
with self.subTest(mode=mode):
self.assertNotEqual(config.radar_key(), original)
self.assertEqual(len(set(keys)), 1)
def test_default_combo_expansion_is_bounded_before_allocation(self) -> None:
payload = _valid_payload()
payload["switches"]["port1"]["positions"] = 65536
payload["run"]["combos"] = []
with self.assertRaisesRegex(ValueError, "Too many combos"):
RunConfigModel.from_dict(payload)
config = self.config("librevna_multi")
config.radar.multi_device.input_switch_positions = 1 << 32
with self.assertRaisesRegex(ValueError, "Too many combos"):
config.apply_device_model_constraints()
def config(self, model: str = "librevna") -> RunConfigModel:
config = RunConfigModel()
config.radar.model = model
config.radar.sweep.points = 32
for port, switch in enumerate((config.output_switch, config.input_switch), 1):
switch.radar_port = port
switch.gpio_chip = "/dev/gpiochip0"
switch.pin_a, switch.pin_b = 17, 27
for name in ("raw", "preprocessed", "raw_tap", "preprocessed_tap"):
getattr(config.rings, name).slot_size_bytes = 1 << 20
config.ensure_combos()
return config
def test_undersized_trace_rings_fail_before_start(self) -> None:
config = self.config("librevna_multi")
for name in ("raw", "raw_tap", "preprocessed", "preprocessed_tap"):
ring = getattr(config.rings, name)
ring.name = "/test"
original = ring.slot_size_bytes
minimum_frame = 44 + len(config.combos) * (28 + 12 * config.radar.sweep.points)
ring.slot_size_bytes = (minimum_frame - 1) // 8 * 8
with self.subTest(ring=name), self.assertRaisesRegex(ValueError, f"rings.{name}"):
validate_acquisition_config(config)
ring.slot_size_bytes = original
validate_acquisition_config(config)
def test_radar_key_separates_switch_matrices(self) -> None:
stock, per_point = self.config("librevna_multi"), self.config("librevna_multi")
per_point.radar.multi_device.stimulus_switch_positions = 2
self.assertTrue(stock.radar_key().endswith("_out1_in1_stim1"))
self.assertNotEqual(stock.radar_key(), per_point.radar_key())
def test_rph_requires_native_mode(self) -> None:
with self.assertRaisesRegex(ValueError, "native"):
validate_acquisition_config(self.config("rph_radar"))
def test_rfg_source_needs_a_known_laser_sweep_one_pair_and_a_bind_address(self) -> None:
config = self.config("rph_radar")
config.radar.driver_mode = "native"
config.radar.rph_radar.source = "rfg_udp"
config.radar.laser_control.mode = "variation"
config.input_switch.positions = config.output_switch.positions = 1
config.combos = config.combos[:1]
validate_acquisition_config(config)
restored = RunConfigModel.from_dict(config.to_dict())
self.assertEqual((restored.radar.rph_radar.source, restored.radar.rph_radar.udp_bind),
("rfg_udp", "0.0.0.0:5000"))
e502 = RunConfigModel.from_dict(config.to_dict())
e502.radar.rph_radar.source = "e502"
self.assertNotEqual(config.radar_key(), e502.radar_key())
for field, value, message in (("source", "pcie", "source"), ("udp_bind", "5000", "udp_bind"),
("udp_bind", "192.168.2.1:70000", "udp_bind"),
("udp_bind", "192.168.2.l:5000", "udp_bind")):
broken = RunConfigModel.from_dict(config.to_dict())
setattr(broken.radar.rph_radar, field, value)
with self.subTest(field=field, value=value), self.assertRaisesRegex(ValueError, message):
validate_acquisition_config(broken)
for change, message in (
(lambda c: setattr(c.radar.laser_control, "mode", "manual"), "variation"),
(lambda c: setattr(c.input_switch, "positions", 2), "one antenna pair"),
(lambda c: setattr(c.radar.laser_control.variation, "time_step", 0), "time_step"),
(lambda c: setattr(c.radar.laser_control.variation, "max_value", 97.32), "two steps"),
):
broken = RunConfigModel.from_dict(config.to_dict())
change(broken)
with self.subTest(message=message), self.assertRaisesRegex(ValueError, message):
validate_acquisition_config(broken)
def test_adc_phase_band_and_timeouts_fail_preflight(self) -> None:
for section, field, value in (
("phase_calibration", "phase1_rad", 0.0),
("phase_calibration", "freq1_hz", 2_046_000_000.0),
("phase_calibration", "phase0_rad", float("nan")),
("band", "stop_hz", 2_100_000_000.0),
("band", "start_hz", -1.0), ("band", "points", 1),
("", "sweep_timeout_s", 0.0), ("", "startup_timeout_s", float("inf")),
):
config = self.config("rph_radar")
config.radar.driver_mode = "native"
adc = config.radar.rph_radar
setattr(getattr(adc, section) if section else adc, field, value)
with self.subTest(section=section, field=field), self.assertRaises(ValueError):
validate_acquisition_config(config)
def test_matrix_validates_physical_switch_instead_of_virtual_axis(self) -> None:
config = self.config("librevna_multi")
config.radar.driver_mode = "native"
config.radar.multi_device.output_switch_positions = 4
config.radar.multi_device.input_switch_positions = 4
config.ensure_combos()
self.assertEqual(config.output_switch.positions, 8)
self.assertEqual(config.input_switch.positions, 16)
validate_acquisition_config(config)
config.radar.multi_device.output_switch_positions = 5
config.ensure_combos()
with self.assertRaisesRegex(ValueError, "supports 1..4"):
validate_acquisition_config(config)
config.radar.driver_mode = "mock"
validate_acquisition_config(config)
def test_bad_switch_settings_are_reported_before_hardware_open(self) -> None:
for field, value in (
("driver_mode", "typo"), ("default_position", 1),
("radar_port", 0), ("pin_a", -1), ("pin_b", 17), ("gpio_chip", ""),
):
config = self.config()
config.output_switch.driver_mode = "native"
setattr(config.output_switch, field, value)
with self.subTest(field=field), self.assertRaises(ValueError):
validate_acquisition_config(config)
class NotchValidationTest(unittest.TestCase):
def test_malformed_bands_and_tapers_match_native_rejection(self) -> None:
for band in (("1", 2), (True, 2), (None, 2), (1, float("nan")), (2, 1), (1, 1e100)):
payload = _valid_payload()
payload["preprocess"]["notch"]["bands_hz"] = [list(band)]
with self.subTest(band=band), self.assertRaises(ValueError):
RunConfigModel.from_dict(payload)
for field, value in (("taper_width_hz", -1), ("taper_type", "typo")):
payload = _valid_payload()
payload["preprocess"]["notch"][field] = value
with self.subTest(field=field), self.assertRaises(ValueError):
RunConfigModel.from_dict(payload)
class SweepValidationTest(unittest.TestCase):
def test_nonfinite_fields_and_wire_overflow_are_rejected(self) -> None:
for field in ('start_hz', 'stop_hz', 'if_bandwidth_hz', 'power_dbm'):
for value in (float('nan'), float('inf')):
sweep = RadarSweepModel()
setattr(sweep, field, value)
with self.subTest(field=field, value=value), self.assertRaisesRegex(ValueError, field):
validate_sweep_model(sweep)
with self.assertRaisesRegex(ValueError, 'points'):
validate_sweep_model(RadarSweepModel(points=2 ** 32))
def test_points_must_be_positive(self) -> None:
for points in (0, -1):
with self.subTest(points=points), self.assertRaisesRegex(ValueError, "points"):
validate_sweep_model(RadarSweepModel(start_hz=1.0, stop_hz=2.0, points=points))
def test_stop_must_be_strictly_greater_than_start(self) -> None:
with self.assertRaisesRegex(ValueError, "stop_hz"): # equal is not a range
validate_sweep_model(RadarSweepModel(start_hz=2.0, stop_hz=2.0, points=1))
with self.assertRaisesRegex(ValueError, "stop_hz"): # inverted
validate_sweep_model(RadarSweepModel(start_hz=3.0, stop_hz=2.0, points=1))
def test_valid_sweep_passes(self) -> None:
validate_sweep_model(RadarSweepModel(start_hz=1.0, stop_hz=2.0, points=201))
class ComboValidationTest(unittest.TestCase):
def test_direct_model_positions_cannot_be_silently_truncated(self) -> None:
for value in (0.5, True, '0'):
with self.subTest(value=value), self.assertRaisesRegex(ValueError, 'integer'):
combo = ComboModel(0, 0)
setattr(combo, 'input', value)
validate_combos([combo], input_positions=2, output_positions=2)
def test_within_bounds_passes(self) -> None:
validate_combos(
[ComboModel(input=0, output=0), ComboModel(input=3, output=1)],
input_positions=4,
output_positions=2,
)
def test_input_out_of_range_rejected(self) -> None:
for inp in (-1, 4):
with self.subTest(input=inp), self.assertRaisesRegex(ValueError, "input"):
validate_combos([ComboModel(input=inp, output=0)], input_positions=4, output_positions=2)
def test_output_out_of_range_rejected(self) -> None:
for out in (-1, 2):
with self.subTest(output=out), self.assertRaisesRegex(ValueError, "output"):
validate_combos([ComboModel(input=0, output=out)], input_positions=4, output_positions=2)
def test_duplicate_combo_rejected(self) -> None:
with self.assertRaisesRegex(ValueError, "duplicate"):
validate_combos(
[ComboModel(input=0, output=0), ComboModel(input=0, output=0)],
input_positions=4,
output_positions=2,
)
def test_out_of_range_combo_rejected_on_config_load(self) -> None:
payload = _valid_payload()
out_of_range = payload["run"]["combos"][0]["output"] + payload["switches"]["port2"]["positions"]
payload["run"]["combos"].append({"input": 0, "output": out_of_range})
with self.assertRaisesRegex(ValueError, "out of range"):
RunConfigModel.from_dict(payload)
class RingValidationTest(unittest.TestCase):
def test_slot_stride_must_keep_atomic_fields_aligned(self) -> None:
with self.assertRaisesRegex(ValueError, 'multiple of 8'):
validate_ring_endpoint(RingEndpointModel(name='r', slot_size_bytes=9))
def test_capacity_and_slot_must_be_positive(self) -> None:
with self.assertRaisesRegex(ValueError, "capacity"):
validate_ring_endpoint(RingEndpointModel(name="r", capacity=0, slot_size_bytes=16))
with self.assertRaisesRegex(ValueError, "slot_size"):
validate_ring_endpoint(RingEndpointModel(name="r", capacity=4, slot_size_bytes=0))
def test_slot_size_uint32_limit(self) -> None:
with self.assertRaisesRegex(ValueError, "uint32"):
validate_ring_endpoint(RingEndpointModel(name="r", capacity=1, slot_size_bytes=1 << 32))
def test_segment_size_limit(self) -> None:
with self.assertRaisesRegex(ValueError, "maximum ring segment"):
validate_ring_endpoint(RingEndpointModel(name="r", capacity=1 << 40, slot_size_bytes=1024))
def test_valid_ring_passes(self) -> None:
validate_ring_endpoint(RingEndpointModel(name="r", capacity=8, slot_size_bytes=4096))
class GprValidationTest(unittest.TestCase):
@staticmethod
def _gpr(**overrides: object) -> GprModel:
model = GprModel(relative_permittivity=4.0)
for key, value in overrides.items():
setattr(model, key, value)
return model
def _validate(self, gpr: GprModel) -> None:
validate_gpr_model(gpr, input_switch_positions=4, output_switch_positions=2)
def test_permittivity_must_be_positive(self) -> None:
with self.assertRaisesRegex(ValueError, "relative_permittivity"):
self._validate(self._gpr(relative_permittivity=0.0))
def test_tx_output_pos_out_of_range(self) -> None:
with self.assertRaisesRegex(ValueError, "output_pos is out of range"):
self._validate(self._gpr(tx_geometry=[GprTxGeometryModel(output_pos=5, x_m=0.0, y_m=0.0, z_m=0.0)]))
def test_tx_duplicate_output_pos(self) -> None:
with self.assertRaisesRegex(ValueError, "duplicate output_pos"):
self._validate(self._gpr(tx_geometry=[
GprTxGeometryModel(output_pos=0, x_m=0.0, y_m=0.0, z_m=0.0),
GprTxGeometryModel(output_pos=0, x_m=1.0, y_m=0.0, z_m=0.0),
]))
def test_rx_input_pos_out_of_range(self) -> None:
with self.assertRaisesRegex(ValueError, "input_pos is out of range"):
self._validate(self._gpr(rx_geometry=[GprRxGeometryModel(input_pos=9, x_m=0.0, y_m=0.0, z_m=0.0)]))
def test_valid_geometry_passes(self) -> None:
self._validate(self._gpr(
tx_geometry=[GprTxGeometryModel(output_pos=0, x_m=0.0, y_m=0.0, z_m=0.0)],
rx_geometry=[GprRxGeometryModel(input_pos=0, x_m=0.0, y_m=0.0, z_m=0.0)],
))
def test_direct_geometry_values_cannot_be_silently_coerced(self) -> None:
for field, values, message in (("output_pos", (0.5, True, "0"), "integer"),
("x_m", (True, "0", None, float("nan"), float("inf"), 3.5e38, 10**400), "number")):
for value in values:
with self.subTest(field=field, value=value), self.assertRaisesRegex(ValueError, message):
entry = GprTxGeometryModel()
setattr(entry, field, value)
self._validate(self._gpr(tx_geometry=[entry]))
class ParseCombosFromTextTest(unittest.TestCase):
def test_parses_pairs(self) -> None:
combos = parse_combos_from_text("0:0,1:0,3:1")
self.assertEqual([(c.input, c.output) for c in combos], [(0, 0), (1, 0), (3, 1)])
def test_blank_text_is_empty_list(self) -> None:
self.assertEqual(parse_combos_from_text(" "), [])
def test_missing_colon_rejected(self) -> None:
with self.assertRaisesRegex(ValueError, "Expected input:output"):
parse_combos_from_text("00")
def test_empty_side_rejected(self) -> None:
with self.assertRaises(ValueError):
parse_combos_from_text("0:")
with self.assertRaises(ValueError):
parse_combos_from_text(":0")
def test_non_integer_rejected(self) -> None:
with self.assertRaisesRegex(ValueError, "not an integer"):
parse_combos_from_text("x:0")
class RoundTripTest(unittest.TestCase):
def test_default_config_round_trips_idempotently(self) -> None:
once = RunConfigModel().to_dict()
twice = RunConfigModel.from_dict(once).to_dict()
self.assertEqual(once, twice)
def test_set_values_are_preserved(self) -> None:
payload = _valid_payload()
payload["radar"]["sweep"].update(points=401, start_hz=1.0e9, stop_hz=5.0e9)
model = RunConfigModel.from_dict(payload)
self.assertEqual(model.radar.sweep.points, 401)
self.assertEqual(model.radar.sweep.start_hz, 1.0e9)
self.assertEqual(model.radar.sweep.stop_hz, 5.0e9)
if __name__ == "__main__":
unittest.main()