Files
radar_system/python_app/tests/test_processing.py
T
2026-10-08 16:24:01 +03:00

182 lines
8.5 KiB
Python

"""GPR payload lookup, B-scan transforms and live-setting normalization."""
from __future__ import annotations
import json
import math
import unittest
from collections import deque
from collections.abc import Iterable, Sequence
import numpy as np
from python_app.gui.controllers.renderers.bscan import (
apply_mean_ascan_subtraction,
bscan_levels,
bscan_lookup_table,
rebuild_bscan_history_from_results,
)
from python_app.gui.controllers.renderers.common import build_lut
from python_app.gui.controllers.renderers.gpr import normalized_display_range, display_y_min, object_label_candidates
from python_app.models.dataset_model import (
ComboKey,
ResultBlock,
ResultCollection,
ResultPayload,
)
from python_app.gui.result_payloads import (
collection_payload_by_name,
gpr_object_rows,
)
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
def _table(name: str, rows: Sequence[Sequence[float]]) -> ResultPayload:
return ResultPayload(processing_name=name, kind=4, table=np.asarray(rows, dtype=np.float32))
def _collection(payloads: Iterable[ResultPayload]) -> ResultCollection:
return ResultCollection(collection_id=1, monotonic_ns=1, collection_payloads=list(payloads))
class CollectionLookupTest(unittest.TestCase):
def test_payload_by_name_matches_name_and_optional_kind(self) -> None:
col = _collection([_table("gpr_points", [[0, 0, 1]])])
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
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_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))
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: tuple[int, int], depth: Sequence[float], amps: Sequence[float],
*, name: str = "bscan", kind: int = 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)
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)
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)
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_selection_is_positional_not_by_collection_id(self) -> None:
# Ids are neither dense nor monotonic across a run boundary, so the tail is
# taken by position only. Here the newest two entries carry the SMALLEST ids;
# an id-based cut-off would have dropped exactly them.
history = [
self._bscan_collection(cid, (0, 0), [1.0], [float(cid)])
for cid in (98, 99, 100, 1, 2)
]
by_combo, _ = rebuild_bscan_history_from_results(history, history_limit=3)
self.assertEqual(len(by_combo[(0, 0)]), 3)
self.assertEqual([sweep[0] for sweep in by_combo[(0, 0)]], [100.0, 1.0, 2.0])
class GprDisplayHelperTest(unittest.TestCase):
def test_normalized_range_orders_and_expands(self) -> None:
self.assertEqual(normalized_display_range(2.0, 5.0), (2.0, 5.0))
self.assertEqual(normalized_display_range(5.0, 2.0), (2.0, 5.0)) # reordered
low, high = 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(display_y_min(2.0, 5.0), 2.0) # surface not visible
self.assertAlmostEqual(display_y_min(0.0, 10.0), -0.3) # 3% of span
self.assertAlmostEqual(display_y_min(-1.0, 1.0), -1.06) # min 0.06 margin
def test_object_label_candidates_are_distinct_positions(self) -> None:
candidates = 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))
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(**json.loads('{"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()