Refactor radar system

This commit is contained in:
Ayzen
2026-10-05 16:46:28 +03:00
parent 515049d2ed
commit 8f097dfa2e
179 changed files with 8212 additions and 6676 deletions
+163 -3
View File
@@ -39,17 +39,24 @@ def _sweep(collection_id: int, monotonic_ns: int) -> SweepCollection:
frequency_hz=np.linspace(1e9, 6e9, points, dtype=np.float32),
s11=np.zeros(points, dtype=np.complex64),
s21=np.full(points, collection_id, dtype=np.complex64),
capture_start_ns=monotonic_ns,
capture_end_ns=monotonic_ns + 1000,
)
return SweepCollection(collection_id=collection_id, monotonic_ns=monotonic_ns, traces=[trace])
return SweepCollection(collection_id=collection_id, monotonic_ns=monotonic_ns, traces=[trace],
capture_start_ns=monotonic_ns, capture_end_ns=monotonic_ns + 1000)
@unittest.skipUnless(_PROCESSOR.exists(), "data_processor is not built (run make)")
class FedBackSweepsTest(unittest.TestCase):
class _ProcessorTestCase(unittest.TestCase):
result_slot_size_bytes: int | None = None
def setUp(self) -> None:
workdir = Path(tempfile.mkdtemp(prefix="processor_history_"))
config = json.loads((_ROOT / "run_config.json").read_text(encoding="utf-8"))
for name, ring in config["rings"].items():
ring["name"] = f"/test_processor_history_{os.getpid()}_{name}"
if self.result_slot_size_bytes is not None:
config["rings"]["results"]["slot_size_bytes"] = self.result_slot_size_bytes
config["run"]["combos"] = [{"input": 0, "output": 0}]
config["run"]["processing_live_config_path"] = str(workdir / "processing_live.json")
config["run"]["locator_server"]["enabled"] = False
@@ -61,11 +68,14 @@ class FedBackSweepsTest(unittest.TestCase):
rings = config["rings"]
shm_files = [Path("/dev/shm" + ring["name"]) for ring in rings.values()]
self._stderr_path = workdir / "processor.log"
stderr_log = self._stderr_path.open("w")
self.addCleanup(stderr_log.close)
processor = subprocess.Popen(
[str(_PROCESSOR), "--config", str(config_path)],
cwd=_ROOT,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stderr=stderr_log,
)
def cleanup() -> None:
@@ -78,6 +88,7 @@ class FedBackSweepsTest(unittest.TestCase):
workdir.rmdir()
self.addCleanup(cleanup)
self._processor = processor
# The processor creates both rings at start-up, the results ring last.
self._results = ShmRingReader(rings["results"]["name"], open_timeout_s=_TIMEOUT_S)
self.addCleanup(self._results.close)
@@ -103,6 +114,22 @@ class FedBackSweepsTest(unittest.TestCase):
deadline = min(deadline, time.monotonic() + 0.3)
return ids
def _result_for_revision(self, revision: int, collection_id: int | None = None):
deadline = time.monotonic() + _TIMEOUT_S
while time.monotonic() < deadline:
result = self._results.pop_result_collection()
if result is not None:
payload = next((item for item in result.collection_payloads
if item.processing_name == "processing_revision"), None)
if payload is not None:
actual = sum(int(word) << (index * 16) for index, word in enumerate(payload.table[0]))
if actual == revision and (collection_id is None or result.collection_id == collection_id):
return result
time.sleep(0.01)
self.fail(f"No processor result for settings revision {revision}: {self._stderr_path.read_text()}")
class FedBackSweepsTest(_ProcessorTestCase):
def test_fed_back_sweeps_are_processed_but_not_retained(self) -> None:
start_ns = time.monotonic_ns()
sweeps = [_sweep(index, start_ns + index * 1_000_000) for index in range(1, _SWEEPS + 1)]
@@ -119,6 +146,139 @@ class FedBackSweepsTest(unittest.TestCase):
self._live_writer.write(self._live)
self.assertEqual(self._published_ids(_SWEEPS), expected)
def test_live_geometry_recomputes_retained_gpr_without_restarting(self) -> None:
self._live.processor_mode = "gpr"
self._live.gpr_input_positions = [0]
self._live.gpr_output_positions = [0]
self._live.gpr_background_subtract_enabled = False
self._live.tx_geometry = [{"output_pos": 0, "x_m": 0.0, "y_m": 0.0, "z_m": 0.0}]
self._live.rx_geometry = [{"input_pos": 0, "x_m": 0.0, "y_m": 0.0, "z_m": 0.0}]
self._live.settings_revision = (1 << 63) + 987654321
self._live_writer.write(self._live)
self._push([_sweep(1, time.monotonic_ns())])
original = self._result_for_revision(self._live.settings_revision)
original_image = next(payload for payload in original.collection_payloads if payload.processing_name == "gpr_accumulator")
# A malformed live file retains the last accepted geometry and revision.
accepted_revision = self._live.settings_revision
self._live.settings_revision += 1
self._live.tx_geometry.append(dict(self._live.tx_geometry[0]))
self._live_writer.write(self._live)
self._push([_sweep(2, time.monotonic_ns())])
retained = self._result_for_revision(accepted_revision, collection_id=2)
retained_image = next(payload for payload in retained.collection_payloads if payload.processing_name == "gpr_accumulator")
np.testing.assert_array_equal(retained_image.image_x_axis, original_image.image_x_axis)
self._live.tx_geometry = self._live.tx_geometry[:1]
self._live.tx_geometry[0]["x_m"] = 2.0
self._live.rx_geometry[0]["x_m"] = 2.0
self._live.settings_revision += 1
self._live_writer.write(self._live)
updated = self._result_for_revision(self._live.settings_revision)
updated_image = next(payload for payload in updated.collection_payloads if payload.processing_name == "gpr_accumulator")
self.assertEqual(updated.collection_id, retained.collection_id)
self.assertEqual(updated.monotonic_ns, retained.monotonic_ns)
np.testing.assert_allclose(updated_image.image_x_axis, original_image.image_x_axis + 2.0, atol=1e-6)
self.assertIsNone(self._processor.poll())
class OversizedResultsTest(_ProcessorTestCase):
result_slot_size_bytes = 4096
def test_valid_mode_recovers_after_an_oversized_result(self) -> None:
self._live.processor_mode = "gpr"
self._live.gpr_input_positions = [0]
self._live.gpr_output_positions = [0]
self._live.gpr_background_subtract_enabled = False
self._live.settings_revision = 17
self._live_writer.write(self._live)
self._push([_sweep(1, time.monotonic_ns())])
deadline = time.monotonic() + _TIMEOUT_S
while "requires" not in self._stderr_path.read_text():
self.assertIsNone(self._processor.poll(), self._stderr_path.read_text())
self.assertLess(time.monotonic(), deadline, self._stderr_path.read_text())
time.sleep(0.01)
self.assertIn("increase rings.results.slot_size_bytes", self._stderr_path.read_text())
self._live.processor_mode = "pass_through"
self._live.settings_revision = 18
self._live_writer.write(self._live)
self.assertEqual(self._result_for_revision(18).collection_id, 1)
self._push([_sweep(2, time.monotonic_ns())])
self.assertEqual(self._result_for_revision(18, collection_id=2).collection_id, 2)
self.assertIsNone(self._processor.poll())
class Sar3dRecoveryTest(_ProcessorTestCase):
def test_sar3d_failure_recovers_without_restarting(self) -> None:
self._live.processor_mode = "sar3d"
self._live.gpr_input_positions = [0]
self._live.gpr_output_positions = [0]
self._live.gpr_background_subtract_enabled = False
self._live.gpr_min_depth_m = 2.0
self._live.gpr_max_depth_m = 5.0
self._live.gpr_speed_m_s = 0.0
self._live.gpr_start_freq_mhz = 1500.0
self._live.gpr_stop_freq_mhz = 6000.0
self._live.sar3d_voxel_m = 0.4
self._live.sar3d_frame_radius = 0
self._live.tx_geometry = [{"output_pos": 0, "x_m": -0.3, "y_m": 0.0, "z_m": 0.0}]
self._live.rx_geometry = [{"input_pos": 0, "x_m": 0.2, "y_m": 0.4, "z_m": 0.0}]
self._live.settings_revision = 31
self._live_writer.write(self._live)
sweep = _sweep(1, time.monotonic_ns())
# df=25 MHz gives a 6 m unambiguous one-way range. The history tests'
# 64-point sweep aliases before this test's 2..5 m depth gate.
sweep.traces[0].frequency_hz = np.linspace(1e9, 6e9, 201, dtype=np.float32)
sweep.traces[0].s11 = np.zeros(201, dtype=np.complex64)
sweep.traces[0].s21 = np.exp(-4j * np.pi * sweep.traces[0].frequency_hz * 4.0 / 3e8).astype(np.complex64)
self._push([sweep])
def ready_images(result):
payloads = {item.processing_name: item for item in result.collection_payloads}
self.assertEqual(payloads["sar3d_status"].table[0, 0], 0)
images = [payloads[name].image for name in ("sar3d_front", "sar3d_side", "sar3d_top", "sar3d_oblique")]
for image in images:
self.assertTrue(np.all(np.isfinite(image)))
self.assertGreater(float(image.max()), 0.0)
self.assertIn("sar3d_peak", payloads)
return images
first = self._result_for_revision(31)
status = next(item for item in first.collection_payloads if item.processing_name == "sar3d_status")
if status.table[0, 0] == 3:
self.assertIn("CUDA", bytes(map(int, status.table[0, 1:])).decode("utf-8"))
self.assertFalse(any(item.processing_name in {"sar3d_front", "sar3d_side", "sar3d_top", "sar3d_oblique"}
for item in first.collection_payloads))
self._push([_sweep(2, sweep.monotonic_ns + 1_000_000)])
repeated = self._result_for_revision(31, collection_id=2)
repeated_status = next(item for item in repeated.collection_payloads if item.processing_name == "sar3d_status")
np.testing.assert_array_equal(status.table, repeated_status.table)
self._live.processor_mode = "pass_through"
self._live.settings_revision = 34
self._live_writer.write(self._live)
recovered = self._result_for_revision(34, collection_id=2)
self.assertEqual(len(recovered.blocks), 1)
self.assertIsNone(self._processor.poll())
return
original = ready_images(first)
self._live.gpr_start_freq_mhz = 8000.0
self._live.gpr_stop_freq_mhz = 9000.0
self._live.settings_revision = 32
self._live_writer.write(self._live)
failed = self._result_for_revision(32)
status = next(item for item in failed.collection_payloads if item.processing_name == "sar3d_status")
self.assertEqual(status.table[0, 0], 2)
self.assertIn("frequency points", bytes(map(int, status.table[0, 1:])).decode("utf-8"))
self.assertIsNone(self._processor.poll())
self._live.gpr_start_freq_mhz = 1500.0
self._live.gpr_stop_freq_mhz = 6000.0
self._live.settings_revision = 33
self._live_writer.write(self._live)
recovered = self._result_for_revision(33)
self.assertEqual(recovered.collection_id, sweep.collection_id)
for before, after in zip(original, ready_images(recovered)):
np.testing.assert_array_equal(before, after)
self.assertIsNone(self._processor.poll())
if __name__ == "__main__":
unittest.main()