Rework radar system

This commit is contained in:
Ayzen
2026-10-06 12:32:26 +03:00
parent 552cdc688a
commit 5bb86b7165
299 changed files with 13537 additions and 27763 deletions
+73 -95
View File
@@ -3,8 +3,6 @@
from __future__ import annotations
import os
from pathlib import Path
from types import SimpleNamespace
import unittest
from unittest import mock
@@ -12,12 +10,11 @@ import numpy as np
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtCore import QSignalBlocker # noqa: E402
from PyQt6.QtWidgets import QApplication # noqa: E402
from PyQt6.QtWidgets import QApplication, QComboBox, QLineEdit, QPushButton # noqa: E402
from python_app.tests.gui_test_support import isolated_window
from python_app.gui.controllers.app_window_snapshot_mixin import AppWindowSnapshotMixin # noqa: E402
from python_app.models.dataset_model import ComboKey, ResultBlock, ResultCollection, ResultPayload # noqa: E402
from python_app.models.run_config_model import RunConfigModel # noqa: E402
from python_app.tests.gui_test_support import isolated_window, widget
from python_app.models.dataset_model import ComboKey, ResultBlock, ResultCollection, ResultPayload, SweepCollection # noqa: E402
from python_app.models.run_config_schema import RunConfigModel # noqa: E402
class ComboNavigationTest(unittest.TestCase):
@@ -25,106 +22,87 @@ class ComboNavigationTest(unittest.TestCase):
def setUpClass(cls) -> None:
cls.app = QApplication.instance() or QApplication([])
cls.window = isolated_window(cls.addClassCleanup)
cls.window._timer.stop()
cls.window._bscan_reprocess_timer.stop()
@classmethod
def tearDownClass(cls) -> None:
cls.window.close()
cls.window._pipeline.timer.stop()
cls.window._pipeline.replay.timer.stop()
def setUp(self) -> None:
self.w = self.window
config = RunConfigModel()
config.radar.model = "librevna_multi"
config.apply_device_model_constraints()
self.w._defaults_config = config
self.w._set_combo_selection_mode("text")
with QSignalBlocker(self.w._processing_mode):
self.w._processing_mode.setCurrentText("bscan")
self.w._result_history.clear()
self.w._bscan_replay_results = []
self.w._bscan_render_signature = None
self.w._single_combo_input.setText("0")
self.w._single_combo_output.setText("0")
self.root = self.w._profile.state.path.parent
self.mode = self.w._view.processing.mode
self.single_input = self.w._view.switches.single_input
self.single_output = self.w._view.switches.single_output
self.next_button = self.w._view.switches.next_combo
self.w._profile.state.config = RunConfigModel.load_from_path(self.w._profile.state.path)
self.w._radar.set_combo_mode("text")
with QSignalBlocker(self.mode):
self.mode.setCurrentText("bscan")
self.w._pipeline.reset_history()
self.w._plots.bscan.clear()
self.single_input.setText("0")
self.single_output.setText("0")
def test_next_button_draws_the_next_cached_combo_without_reprocessing(self) -> None:
self.w._result_history.append(ResultCollection(
collection_id=1,
monotonic_ns=1,
blocks=[
ResultBlock(combo=ComboKey(input=0, output=output), payloads=[ResultPayload(
processing_name="bscan", kind=1,
frequency_hz=np.array([0.0, 1.0], dtype=np.float32),
trace=np.array([output + 1, output + 2], dtype=np.complex64),
)])
for output in (0, 1)
],
))
with mock.patch.object(self.w, "_write_live_processing_config") as write_config:
self.w._next_combo_button.click()
def _seed_bscan(self, pairs: tuple[tuple[int, int], ...] | list[tuple[int, int]] = ((0, 0), (0, 1))) -> None:
result = ResultCollection(1, 1, capture_start_ns=1, blocks=[
ResultBlock(ComboKey(*key), [ResultPayload(
processing_name="bscan", kind=1,
frequency_hz=np.array([0.0, 1.0], dtype=np.float32),
trace=np.array([key[1] + 1, key[1] + 2], dtype=np.complex64),
)]) for key in pairs
])
self.w._history.results.append(result)
self.w._plots.draw(result)
self.assertEqual(self.w._single_combo_input.text(), "0")
self.assertEqual(self.w._single_combo_output.text(), "1")
self.assertIn("in0/out1", self.w._bscan_plot.getPlotItem().titleLabel.text)
write_config.assert_not_called()
self.assertFalse(self.w._bscan_reprocess_timer.isActive())
def test_next_button_draws_cached_combo_without_changing_acquisition(self) -> None:
for model in ("librevna", "librevna_multi", "kamil_adc"):
with self.subTest(model=model):
self.w._profile.state.config.radar.model = model
self._seed_bscan()
self.w._plots.select_combo(0, 0)
with mock.patch.object(self.w._live, "publish") as write_config:
self.next_button.click()
self.assertEqual(self.w._plots.display_key, (0, 1))
self.assertEqual(self.single_input.text(), "0")
self.assertEqual(self.single_output.text(), "0")
plot = self.w._plots.bscan.widget.getPlotItem()
assert plot is not None
self.assertIn("in0/out1", plot.titleLabel.text)
write_config.assert_not_called()
self.assertFalse(self.w._pipeline.replay.timer.isActive())
def test_navigation_wraps_with_output_changing_first(self) -> None:
keys = self.w._available_display_combo_keys()
visited = [(0, 0)]
def test_navigation_wraps_only_through_recorded_pairs(self) -> None:
keys = [(0, 0), (0, 3), (2, 1)]
self._seed_bscan(keys)
self.w._plots.select_combo(*keys[0])
visited = [self.w._plots.display_key]
for _ in keys:
self.w._next_combo_button.click()
visited.append((int(self.w._single_combo_input.text()), int(self.w._single_combo_output.text())))
self.next_button.click()
visited.append(self.w._plots.display_key)
self.assertEqual(visited, keys + [keys[0]])
def test_invalid_current_pair_recovers_to_the_first_pair(self) -> None:
self.w._single_combo_input.setText("invalid")
self.w._single_combo_output.setText("99")
self.w._next_combo_button.click()
self.assertEqual(self.w._single_combo_input.text(), "0")
self.assertEqual(self.w._single_combo_output.text(), "0")
def test_unknown_selection_keeps_the_previous_fields(self) -> None:
with self.assertRaisesRegex(ValueError, "Unknown channel combination"):
self.w._select_display_combo(999, 999)
self.assertEqual(self.w._single_combo_input.text(), "0")
self.assertEqual(self.w._single_combo_output.text(), "0")
self.w._plots.select_combo(999, 999)
self.assertEqual(self.w._plots.display_key, keys[0])
def _seed_unaligned_stages(self) -> tuple[list[SweepCollection], list[SweepCollection], list[ResultCollection]]:
raw = [SweepCollection(i, i, capture_start_ns=i) for i in range(1, 1003)]
pre = [SweepCollection(i, i, capture_start_ns=i) for i in range(11, 1012)]
results = [ResultCollection(i, i, capture_start_ns=i) for i in range(21, 1021)]
self.w._pipeline.reset_history()
self.w._history.raw.extend(raw)
self.w._history.preprocessed.extend(pre)
self.w._history.results.extend(results)
return raw[-1000:], pre[-1000:], results[-1000:]
class ManualSaveLimitTest(unittest.TestCase):
def setUp(self) -> None:
self.w = AppWindowSnapshotMixin()
self.w._raw_history = ["raw"]
self.w._pre_history = ["pre"]
self.w._result_history = ["result"]
self.w._drain_runtime_rings_for_snapshot = mock.Mock()
self.w._show_exception = mock.Mock()
def test_save_cancels_when_drain_restarts_acquisition_with_another_profile(self) -> None:
self._seed_unaligned_stages()
active = self.w._pipeline.active_config
self.addCleanup(setattr, self.w._pipeline, "active_config", active)
with mock.patch.object(self.w._pipeline, "drain_snapshot", side_effect=lambda: setattr(
self.w._pipeline, "active_config", RunConfigModel(),
)), mock.patch.object(self.w._snapshots.store, "save_runtime_snapshot_numpy") as save:
self.w._snapshots.save()
save.assert_not_called()
def test_snapshot_always_saves_1000_and_preserves_unaligned_stages(self) -> None:
self.w._write_snapshot_dataset = mock.Mock()
self.w._save_snapshot()
self.w._write_snapshot_dataset.assert_called_once_with(
["raw"], ["pre"], ["result"], 1000, include_unaligned=True,
)
def test_json_export_uses_the_same_fixed_limit(self) -> None:
self.w._save_path_input = SimpleNamespace(text=lambda: "/tmp/measurements")
self.w._save_name_input = SimpleNamespace(text=lambda: "scene")
self.w._radar_config_name_prefix = lambda: "radar"
save_json = mock.Mock(return_value=([Path("/tmp/measurements/scene/trace.json")], {
"output_stem": "scene", "output_dir": "/tmp/measurements/scene",
}))
self.w._store = SimpleNamespace(save_runtime_vna_history_json_batch=save_json)
self.w._write_gui_profile_to_path = mock.Mock()
self.w._log = mock.Mock()
self.w._save_vna_history_json()
self.w._show_exception.assert_not_called()
save_json.assert_called_once_with(
Path("/tmp/measurements"), "scene", ["raw"], ["pre"], ["result"], 1000,
channel="s21", primary_stage="preprocessed", name_prefix="radar", include_unaligned=True,
)
if __name__ == "__main__":