web UI added and refactoring done

This commit is contained in:
Ayzen
2026-06-06 00:06:30 +03:00
parent 3c30a12d4a
commit af6005d68f
65 changed files with 3630 additions and 4720 deletions
@@ -30,6 +30,42 @@ class GuiProfileCodecTest(unittest.TestCase):
self.assertEqual(decoded.gui.processing.pass_through.combo_filter, "0:0,1:0")
self.assertEqual(encoded["gui"]["processing"]["pass_through"]["combo_filter"], "0:0,1:0")
def test_default_profile_round_trips_idempotently(self) -> None:
# Full-subtree idempotence catches field-drop/mis-map regressions across every
# sub-model, which the single combo_filter round-trip above cannot.
once = GuiProfileModel().to_dict()
twice = GuiProfileModel.from_dict(once).to_dict()
self.assertEqual(once["gui"], twice["gui"])
def test_missing_gui_section_yields_none(self) -> None:
self.assertIsNone(GuiProfileModel.from_dict({}).gui)
def test_unsupported_version_is_rejected(self) -> None:
with self.assertRaisesRegex(ValueError, "version"):
GuiProfileModel.from_dict({"gui": {"version": 2}})
def test_invalid_combo_mode_is_rejected(self) -> None:
with self.assertRaisesRegex(ValueError, "combo_mode"):
GuiProfileModel.from_dict({"gui": {"switches": {"combo_mode": "bogus"}}})
def test_wrong_typed_field_is_rejected(self) -> None:
with self.assertRaises(ValueError): # combos_text must be a JSON string, not a number
GuiProfileModel.from_dict({"gui": {"switches": {"combos_text": 123}}})
def test_legacy_gpr_payload_migrates_selected_mode(self) -> None:
# An old payload that selects 'gpr' but carries a legacy root gpr.mode is migrated.
decoded = GuiProfileModel.from_dict({
"gui": {"processing": {"selected_mode": "gpr"}},
"gpr": {"relative_permittivity": 4.0, "mode": "point"},
})
assert decoded.gui is not None
self.assertEqual(decoded.gui.processing.selected_mode, "legacy_gpr")
def test_modern_gpr_selection_is_not_migrated(self) -> None:
decoded = GuiProfileModel.from_dict({"gui": {"processing": {"selected_mode": "gpr"}}})
assert decoded.gui is not None
self.assertEqual(decoded.gui.processing.selected_mode, "gpr")
if __name__ == "__main__":
unittest.main()
@@ -60,6 +60,46 @@ class KamilAdcNeutralPreprocessTest(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "kamil_adc"):
build_kamil_adc_neutral_s21_sets(config, point_count=4)
@staticmethod
def _kamil_config() -> RunConfigModel:
return RunConfigModel.from_dict(
{
"radar": {
"model": "kamil_adc",
"sweep": {"start_hz": 1_000_000.0, "stop_hz": 4_000_000.0,
"if_bandwidth_hz": 1.0, "stimulus_power_dbm": -10.0},
},
"switches": {"port1": {"positions": 1}, "port2": {"positions": 2}},
"run": {"combos": [{"input": 0, "output": 0}, {"input": 1, "output": 0}]},
}
)
def test_point_count_zero_or_negative_raises(self) -> None:
config = self._kamil_config()
for bad in (0, -1):
with self.subTest(point_count=bad), self.assertRaisesRegex(ValueError, "point count"):
build_kamil_adc_neutral_s21_sets(config, point_count=bad)
def test_single_point_sweep(self) -> None:
calibration, _reference = build_kamil_adc_neutral_s21_sets(self._kamil_config(), point_count=1)
for trace in calibration.traces:
self.assertEqual(trace.frequency_hz.tolist(), [1_000_000.0])
self.assertEqual(trace.s21.shape, (1,))
def test_dtypes_are_float32_and_complex64(self) -> None:
calibration, _reference = build_kamil_adc_neutral_s21_sets(self._kamil_config(), point_count=4)
trace = calibration.traces[0]
self.assertEqual(trace.frequency_hz.dtype, np.float32)
self.assertEqual(trace.s21.dtype, np.complex64)
self.assertEqual(trace.s11.dtype, np.complex64)
def test_calibration_s21_is_a_nonzero_divisor(self) -> None:
# The C++ through-calibrator divides measured/calibration, so calibration S21
# must never be zero — that is the whole point of the '1+0j neutral' contract.
calibration, _reference = build_kamil_adc_neutral_s21_sets(self._kamil_config(), point_count=4)
for trace in calibration.traces:
self.assertTrue(bool(np.all(trace.s21 != 0)))
if __name__ == "__main__":
unittest.main()
@@ -140,6 +140,23 @@ class KamilAdcTtyReaderTest(unittest.TestCase):
finally:
self._close(master_fd, slave_fd, reader)
def test_corrupt_frame_fails_fast_without_resync(self) -> None:
"""A garbage frame (bad marker) mid-stream surfaces on read; the reader does
NOT silently resync — fail-fast lets the producer die and the supervisor relaunch."""
master_fd, slave_fd, reader = self._open_pty_reader()
try:
os.write(
master_fd,
_start_frame()
+ _point_frame(1, 10, -1)
+ _point_frame(2, 5, 5, marker=0x001A) # corrupt marker (not 0x000A)
+ _start_frame(),
)
with self.assertRaises((ValueError, RuntimeError)):
reader.read_sweep(timeout_s=1.0)
finally:
self._close(master_fd, slave_fd, reader)
def test_no_completed_sweep_times_out(self) -> None:
master_fd, slave_fd, reader = self._open_pty_reader()
try:
@@ -17,7 +17,9 @@ DEVICE_MAIN_CHANGE_CURRENT_LD1_HEX = (
"7777ff3701003d2acc2c10008813ffa5cc2c18ab0a00000a8000000a8000b600"
)
DEVICE_MAIN_CHANGE_CURRENT_LD2_HEX = (
"7777ff3702003d2acc2c0500881318ab3d2affa50a00000a8000000a80005106"
# Word 5 (step) = current_ma_to_n(0.05)=0x0010, matching LD1 and min/max — see the
# step-encoding fix in protocol.py (was the inconsistent int(step*100)=0x0005).
"7777ff3702003d2acc2c1000881318ab3d2affa50a00000a8000000a80004406"
)
@@ -128,6 +130,18 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
self.assertEqual(ld1_command.hex(), DEVICE_MAIN_CHANGE_CURRENT_LD1_HEX)
self.assertEqual(ld2_command.hex(), DEVICE_MAIN_CHANGE_CURRENT_LD2_HEX)
def test_ld1_and_ld2_encode_current_step_identically(self) -> None:
# Regression guard for the LD2 step-scale bug: both current-variation channels
# must encode the step with the same scale (current_ma_to_n), like min/max.
params = dict(static_temp1=28.0, static_temp2=28.9, static_current1=33.0, static_current2=35.0,
min_value=33.0, max_value=35.0, step=0.05, time_step=50, delay_time=10,
message_id=DEVICE_MAIN_MESSAGE_ID, pi_coeff1_p=2560, pi_coeff1_i=128,
pi_coeff2_p=2560, pi_coeff2_i=128)
ld1 = Protocol.encode_task_enable(task_type=TaskType.CHANGE_CURRENT_LD1, **params)
ld2 = Protocol.encode_task_enable(task_type=TaskType.CHANGE_CURRENT_LD2, **params)
# Word 5 (step) is at bytes 10:12 in both frames (sync, header, task, min, max, step).
self.assertEqual(ld1[10:12], ld2[10:12])
def test_start_sequence_matches_device_main_order(self) -> None:
fake_protocol = _FakeProtocol()
controller = LaserController(pi_coeff1_p=2560, pi_coeff1_i=128, pi_coeff2_p=2560, pi_coeff2_i=128)
@@ -235,6 +249,37 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
"message_id": DEVICE_MAIN_MESSAGE_ID,
},
)
# The variation handoff itself (type + params), not just call order, must be right.
variation_payload = controller.calls[3][1]
self.assertEqual(variation_payload["variation_type"], VariationType.CHANGE_CURRENT_LD1)
self.assertEqual(variation_payload["params"]["step"], 0.05)
self.assertEqual(variation_payload["params"]["min_value"], 33.0)
self.assertEqual(variation_payload["params"]["max_value"], 35.0)
def test_apply_radar_manual_mode_sets_manual_without_variation(self) -> None:
config = self._kamil_config(
{
"enabled": True,
"port": "/dev/ttyUSB0",
"mode": "manual",
"manual": {"temp1": 26.0, "temp2": 27.0, "current1": 30.0, "current2": 31.0},
}
)
with patch("python_app.hardware_full.laser_control.controller.LaserController", _FakeLaserController):
applied = apply_kamil_adc_laser_control(config)
self.assertTrue(applied)
controller = _FakeLaserController.instances[0]
self.assertEqual([name for name, _ in controller.calls], ["connect", "reset", "set_manual_mode", "disconnect"])
self.assertEqual(controller.calls[2][1]["current1"], 30.0)
def test_apply_radar_unknown_variation_type_raises(self) -> None:
config = self._kamil_config(
{"enabled": True, "port": "/dev/ttyUSB0", "mode": "variation",
"variation": {"variation_type": "NOT_A_REAL_TYPE"}}
)
with patch("python_app.hardware_full.laser_control.controller.LaserController", _FakeLaserController):
with self.assertRaisesRegex(ValueError, "variation_type"):
apply_kamil_adc_laser_control(config)
def test_apply_radar_skips_disabled_laser_control(self) -> None:
config = self._kamil_config({"enabled": False})
@@ -0,0 +1,197 @@
"""Orchestration & recovery tests.
Pins the agreed semantics:
* crash auto-restart retries FOREVER with capped exponential back-off (never gives
up — unattended appliance); the failure streak resets when data flows again;
* config writes are atomic — a serialization failure leaves no partial output and
never corrupts an existing config;
* the acquisition producer command is selected by radar.model;
* process exit reports classify clean vs unexpected exits correctly.
"""
from __future__ import annotations
import json
import os
import sys
import tempfile
import time
import unittest
from pathlib import Path
from python_app.models.run_config_model import RunConfigModel
from python_app.orchestration.config_writer import ConfigWriter
from python_app.orchestration.process_supervisor import ProcessExitReport, ProcessSupervisor
from python_app.orchestration.restart_policy import RestartPolicy
class RestartPolicyTest(unittest.TestCase):
def test_backoff_grows_and_caps(self) -> None:
policy = RestartPolicy(min_interval_s=3.0, max_interval_s=60.0, backoff_factor=2.0)
self.assertEqual(policy.backoff_for(0), 3.0)
self.assertEqual(policy.backoff_for(1), 6.0)
self.assertEqual(policy.backoff_for(2), 12.0)
self.assertEqual(policy.backoff_for(3), 24.0)
self.assertEqual(policy.backoff_for(100), 60.0) # capped
def test_never_gives_up_even_after_huge_streak(self) -> None:
# No attempt cap: a long failure streak still yields a finite, capped wait.
policy = RestartPolicy()
self.assertEqual(policy.backoff_for(10_000), policy.max_interval_s)
def test_should_restart_respects_backoff_window(self) -> None:
policy = RestartPolicy(min_interval_s=3.0, max_interval_s=60.0)
# First failure (streak 0): needs >= 3s since last restart.
self.assertFalse(policy.should_restart_now(now_s=102.0, last_restart_s=100.0, consecutive_failures=0))
self.assertTrue(policy.should_restart_now(now_s=103.0, last_restart_s=100.0, consecutive_failures=0))
# After 2 failures the window is 12s.
self.assertFalse(policy.should_restart_now(now_s=111.0, last_restart_s=100.0, consecutive_failures=2))
self.assertTrue(policy.should_restart_now(now_s=112.0, last_restart_s=100.0, consecutive_failures=2))
def test_first_restart_is_immediate(self) -> None:
# last_restart defaults to 0.0 in the mixin, so the very first crash restarts now.
self.assertTrue(RestartPolicy().should_restart_now(now_s=5_000.0, last_restart_s=0.0, consecutive_failures=0))
class ConfigWriterTest(unittest.TestCase):
def setUp(self) -> None:
self._dir = tempfile.TemporaryDirectory()
self.addCleanup(self._dir.cleanup)
self.root = Path(self._dir.name)
self.writer = ConfigWriter(self.root / "runtime")
def test_writes_loadable_config_and_leaves_no_tmp(self) -> None:
out = self.root / "run_config.json"
self.writer.write(RunConfigModel(), out)
self.assertTrue(out.exists())
self.assertFalse(out.with_suffix(".json.tmp").exists())
RunConfigModel.from_dict(json.loads(out.read_text())) # round-trips back through the schema
def test_nan_fails_loudly_without_corrupting_existing(self) -> None:
out = self.root / "run_config.json"
self.writer.write(RunConfigModel(), out)
original = out.read_text()
broken = RunConfigModel()
broken.radar.sweep.if_bandwidth_hz = float("nan")
with self.assertRaises(ValueError): # allow_nan=False
self.writer.write(broken, out)
self.assertEqual(out.read_text(), original) # existing config untouched
self.assertFalse(out.with_suffix(".json.tmp").exists()) # no half-written tmp left
class RadarModelCommandTest(unittest.TestCase):
def setUp(self) -> None:
self._dir = tempfile.TemporaryDirectory()
self.addCleanup(self._dir.cleanup)
self.root = Path(self._dir.name)
self.supervisor = ProcessSupervisor(self.root)
def _config_with_model(self, model: str) -> Path:
path = self.root / "cfg.json"
path.write_text(json.dumps({"radar": {"model": model}}))
return path
def test_read_radar_model(self) -> None:
self.assertEqual(ProcessSupervisor._read_radar_model(self._config_with_model("kamil_adc")), "kamil_adc")
def test_read_radar_model_defaults_on_malformed(self) -> None:
path = self.root / "bad.json"
path.write_text("[]") # not an object
self.assertEqual(ProcessSupervisor._read_radar_model(path), "librevna")
def test_matrix_producer_for_multi_and_sn9000(self) -> None:
for model in ("librevna_multi", "sn9000"):
cmd = self.supervisor._acquisition_command(self._config_with_model(model))
self.assertEqual(cmd[:3], [sys.executable, "-m", "python_app.scripts.matrix_raw_producer"])
def test_kamil_producer_for_kamil_adc(self) -> None:
cmd = self.supervisor._acquisition_command(self._config_with_model("kamil_adc"))
self.assertEqual(cmd[:3], [sys.executable, "-m", "python_app.scripts.kamil_adc_raw_producer"])
def test_native_orchestrator_for_librevna(self) -> None:
cmd = self.supervisor._acquisition_command(self._config_with_model("librevna"))
self.assertTrue(cmd[0].endswith("build/bin/sweep_orchestrator"))
class ProcessExitReportTest(unittest.TestCase):
@staticmethod
def _report(*, code: int, clean: bool) -> ProcessExitReport:
return ProcessExitReport(
name="data_processor",
command=["x"],
working_directory=Path("/tmp"),
return_code=code,
stdout_path=Path("/nonexistent.out"),
stderr_path=Path("/nonexistent.err"),
expected_clean_exit=clean,
)
def test_clean_exit_is_info(self) -> None:
report = self._report(code=0, clean=True)
self.assertEqual(report.level, "INFO")
self.assertIn("completed normally", report.format())
def test_nonzero_exit_is_error(self) -> None:
report = self._report(code=3, clean=False)
self.assertEqual(report.level, "ERROR")
self.assertIn("exited with code 3", report.format())
def test_unexpected_zero_exit_is_error(self) -> None:
report = self._report(code=0, clean=False)
self.assertEqual(report.level, "ERROR")
self.assertIn("exited unexpectedly with code 0", report.format())
class SupervisorLifecycleTest(unittest.TestCase):
def setUp(self) -> None:
self._dir = tempfile.TemporaryDirectory()
self.addCleanup(self._dir.cleanup)
self.supervisor = ProcessSupervisor(Path(self._dir.name))
self.addCleanup(self.supervisor.stop_all)
def _await_reports(self, timeout_s: float = 3.0) -> list[ProcessExitReport]:
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
reports = self.supervisor.collect_exit_reports()
if reports:
return reports
time.sleep(0.02)
return []
def test_spawn_alive_then_stop(self) -> None:
self.supervisor._spawn("data_preprocessor",
[sys.executable, "-c", "import time; time.sleep(30)"],
allow_clean_exit=False)
self.assertTrue(self.supervisor.is_running())
self.assertIn("data_preprocessor", self.supervisor.pids())
self.supervisor.stop()
self.assertFalse(self.supervisor.is_running())
def test_unexpected_exit_reported_as_error(self) -> None:
self.supervisor._spawn("data_processor",
[sys.executable, "-c", "import sys; sys.exit(3)"],
allow_clean_exit=False)
reports = self._await_reports()
self.assertEqual(len(reports), 1)
self.assertEqual(reports[0].return_code, 3)
self.assertEqual(reports[0].level, "ERROR")
def test_clean_exit_reported_as_info(self) -> None:
self.supervisor._spawn("sweep_orchestrator",
[sys.executable, "-c", "import sys; sys.exit(0)"],
allow_clean_exit=True)
reports = self._await_reports()
self.assertEqual(len(reports), 1)
self.assertTrue(reports[0].expected_clean_exit)
self.assertEqual(reports[0].level, "INFO")
def test_stale_pid_guard_rejects_unrelated_processes(self) -> None:
# The reap guard must never kill a recycled PID that is not one of our binaries.
self.assertFalse(self.supervisor._is_stale_pipeline_pid(2_000_000_000)) # no such pid
self.assertFalse(self.supervisor._is_stale_pipeline_pid(os.getpid())) # the test runner
if __name__ == "__main__":
unittest.main()
+296
View File
@@ -0,0 +1,296 @@
"""Processing-interpretation tests across processor modes.
Pins the agreed semantics (not just current behaviour):
* GPR object filtering is mode-agnostic — keep finite rows with score >= threshold
(a normalized float for coherent GPR, a pair count for legacy GPR; same compare)
inside the visible X/Z window, then apply draw limits. When more than
max_detected_objects survive, ALL are hidden; legacy GPR passes draw_limits=None
(count rules disabled) but is otherwise filtered identically — so heatmap markers
match the objects-only view in both modes.
* B-scan level/colormap/mean-subtraction/history transforms are deterministic.
* ProcessingLiveConfig normalizes optional position lists to concrete int lists.
"""
from __future__ import annotations
import math
import unittest
from collections import deque
import numpy as np
from python_app.gui.controllers.app_window_plot.bscan_plot_mixin import (
apply_mean_ascan_subtraction,
bscan_levels,
bscan_lookup_table,
build_lut,
rebuild_bscan_history_from_results,
)
from python_app.gui.controllers.app_window_plot.gpr_plot_mixin import AppWindowGprPlotMixin
from python_app.models.dataset_model import (
ComboKey,
ResultBlock,
ResultCollection,
ResultPayload,
)
from python_app.orchestration.gpr_locator import (
apply_object_draw_limits,
collection_has_gpr_payloads,
collection_payload_by_name,
collection_payloads_by_prefix,
filter_object_rows,
gpr_object_rows,
)
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
def _table(name: str, rows) -> ResultPayload:
return ResultPayload(processing_name=name, kind=4, table=np.asarray(rows, dtype=np.float32))
def _collection(payloads) -> ResultCollection:
return ResultCollection(collection_id=1, monotonic_ns=1, collection_payloads=list(payloads))
# --------------------------------------------------------------------------- #
# Shared result-collection lookup helpers
# --------------------------------------------------------------------------- #
class CollectionLookupTest(unittest.TestCase):
def test_payload_by_name_matches_name_and_optional_kind(self) -> None:
col = _collection([_table("gpr_points", [[0, 0, 1]]), _table("gpr_region_centers", [[1, 1, 2, 9]])])
self.assertIs(collection_payload_by_name(col, "gpr_points"), col.collection_payloads[0])
self.assertIsNone(collection_payload_by_name(col, "missing"))
self.assertIsNone(collection_payload_by_name(col, "gpr_points", kind=3)) # wrong kind
def test_payloads_by_prefix_returns_all_in_order(self) -> None:
col = _collection([
ResultPayload(processing_name="gpr_region_mask_0", kind=3, image=np.zeros((1, 1), dtype=np.float32)),
ResultPayload(processing_name="gpr_region_mask_1", kind=3, image=np.zeros((1, 1), dtype=np.float32)),
_table("gpr_points", [[0, 0, 1]]),
])
masks = collection_payloads_by_prefix(col, "gpr_region_mask_", kind=3)
self.assertEqual([p.processing_name for p in masks], ["gpr_region_mask_0", "gpr_region_mask_1"])
self.assertEqual(collection_payloads_by_prefix(col, "nope_"), [])
def test_has_gpr_payloads(self) -> None:
self.assertTrue(collection_has_gpr_payloads(_collection([_table("gpr_points", [[0, 0, 1]])])))
self.assertFalse(collection_has_gpr_payloads(_collection([_table("pass_through", [[0, 0, 1]])])))
self.assertFalse(collection_has_gpr_payloads(_collection([])))
# --------------------------------------------------------------------------- #
# GPR object row extraction
# --------------------------------------------------------------------------- #
class GprObjectRowsTest(unittest.TestCase):
def test_extracts_first_three_columns_of_points(self) -> None:
rows = gpr_object_rows(_collection([_table("gpr_points", [[1, 2, 3], [4, 5, 6]])]))
self.assertTrue(np.array_equal(rows, np.array([[1, 2, 3], [4, 5, 6]], dtype=np.float32)))
def test_falls_back_to_region_centers_and_drops_extra_columns(self) -> None:
# region_centers is [x, z, score, pixel_count]; only the first 3 cols are used.
rows = gpr_object_rows(_collection([_table("gpr_region_centers", [[1, 2, 3, 99]])]))
self.assertTrue(np.array_equal(rows, np.array([[1, 2, 3]], dtype=np.float32)))
def test_empty_when_no_object_payloads(self) -> None:
self.assertEqual(gpr_object_rows(_collection([_table("gpr_accumulator", [[1, 2, 3]])])).shape, (0, 3))
def test_rejects_table_with_too_few_columns(self) -> None:
self.assertEqual(gpr_object_rows(_collection([_table("gpr_points", [[1, 2]])])).shape, (0, 3))
# --------------------------------------------------------------------------- #
# Object draw limits (hide-all-when-over, then top-M)
# --------------------------------------------------------------------------- #
class ApplyObjectDrawLimitsTest(unittest.TestCase):
@staticmethod
def _rows(n: int) -> np.ndarray:
return np.column_stack([np.arange(n), np.arange(n), np.arange(n)]).astype(np.float32)
def test_none_limits_passes_through(self) -> None:
rows = self._rows(5)
self.assertTrue(np.array_equal(apply_object_draw_limits(rows, None), rows))
def test_hides_all_when_over_max(self) -> None:
self.assertEqual(apply_object_draw_limits(self._rows(6), (5, 3)).shape, (0, 3))
def test_keeps_top_m_when_within_max(self) -> None:
out = apply_object_draw_limits(self._rows(4), (5, 2))
self.assertEqual(out.shape, (2, 3))
self.assertTrue(np.array_equal(out, self._rows(4)[:2]))
def test_max_zero_hides_any_objects(self) -> None:
# max_detected_objects == 0 means "hide all" (any object exceeds it).
self.assertEqual(apply_object_draw_limits(self._rows(1), (0, 5)).shape, (0, 3))
def test_empty_input_passes_through(self) -> None:
empty = np.zeros((0, 3), dtype=np.float32)
self.assertEqual(apply_object_draw_limits(empty, (5, 3)).shape, (0, 3))
# --------------------------------------------------------------------------- #
# Mode-agnostic object filtering (the core both modes + the locator share)
# --------------------------------------------------------------------------- #
class FilterObjectRowsTest(unittest.TestCase):
BOUNDS = {"x_bounds": (-2.0, 2.0), "z_bounds": (0.0, 10.0)}
def _filter(self, rows, *, min_score, draw_limits=None):
return filter_object_rows(np.asarray(rows, dtype=np.float32), min_score=min_score,
draw_limits=draw_limits, **self.BOUNDS)
def test_keeps_in_window_and_at_or_above_threshold(self) -> None:
out = self._filter([[0.0, 5.0, 0.5], [1.0, 1.0, 0.9]], min_score=0.5) # score==threshold kept (inclusive)
self.assertEqual(out.shape[0], 2)
def test_drops_below_threshold(self) -> None:
out = self._filter([[0.0, 5.0, 0.4]], min_score=0.5)
self.assertEqual(out.shape[0], 0)
def test_window_bounds_are_inclusive(self) -> None:
out = self._filter([[2.0, 10.0, 1.0], [-2.0, 0.0, 1.0]], min_score=0.0) # exactly on each edge
self.assertEqual(out.shape[0], 2)
def test_drops_outside_window(self) -> None:
out = self._filter([[2.001, 5.0, 1.0], [0.0, 10.001, 1.0]], min_score=0.0)
self.assertEqual(out.shape[0], 0)
def test_drops_non_finite_rows(self) -> None:
out = self._filter([[np.nan, 5.0, 1.0], [0.0, np.inf, 1.0], [0.0, 5.0, 1.0]], min_score=0.0)
self.assertEqual(out.shape[0], 1)
def test_legacy_pair_count_threshold_uses_same_compare(self) -> None:
# legacy GPR passes an integer pair-count threshold; the >= compare is identical.
out = self._filter([[0.0, 5.0, 3.0], [0.0, 5.0, 2.0]], min_score=3, draw_limits=None)
self.assertTrue(np.array_equal(out, np.array([[0.0, 5.0, 3.0]], dtype=np.float32)))
def test_draw_limits_hide_all_when_over(self) -> None:
rows = [[0.0, 5.0, 1.0]] * 4
self.assertEqual(self._filter(rows, min_score=0.0, draw_limits=(3, 2)).shape[0], 0)
def test_legacy_none_limits_skips_count_rule(self) -> None:
rows = [[0.0, 5.0, 1.0]] * 4
self.assertEqual(self._filter(rows, min_score=0.0, draw_limits=None).shape[0], 4)
# --------------------------------------------------------------------------- #
# B-scan transforms
# --------------------------------------------------------------------------- #
class BscanTransformTest(unittest.TestCase):
def test_mean_ascan_subtraction(self) -> None:
history = deque([np.array([1.0, 1.0], dtype=np.float32), np.array([3.0, 3.0], dtype=np.float32)])
self.assertTrue(np.array_equal(apply_mean_ascan_subtraction(history, enabled=False),
np.array([[1, 1], [3, 3]], dtype=np.float32)))
self.assertTrue(np.array_equal(apply_mean_ascan_subtraction(history, enabled=True),
np.array([[-1, -1], [1, 1]], dtype=np.float32)))
def test_levels_abs_mode(self) -> None:
self.assertEqual(bscan_levels(np.array([[1.0, 4.0], [2.0, 3.0]], dtype=np.float32), "abs"), (1.0, 4.0))
def test_levels_abs_degenerate(self) -> None:
low, high = bscan_levels(np.full((2, 2), 5.0, dtype=np.float32), "abs")
self.assertEqual(low, 5.0)
self.assertGreater(high, low)
def test_levels_signed_mode_symmetric(self) -> None:
self.assertEqual(bscan_levels(np.array([[-3.0, 1.0]], dtype=np.float32), "real"), (-3.0, 3.0))
def test_build_lut_shape_and_endpoints(self) -> None:
lut = build_lut(["#000000", "#ffffff"])
self.assertEqual(lut.shape, (256, 3))
self.assertEqual(lut.dtype, np.uint8)
self.assertTrue(np.array_equal(lut[0], [0, 0, 0]))
self.assertTrue(np.array_equal(lut[-1], [255, 255, 255]))
def test_lookup_table_for_each_axis_mode(self) -> None:
for mode in ("abs", "real", "phase"):
self.assertEqual(bscan_lookup_table(mode).shape, (256, 3))
class RebuildBscanHistoryTest(unittest.TestCase):
@staticmethod
def _bscan_collection(cid: int, combo, depth, amps, *, name="bscan", kind=1) -> ResultCollection:
trace = np.asarray(amps, dtype=np.float32).astype(np.complex64)
payload = ResultPayload(processing_name=name, kind=kind,
frequency_hz=np.asarray(depth, dtype=np.float32), trace=trace)
block = ResultBlock(combo=ComboKey(input=combo[0], output=combo[1]), payloads=[payload])
return ResultCollection(collection_id=cid, monotonic_ns=cid, blocks=[block])
def test_accumulates_sweeps_per_combo(self) -> None:
history = [
self._bscan_collection(1, (0, 0), [1.0, 2.0], [10.0, 20.0]),
self._bscan_collection(2, (0, 0), [1.0, 2.0], [11.0, 21.0]),
]
by_combo, axes = rebuild_bscan_history_from_results(history, history_limit=10, floor_collection_id=0)
self.assertEqual(len(by_combo[(0, 0)]), 2)
self.assertTrue(np.array_equal(axes[(0, 0)], np.array([1.0, 2.0], dtype=np.float32)))
def test_skips_non_bscan_and_mismatched_payloads(self) -> None:
history = [
self._bscan_collection(1, (0, 0), [1.0, 2.0], [1.0, 2.0], name="other"), # wrong name
self._bscan_collection(2, (0, 0), [1.0, 2.0], [1.0, 2.0], kind=2), # wrong kind
self._bscan_collection(3, (0, 0), [1.0, 2.0, 3.0], [1.0, 2.0]), # size mismatch
]
by_combo, _ = rebuild_bscan_history_from_results(history, history_limit=10, floor_collection_id=0)
self.assertEqual(by_combo, {})
def test_depth_axis_change_resets_history(self) -> None:
history = [
self._bscan_collection(1, (0, 0), [1.0, 2.0], [10.0, 20.0]),
self._bscan_collection(2, (0, 0), [1.0, 2.0, 3.0], [11.0, 21.0, 31.0]), # new depth axis
]
by_combo, axes = rebuild_bscan_history_from_results(history, history_limit=10, floor_collection_id=0)
self.assertEqual(len(by_combo[(0, 0)]), 1) # reset on axis change; only the latest sweep remains
self.assertEqual(axes[(0, 0)].shape, (3,))
def test_floor_collection_id_excludes_older(self) -> None:
history = [
self._bscan_collection(1, (0, 0), [1.0], [10.0]),
self._bscan_collection(2, (0, 0), [1.0], [20.0]),
]
by_combo, _ = rebuild_bscan_history_from_results(history, history_limit=10, floor_collection_id=1)
self.assertEqual(len(by_combo[(0, 0)]), 1) # only collection_id > 1
# --------------------------------------------------------------------------- #
# GPR display-range helpers (pure static math on the mixin)
# --------------------------------------------------------------------------- #
class GprDisplayHelperTest(unittest.TestCase):
def test_normalized_range_orders_and_expands(self) -> None:
self.assertEqual(AppWindowGprPlotMixin._normalized_display_range(2.0, 5.0), (2.0, 5.0))
self.assertEqual(AppWindowGprPlotMixin._normalized_display_range(5.0, 2.0), (2.0, 5.0)) # reordered
low, high = AppWindowGprPlotMixin._normalized_display_range(3.0, 3.0) # zero span expands to 0.1
self.assertAlmostEqual(high - low, 0.1)
self.assertAlmostEqual(0.5 * (low + high), 3.0)
def test_display_y_min_keeps_surface_margin(self) -> None:
self.assertEqual(AppWindowGprPlotMixin._gpr_display_y_min(2.0, 5.0), 2.0) # surface not visible
self.assertAlmostEqual(AppWindowGprPlotMixin._gpr_display_y_min(0.0, 10.0), -0.3) # 3% of span
self.assertAlmostEqual(AppWindowGprPlotMixin._gpr_display_y_min(-1.0, 1.0), -1.06) # min 0.06 margin
def test_object_label_candidates_are_distinct_positions(self) -> None:
candidates = AppWindowGprPlotMixin._gpr_object_label_candidates(0.0, 0.0, x_span=1.0, z_span=1.0)
self.assertEqual(len(candidates), 10)
self.assertTrue(all(math.isfinite(x) and math.isfinite(z) for x, z, _ in candidates))
# --------------------------------------------------------------------------- #
# ProcessingLiveConfig normalization
# --------------------------------------------------------------------------- #
class ProcessingLiveConfigTest(unittest.TestCase):
def test_none_positions_become_empty_lists(self) -> None:
cfg = ProcessingLiveConfig(gpr_input_positions=None, gpr_output_positions=None)
self.assertEqual(cfg.gpr_input_positions, [])
self.assertEqual(cfg.gpr_output_positions, [])
def test_positions_coerced_to_ints(self) -> None:
cfg = ProcessingLiveConfig(gpr_input_positions=[1.5, 2.9], gpr_output_positions=[0.0])
self.assertEqual(cfg.gpr_input_positions, [1, 2])
self.assertEqual(cfg.gpr_output_positions, [0])
def test_to_dict_is_json_typed(self) -> None:
data = ProcessingLiveConfig().to_dict()
self.assertIsInstance(data["processor_mode"], str)
self.assertIsInstance(data["gpr_input_positions"], list)
if __name__ == "__main__":
unittest.main()
+232
View File
@@ -0,0 +1,232 @@
"""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_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 _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_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 SweepValidationTest(unittest.TestCase):
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_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_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:
base = {"relative_permittivity": 4.0, "tx_geometry": [], "rx_geometry": []}
base.update(overrides)
return GprModel(**base) # type: ignore[arg-type]
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)],
))
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()
+247
View File
@@ -0,0 +1,247 @@
"""IPC/SHM tests: encode/decode round-trips, corruption handling, ring semantics.
Pins the agreed contract:
* encode -> decode is lossless for raw/preprocessed traces and result payloads;
* a corrupt/truncated frame raises a single, catchable ValueError (never a bare
struct.error / UnicodeDecodeError);
* the ring is latest-wins: on overflow the oldest slot is overwritten and a slow
reader keeps the freshest frames;
* peek_latest returns the newest frame without consuming it;
* opening a missing/incompatible ring fails fast.
"""
from __future__ import annotations
import struct
import unittest
from contextlib import suppress
from pathlib import Path
import numpy as np
from python_app.models.dataset_model import (
ComboKey,
ResultBlock,
ResultCollection,
ResultPayload,
SweepCollection,
TraceData,
)
from python_app.orchestration.shm.decoder import (
PREPROC_MAGIC,
RAW_MAGIC,
RESULT_MAGIC,
decode_result_collection,
decode_trace_collection,
)
from python_app.orchestration.shm.ring_reader import ShmRingReader
from python_app.orchestration.shm.ring_writer import ShmRingWriter
from python_app.storage.npz.serialize import serialize_result_collection, serialize_trace_collection
def _trace(in_pos: int, out_pos: int, n: int) -> TraceData:
"""Build a trace with float32-exact data so round-trips compare exactly."""
freq = np.arange(n, dtype=np.float32) + 1.0
s11 = (np.arange(n, dtype=np.float32) + 0.5j * np.arange(n, dtype=np.float32)).astype(np.complex64)
s21 = (-np.arange(n, dtype=np.float32) + 2.0j * np.arange(n, dtype=np.float32)).astype(np.complex64)
return TraceData(combo=ComboKey(input=in_pos, output=out_pos), frequency_hz=freq, s11=s11, s21=s21)
class TraceCollectionRoundTripTest(unittest.TestCase):
def _assert_round_trips(self, magic: int) -> None:
collection = SweepCollection(
collection_id=7,
monotonic_ns=123,
traces=[_trace(0, 0, 4), _trace(3, 1, 2)],
capture_start_ns=10,
capture_end_ns=20,
)
decoded = decode_trace_collection(serialize_trace_collection(collection, magic), magic)
self.assertEqual(decoded.collection_id, 7)
self.assertEqual(decoded.monotonic_ns, 123)
self.assertEqual((decoded.capture_start_ns, decoded.capture_end_ns), (10, 20))
self.assertEqual(len(decoded.traces), 2)
for original, got in zip(collection.traces, decoded.traces):
self.assertEqual((got.combo.input, got.combo.output), (original.combo.input, original.combo.output))
self.assertTrue(np.array_equal(got.frequency_hz, original.frequency_hz))
self.assertTrue(np.array_equal(got.s11, original.s11))
self.assertTrue(np.array_equal(got.s21, original.s21))
def test_raw_round_trips(self) -> None:
self._assert_round_trips(RAW_MAGIC)
def test_preprocessed_round_trips(self) -> None:
self._assert_round_trips(PREPROC_MAGIC)
def test_empty_traces_round_trip(self) -> None:
collection = SweepCollection(collection_id=1, monotonic_ns=2, traces=[])
decoded = decode_trace_collection(serialize_trace_collection(collection, RAW_MAGIC), RAW_MAGIC)
self.assertEqual(decoded.traces, [])
class ResultCollectionRoundTripTest(unittest.TestCase):
def test_all_payload_kinds_round_trip(self) -> None:
image = np.arange(6, dtype=np.float32).reshape((2, 3))
table = np.array([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], dtype=np.float32)
collection = ResultCollection(
collection_id=9,
monotonic_ns=42,
processing_duration_ns=1000,
collection_payloads=[
ResultPayload(
processing_name="gpr_accumulator", kind=3,
image_x_axis=np.array([0.0, 1.0, 2.0], dtype=np.float32),
image_y_axis=np.array([0.0, 1.0], dtype=np.float32),
image=image,
),
ResultPayload(processing_name="gpr_points", kind=4, table=table),
],
blocks=[
ResultBlock(combo=ComboKey(input=1, output=0), payloads=[
ResultPayload(
processing_name="bscan", kind=1,
frequency_hz=np.array([1.0, 2.0], dtype=np.float32),
trace=np.array([1 + 1j, 2 - 2j], dtype=np.complex64),
),
ResultPayload(processing_name="snr", kind=2, scalar_value=2.5),
]),
],
)
decoded = decode_result_collection(serialize_result_collection(collection))
self.assertEqual((decoded.collection_id, decoded.monotonic_ns, decoded.processing_duration_ns), (9, 42, 1000))
acc, points = decoded.collection_payloads
self.assertEqual(acc.processing_name, "gpr_accumulator")
self.assertTrue(np.array_equal(acc.image, image))
self.assertTrue(np.array_equal(points.table, table))
block = decoded.blocks[0]
self.assertEqual((block.combo.input, block.combo.output), (1, 0))
self.assertTrue(np.array_equal(block.payloads[0].trace, np.array([1 + 1j, 2 - 2j], dtype=np.complex64)))
self.assertAlmostEqual(block.payloads[1].scalar_value, 2.5)
class CorruptFrameTest(unittest.TestCase):
"""Any corruption surfaces as ValueError (the single catchable contract)."""
def _valid_trace_bytes(self) -> bytes:
return serialize_trace_collection(
SweepCollection(collection_id=1, monotonic_ns=1, traces=[_trace(0, 0, 3)]), RAW_MAGIC
)
def test_bad_magic(self) -> None:
corrupt = b"\x00\x00\x00\x00" + self._valid_trace_bytes()[4:]
with self.assertRaises(ValueError):
decode_trace_collection(corrupt, RAW_MAGIC)
def test_truncated_buffer(self) -> None:
with self.assertRaises(ValueError):
decode_trace_collection(self._valid_trace_bytes()[:18], RAW_MAGIC)
def test_absurd_trace_count(self) -> None:
# Claims 1e6 traces but supplies none -> the first trace read runs off the end.
corrupt = struct.pack("<IQQI", RAW_MAGIC, 1, 1, 1_000_000)
with self.assertRaises(ValueError):
decode_trace_collection(corrupt, RAW_MAGIC)
def test_unsupported_payload_kind(self) -> None:
corrupt = struct.pack("<IQQQII", RESULT_MAGIC, 1, 1, 0, 1, 0) + struct.pack("<BH", 99, 0)
with self.assertRaises(ValueError):
decode_result_collection(corrupt)
def test_invalid_utf8_name(self) -> None:
corrupt = struct.pack("<IQQQII", RESULT_MAGIC, 1, 1, 0, 1, 0) + struct.pack("<BH", 2, 1) + b"\xff"
with self.assertRaises(ValueError):
decode_result_collection(corrupt)
class _RingTestCase(unittest.TestCase):
"""Base case that creates/cleans named /dev/shm rings."""
def _ring_name(self, suffix: str = "") -> str:
return f"/radar_test_{self._testMethodName}{suffix}"
def _writer(self, name: str, capacity: int, slot_size: int) -> ShmRingWriter:
with suppress(OSError):
(Path("/dev/shm") / name[1:]).unlink() # drop a leftover from a crashed run
writer = ShmRingWriter(name, capacity, slot_size)
self.addCleanup(self._cleanup, name, writer)
return writer
def _reader(self, name: str, **kw: object) -> ShmRingReader:
reader = ShmRingReader(name, **kw)
self.addCleanup(self._safe_close, reader)
return reader
@staticmethod
def _safe_close(obj: object) -> None:
with suppress(Exception):
obj.close() # type: ignore[attr-defined]
@staticmethod
def _cleanup(name: str, writer: ShmRingWriter) -> None:
with suppress(Exception):
writer.close()
with suppress(OSError):
(Path("/dev/shm") / name[1:]).unlink()
class RingRoundTripTest(_RingTestCase):
def test_fifo_round_trip(self) -> None:
name = self._ring_name()
writer = self._writer(name, capacity=8, slot_size=64)
reader = self._reader(name)
payloads = [f"frame{i}".encode() for i in range(5)]
for p in payloads:
self.assertTrue(writer.push(p))
self.assertEqual([reader.pop_payload() for _ in payloads], payloads)
self.assertIsNone(reader.pop_payload()) # empty afterwards
def test_oversized_payload_rejected(self) -> None:
writer = self._writer(self._ring_name(), capacity=4, slot_size=8)
self.assertFalse(writer.push(b"x" * 9)) # larger than the slot
class RingOverflowTest(_RingTestCase):
def test_latest_wins_drops_oldest(self) -> None:
name = self._ring_name()
writer = self._writer(name, capacity=4, slot_size=64)
reader = self._reader(name)
for i in range(7): # 3 more than capacity
self.assertTrue(writer.push(f"f{i}".encode()))
# The 4 newest survive; the 3 oldest were overwritten.
survivors = []
while (item := reader.pop_payload()) is not None:
survivors.append(item)
self.assertEqual(survivors, [b"f3", b"f4", b"f5", b"f6"])
class PeekLatestTest(_RingTestCase):
def test_peek_is_latest_and_non_consuming(self) -> None:
name = self._ring_name()
writer = self._writer(name, capacity=8, slot_size=64)
reader = self._reader(name)
self.assertIsNone(reader.peek_latest_payload()) # nothing published yet
for i in range(3):
writer.push(f"f{i}".encode())
self.assertEqual(reader.peek_latest_payload(), b"f2") # newest
self.assertEqual(reader.peek_latest_payload(), b"f2") # stable, not consumed
self.assertEqual(reader.pop_payload(), b"f0") # consumer cursor untouched
writer.push(b"f3")
self.assertEqual(reader.peek_latest_payload(), b"f3") # follows the newest
class RingOpenTest(_RingTestCase):
def test_missing_ring_fails_fast(self) -> None:
with self.assertRaises(FileNotFoundError):
ShmRingReader("/radar_test_definitely_missing", open_timeout_s=0.1, open_poll_s=0.02)
def test_incompatible_header_rejected(self) -> None:
name = "/radar_test_bad_header"
path = Path("/dev/shm") / name[1:]
path.write_bytes(b"\x00" * 128) # right size, wrong magic
self.addCleanup(lambda: path.unlink(missing_ok=True))
with self.assertRaises(RuntimeError):
ShmRingReader(name)
if __name__ == "__main__":
unittest.main()
+212
View File
@@ -0,0 +1,212 @@
"""Storage + embedded WebUI tests.
Pins the agreed semantics:
* NPZ set persistence round-trips traces + metadata (float32 wire precision);
* vna_history export fails loud (ValueError) when no matching traces / bad stage;
* the web bridge rejects unknown live-settings fields (HTTP 400), serves immutable
snapshot copies, and forwards controls as Qt signals;
* the web fan-out is latest-wins — a full client queue drops its oldest frame.
"""
from __future__ import annotations
import asyncio
import os
import tempfile
import unittest
from pathlib import Path
from unittest import mock
import numpy as np
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") # before any Qt import
from PyQt6.QtWidgets import QApplication # noqa: E402
from python_app.gui.controllers.app_window_web_mixin import ( # noqa: E402
AppWindowWebController,
AppWindowWebMixin,
)
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData # noqa: E402
from python_app.storage.npz.store import NpzStore # noqa: E402
from python_app.storage.npz.vna_history_json import ( # noqa: E402
_complex_to_points,
_normalize_channel,
build_vna_history_payload,
)
from python_app.webui.streaming import RingBroadcaster # noqa: E402
def setUpModule() -> None:
global _app
_app = QApplication.instance() or QApplication([])
def _trace(in_pos: int, out_pos: int) -> TraceData:
return TraceData(
combo=ComboKey(input=in_pos, output=out_pos),
frequency_hz=np.array([1.0, 2.0], dtype=np.float32),
s11=np.array([1 + 1j, 2 + 2j], dtype=np.complex64),
s21=np.array([3 + 3j, 4 - 4j], dtype=np.complex64),
)
# --------------------------------------------------------------------------- #
# NPZ set persistence
# --------------------------------------------------------------------------- #
class NpzStoreRoundTripTest(unittest.TestCase):
def setUp(self) -> None:
self._dir = tempfile.TemporaryDirectory()
self.addCleanup(self._dir.cleanup)
self.store = NpzStore(Path(self._dir.name))
def test_save_then_load_round_trips_traces_and_metadata(self) -> None:
col = SweepCollection(collection_id=7, monotonic_ns=11, traces=[_trace(0, 0), _trace(1, 0)],
capture_start_ns=100, capture_end_ns=200)
self.store.save_set("calibration", "radar1", "set1", col)
loaded = self.store.load_set("calibration", "radar1", "set1")
self.assertEqual(loaded.collection_id, 7)
self.assertEqual((loaded.capture_start_ns, loaded.capture_end_ns), (100, 200))
self.assertEqual(len(loaded.traces), 2)
by_combo = {(t.combo.input, t.combo.output): t for t in loaded.traces}
self.assertTrue(np.array_equal(by_combo[(0, 0)].s21, _trace(0, 0).s21))
self.assertTrue(np.array_equal(by_combo[(1, 0)].frequency_hz, _trace(1, 0).frequency_hz))
def test_set_appears_in_listing_and_leaves_no_tmp(self) -> None:
self.store.save_set("calibration", "radar1", "set1", SweepCollection(collection_id=1, monotonic_ns=1,
traces=[_trace(0, 0)]))
self.assertIn("set1", self.store.list_sets("calibration", "radar1"))
leftover = list(Path(self._dir.name).rglob("*.tmp"))
self.assertEqual(leftover, [])
def test_load_missing_set_raises(self) -> None:
with self.assertRaises(Exception):
self.store.load_set("calibration", "radar1", "absent")
# --------------------------------------------------------------------------- #
# vna_history export
# --------------------------------------------------------------------------- #
class VnaHistoryTest(unittest.TestCase):
def _sweeps(self, *combos) -> list[SweepCollection]:
return [SweepCollection(collection_id=1, monotonic_ns=1, traces=[_trace(i, o) for i, o in combos])]
def test_builds_payload_for_matching_combo(self) -> None:
payload = build_vna_history_payload([], self._sweeps((0, 0)), [], input_index=0, output_index=0)
self.assertEqual(payload["input_index"], 0)
self.assertEqual(payload["channel"], "s21")
self.assertEqual(payload["preprocessed_record_count"], 1)
self.assertTrue(payload["sweep_history"])
def test_no_matching_traces_raises(self) -> None:
with self.assertRaises(ValueError):
build_vna_history_payload([], self._sweeps((0, 0)), [], input_index=9, output_index=9)
def test_invalid_primary_stage_raises(self) -> None:
with self.assertRaisesRegex(ValueError, "primary_stage"):
build_vna_history_payload([], self._sweeps((0, 0)), [], input_index=0, output_index=0, primary_stage="x")
def test_normalize_channel(self) -> None:
self.assertEqual(_normalize_channel("S21"), "s21")
self.assertEqual(_normalize_channel(" s11 "), "s11")
with self.assertRaises(ValueError):
_normalize_channel("s99")
def test_complex_to_points(self) -> None:
self.assertEqual(_complex_to_points(np.array([1 + 2j, 3 - 4j])), [[1.0, 2.0], [3.0, -4.0]])
# --------------------------------------------------------------------------- #
# Web bridge controller
# --------------------------------------------------------------------------- #
class WebControllerTest(unittest.TestCase):
def setUp(self) -> None:
self.controller = AppWindowWebController()
self.addCleanup(self.controller.deleteLater)
def test_rejects_unknown_field(self) -> None:
with self.assertRaisesRegex(ValueError, "Unknown live-settings"):
self.controller.apply_live_settings({"definitely_not_a_field": 1})
def test_known_field_emits_and_returns_snapshot(self) -> None:
received: list[dict] = []
self.controller.apply_settings_requested.connect(received.append)
out = self.controller.apply_live_settings({"gpr_min_visible_score": 0.5})
self.assertEqual(received, [{"gpr_min_visible_score": 0.5}])
self.assertIsInstance(out, list)
def test_snapshot_is_replaced_and_returned_as_copy(self) -> None:
self.controller.update_snapshot(status={"running": True}, live_settings=[{"name": "x"}], frame={"seq": 1})
status = self.controller.status()
self.assertEqual(status, {"running": True})
status["running"] = False # mutating the copy must not affect the controller
self.assertTrue(self.controller.status()["running"])
self.assertEqual(self.controller.peek_frame(), {"seq": 1})
def test_frame_only_updates_when_present(self) -> None:
self.controller.update_snapshot(status={}, live_settings=[], frame={"seq": 1})
self.controller.update_snapshot(status={}, live_settings=[], frame=None) # no new grab
self.assertEqual(self.controller.peek_frame(), {"seq": 1}) # keeps the last frame
def test_controls_emit_signals(self) -> None:
fired: list[str] = []
self.controller.start_requested.connect(lambda: fired.append("start"))
self.controller.stop_requested.connect(lambda: fired.append("stop"))
self.controller.single_capture_requested.connect(lambda: fired.append("single"))
self.controller.capture_requested.connect(lambda: fired.append("capture"))
self.controller.start()
self.controller.stop()
self.controller.single_capture()
self.controller.capture_tmp_reference()
self.assertEqual(fired, ["start", "stop", "single", "capture"])
class WebPortTest(unittest.TestCase):
def _port_with_env(self, value: str | None) -> int:
env = {} if value is None else {"RADAR_SYSTEM_WEBUI_PORT": value}
with mock.patch.dict(os.environ, env, clear=False):
if value is None:
os.environ.pop("RADAR_SYSTEM_WEBUI_PORT", None)
return AppWindowWebMixin._web_ui_port()
def test_default_when_unset_or_invalid(self) -> None:
self.assertEqual(self._port_with_env(None), 8080)
self.assertEqual(self._port_with_env("abc"), 8080)
self.assertEqual(self._port_with_env("99999"), 8080) # out of range
self.assertEqual(self._port_with_env("0"), 8080)
def test_valid_port(self) -> None:
self.assertEqual(self._port_with_env("9000"), 9000)
# --------------------------------------------------------------------------- #
# Latest-wins fan-out
# --------------------------------------------------------------------------- #
class RingBroadcasterFanOutTest(unittest.TestCase):
def test_full_client_queue_drops_oldest(self) -> None:
async def scenario() -> None:
broadcaster = RingBroadcaster(controller=object())
queue: asyncio.Queue = asyncio.Queue(maxsize=1)
broadcaster.register(queue)
broadcaster._publish({"type": "frame", "seq": 1})
broadcaster._publish({"type": "frame", "seq": 2}) # evicts seq 1
self.assertEqual(queue.qsize(), 1)
self.assertEqual(queue.get_nowait(), {"type": "frame", "seq": 2}) # newest survives
asyncio.run(scenario())
def test_unregister_stops_delivery(self) -> None:
async def scenario() -> None:
broadcaster = RingBroadcaster(controller=object())
queue: asyncio.Queue = asyncio.Queue(maxsize=4)
broadcaster.register(queue)
broadcaster.unregister(queue)
broadcaster.unregister(queue) # idempotent
broadcaster._publish({"type": "frame", "seq": 1})
self.assertEqual(queue.qsize(), 0)
asyncio.run(scenario())
if __name__ == "__main__":
unittest.main()