added data saving feature
This commit is contained in:
@@ -167,6 +167,55 @@ class SweepProcessorTest(unittest.TestCase):
|
||||
self.assertTrue(np.all(np.isfinite(result.real)))
|
||||
self.assertTrue(np.all(np.isfinite(result.imag)))
|
||||
|
||||
# -- cross-sweep branch tracking ------------------------------------------
|
||||
|
||||
def test_align_phase_branch_anchors_first_sweep_to_calibration(self) -> None:
|
||||
# No previous anchor yet -> snap onto the branch nearest phase0 (=0 here).
|
||||
processor = self._processor()
|
||||
phase = np.array([0.05, 1.0, 2.0]) + 2.0 * np.pi # one turn above phase0
|
||||
aligned, anchor = processor._align_phase_branch(phase)
|
||||
np.testing.assert_allclose(aligned, np.array([0.05, 1.0, 2.0]), atol=1e-9)
|
||||
self.assertAlmostEqual(anchor, 0.05, places=6)
|
||||
|
||||
def test_align_phase_branch_snaps_to_previous_anchor(self) -> None:
|
||||
# A genuine sub-pi float is preserved; a full-turn anchor wrap is undone.
|
||||
processor = self._processor()
|
||||
processor._previous_anchor_rad = 0.05
|
||||
kept, kept_anchor = processor._align_phase_branch(np.array([0.40, 1.4, 2.4]))
|
||||
np.testing.assert_allclose(kept, np.array([0.40, 1.4, 2.4]), atol=1e-9) # <pi: untouched
|
||||
self.assertAlmostEqual(kept_anchor, 0.40, places=6)
|
||||
wrapped, wrapped_anchor = processor._align_phase_branch(np.array([0.05, 1.0, 2.0]) - 2.0 * np.pi)
|
||||
np.testing.assert_allclose(wrapped, np.array([0.05, 1.0, 2.0]), atol=1e-9) # turn undone
|
||||
self.assertAlmostEqual(wrapped_anchor, 0.05, places=6)
|
||||
|
||||
def test_rejected_sweep_does_not_update_branch_tracker(self) -> None:
|
||||
# The anchor is committed only on accepted sweeps, so a rejected sweep
|
||||
# cannot latch the tracker onto a wrong branch.
|
||||
processor = self._processor()
|
||||
covering = _reference(np.linspace(0.0, 100.0, 401))
|
||||
self.assertIsNotNone(processor.process(np.abs(covering).astype(np.complex128), covering))
|
||||
anchor_after_accept = processor._previous_anchor_rad
|
||||
self.assertIsNotNone(anchor_after_accept)
|
||||
short = _reference(np.linspace(0.0, 40.0, 201)) # does not span the band
|
||||
self.assertIsNone(processor.process(np.ones(201, dtype=np.complex128), short))
|
||||
self.assertEqual(processor._previous_anchor_rad, anchor_after_accept)
|
||||
|
||||
def test_cross_sweep_unwrap_recovers_continuous_anchor_across_a_wrap(self) -> None:
|
||||
# Two physically adjacent sweeps whose anchor straddles +pi: np.angle wraps
|
||||
# the second's anchor by ~2*pi, but the cross-sweep tracking must recover
|
||||
# the continuous value (~3.3), not the wrapped one (~-2.98).
|
||||
processor = self._processor()
|
||||
ramp_home = np.linspace(3.0, 80.0, 401) # anchor 3.0 (< pi), covers band
|
||||
ramp_drift = np.linspace(3.3, 80.3, 401) # anchor 3.3 (> pi) -> angle wraps
|
||||
ref_home = _reference(ramp_home)
|
||||
ref_drift = _reference(ramp_drift)
|
||||
self.assertIsNotNone(processor.process(np.abs(ref_home).astype(np.complex128), ref_home))
|
||||
self.assertAlmostEqual(processor._previous_anchor_rad, 3.0, places=2)
|
||||
self.assertIsNotNone(processor.process(np.abs(ref_drift).astype(np.complex128), ref_drift))
|
||||
# Without correction this would be ~-2.98 (one turn below); corrected it
|
||||
# continues smoothly from 3.0 to ~3.3.
|
||||
self.assertAlmostEqual(processor._previous_anchor_rad, 3.3, places=2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Tests for the streaming disk-recording mixin.
|
||||
|
||||
The recorder pairs each result with its raw/preprocessed collection by id, buffers a
|
||||
small chunk, and flushes it to a stream writer — so memory stays flat regardless of how
|
||||
many measurements are recorded. These tests pin that behaviour (chunking, finish-at-N,
|
||||
arm guards, only-new gating) against light stubs of the AppWindow collaborators — no Qt,
|
||||
hardware, or disk needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from python_app.gui.controllers import app_window_recording_mixin as rec
|
||||
from python_app.gui.controllers.app_window_recording_mixin import AppWindowRecordingMixin
|
||||
|
||||
|
||||
class _Collection:
|
||||
def __init__(self, collection_id: int, monotonic_ns: int = 0) -> None:
|
||||
self.collection_id = collection_id
|
||||
self.monotonic_ns = monotonic_ns
|
||||
|
||||
|
||||
class _Spin:
|
||||
def __init__(self, value: int) -> None:
|
||||
self._value = value
|
||||
|
||||
def value(self) -> int:
|
||||
return self._value
|
||||
|
||||
|
||||
class _Text:
|
||||
def __init__(self, value: str) -> None:
|
||||
self._value = value
|
||||
|
||||
def text(self) -> str:
|
||||
return self._value
|
||||
|
||||
|
||||
class _Supervisor:
|
||||
def __init__(self, running: bool) -> None:
|
||||
self._running = running
|
||||
|
||||
def is_running(self) -> bool:
|
||||
return self._running
|
||||
|
||||
|
||||
class _FakePath:
|
||||
def __init__(self, exists: bool) -> None:
|
||||
self._exists = exists
|
||||
|
||||
def exists(self) -> bool:
|
||||
return self._exists
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "/tmp/dataset"
|
||||
|
||||
|
||||
class _FakeWriter:
|
||||
def __init__(self) -> None:
|
||||
self.directory = Path("/tmp/dataset")
|
||||
self.appends: list[tuple[int, int, int]] = []
|
||||
self.result_count = 0
|
||||
|
||||
def append(self, raw, preprocessed, results) -> None:
|
||||
self.appends.append((len(raw), len(preprocessed), len(results)))
|
||||
self.result_count += len(results)
|
||||
|
||||
|
||||
class _FakeStore:
|
||||
def __init__(self, *, exists: bool = False) -> None:
|
||||
self._exists = exists
|
||||
self.created: list[tuple] = []
|
||||
self.writer: _FakeWriter | None = None
|
||||
|
||||
def create_snapshot_stream(self, root, name, *, name_prefix=""):
|
||||
if self._exists:
|
||||
raise FileExistsError(f"Snapshot directory already exists: {root}/{name}")
|
||||
self.created.append((root, name, name_prefix))
|
||||
self.writer = _FakeWriter()
|
||||
return self.writer
|
||||
|
||||
|
||||
class _Harness(AppWindowRecordingMixin):
|
||||
def __init__(self, *, count, running, dest_exists=False, store_exists=False) -> None:
|
||||
self._init_recording_state()
|
||||
self._record_count = _Spin(count)
|
||||
self._supervisor = _Supervisor(running)
|
||||
self._store = _FakeStore(exists=store_exists)
|
||||
self._save_path_input = _Text("/tmp")
|
||||
self._save_name_input = _Text("run")
|
||||
self._dest_exists = dest_exists
|
||||
self.started = False
|
||||
self.errors: list[str] = []
|
||||
self.exceptions: list[tuple] = []
|
||||
self.logs: list[str] = []
|
||||
self.profiles: list[tuple] = []
|
||||
|
||||
def _snapshot_destination_dir(self):
|
||||
return _FakePath(self._dest_exists)
|
||||
|
||||
def _radar_config_name_prefix(self) -> str:
|
||||
return "pref"
|
||||
|
||||
def _snapshot_config_profile_path(self, directory):
|
||||
return Path(directory) / "config_profile.json"
|
||||
|
||||
def _write_gui_profile_to_path(self, path, allow_overwrite) -> None:
|
||||
self.profiles.append((path, allow_overwrite))
|
||||
|
||||
def _start_run(self) -> None:
|
||||
self.started = True
|
||||
|
||||
def _show_error(self, message, *, details=None) -> None:
|
||||
self.errors.append(message)
|
||||
|
||||
def _show_exception(self, context, exc) -> None:
|
||||
self.exceptions.append((context, exc))
|
||||
|
||||
def _log(self, message) -> None:
|
||||
self.logs.append(message)
|
||||
|
||||
# convenience for tests: stamp collections just after the arm instant so the
|
||||
# "only record sweeps produced after arming" gate accepts them.
|
||||
def feed(self, collection_id: int, *, ns: int | None = None) -> None:
|
||||
if ns is None:
|
||||
ns = self._recording_since_ns + 1
|
||||
self._record_collection("raw", _Collection(collection_id, ns))
|
||||
self._record_collection("preprocessed", _Collection(collection_id, ns))
|
||||
self._record_collection("results", _Collection(collection_id, ns))
|
||||
|
||||
|
||||
class _RecordingTestBase(unittest.TestCase):
|
||||
def _make(self, **kwargs) -> _Harness:
|
||||
harness = _Harness(**kwargs)
|
||||
self.addCleanup(harness._shutdown_recording) # never leak the writer thread
|
||||
return harness
|
||||
|
||||
def _drain_and_finalize(self, harness: _Harness) -> None:
|
||||
"""Wait for the writer thread to flush all chunks, then finalize on the GUI side."""
|
||||
thread = harness._recording_thread
|
||||
self.assertIsNotNone(thread)
|
||||
thread.join(timeout=2.0)
|
||||
self.assertFalse(thread.is_alive(), "writer thread did not drain")
|
||||
harness._poll_recording_writer()
|
||||
|
||||
|
||||
class RecordingArmTest(_RecordingTestBase):
|
||||
def test_arm_starts_a_stopped_run_and_opens_a_stream(self) -> None:
|
||||
h = self._make(count=3, running=False)
|
||||
h._start_run_with_recording()
|
||||
self.assertTrue(h.started)
|
||||
self.assertTrue(h._recording_active)
|
||||
self.assertEqual(h._recording_target, 3)
|
||||
self.assertEqual(len(h._store.created), 1) # one stream opened
|
||||
self.assertEqual(h.profiles[0][1], False) # config profile written, no overwrite
|
||||
|
||||
def test_arm_does_not_restart_a_running_pipeline(self) -> None:
|
||||
h = self._make(count=3, running=True)
|
||||
h._start_run_with_recording()
|
||||
self.assertFalse(h.started)
|
||||
self.assertTrue(h._recording_active)
|
||||
|
||||
def test_arm_refuses_when_destination_exists(self) -> None:
|
||||
h = self._make(count=3, running=True, dest_exists=True)
|
||||
h._start_run_with_recording()
|
||||
self.assertFalse(h._recording_active)
|
||||
self.assertEqual(len(h._store.created), 0)
|
||||
self.assertEqual(len(h.errors), 1)
|
||||
|
||||
def test_second_arm_while_recording_is_refused(self) -> None:
|
||||
h = self._make(count=5, running=True)
|
||||
h._start_run_with_recording()
|
||||
h._start_run_with_recording() # already in progress
|
||||
self.assertEqual(len(h._store.created), 1) # no second stream
|
||||
self.assertEqual(len(h.errors), 1)
|
||||
self.assertTrue(h._recording_active)
|
||||
|
||||
|
||||
class RecordingStreamTest(_RecordingTestBase):
|
||||
def test_ignores_collections_from_before_arming(self) -> None:
|
||||
h = self._make(count=2, running=True)
|
||||
h._start_run_with_recording()
|
||||
arm = h._recording_since_ns
|
||||
h._record_collection("results", _Collection(1, monotonic_ns=arm - 1))
|
||||
self.assertEqual(h._recording_status()["collected"], 0)
|
||||
self.assertIsNotNone(h._store.writer)
|
||||
self.assertEqual(h._store.writer.result_count, 0)
|
||||
|
||||
def test_streams_one_chunk_and_finishes_at_target(self) -> None:
|
||||
h = self._make(count=3, running=True)
|
||||
h._start_run_with_recording()
|
||||
writer = h._store.writer
|
||||
for cid in (1, 2, 3):
|
||||
h.feed(cid)
|
||||
self._drain_and_finalize(h)
|
||||
self.assertEqual(writer.appends, [(3, 3, 3)]) # one flush at target
|
||||
self.assertEqual(writer.result_count, 3)
|
||||
self.assertFalse(h._recording_active) # disarmed after the writer drained
|
||||
|
||||
def test_flushes_in_chunks_so_memory_stays_flat(self) -> None:
|
||||
with mock.patch.object(rec, "_RECORDING_CHUNK_SIZE", 2):
|
||||
h = self._make(count=5, running=True)
|
||||
h._start_run_with_recording()
|
||||
writer = h._store.writer
|
||||
for cid in range(1, 6):
|
||||
h.feed(cid)
|
||||
self._drain_and_finalize(h)
|
||||
# 2 + 2 + 1: flushed at the chunk boundaries and the final remainder.
|
||||
self.assertEqual(writer.appends, [(2, 2, 2), (2, 2, 2), (1, 1, 1)])
|
||||
self.assertEqual(writer.result_count, 5)
|
||||
self.assertFalse(h._recording_active)
|
||||
|
||||
def test_does_not_record_beyond_target(self) -> None:
|
||||
h = self._make(count=2, running=True)
|
||||
h._start_run_with_recording()
|
||||
writer = h._store.writer
|
||||
for cid in (1, 2, 3, 4): # 4 results, target 2
|
||||
h.feed(cid)
|
||||
self._drain_and_finalize(h)
|
||||
self.assertEqual(writer.result_count, 2)
|
||||
|
||||
def test_records_results_with_missing_raw_or_pre(self) -> None:
|
||||
h = self._make(count=1, running=True)
|
||||
h._start_run_with_recording()
|
||||
writer = h._store.writer
|
||||
# result with no matching raw/pre buffered (e.g. a dropped raw frame)
|
||||
h._record_collection("results", _Collection(9, monotonic_ns=h._recording_since_ns + 1))
|
||||
self._drain_and_finalize(h)
|
||||
self.assertEqual(writer.appends, [(0, 0, 1)])
|
||||
self.assertEqual(writer.result_count, 1)
|
||||
|
||||
def test_stop_flushes_the_partial_chunk(self) -> None:
|
||||
# Stop pressed before a chunk fills (and before the target): the buffered
|
||||
# measurements must be written, not lost.
|
||||
h = self._make(count=1000, running=True) # target large -> never reached here
|
||||
h._start_run_with_recording()
|
||||
writer = h._store.writer
|
||||
for cid in (1, 2, 3): # 3 < chunk size and < target -> buffered, not yet flushed
|
||||
h.feed(cid)
|
||||
self.assertEqual(writer.appends, []) # nothing flushed yet
|
||||
h._finalize_recording_on_stop() # Stop pressed
|
||||
self.assertEqual(writer.appends, [(3, 3, 3)]) # partial chunk written
|
||||
self.assertEqual(writer.result_count, 3)
|
||||
self.assertFalse(h._recording_active) # recording ended
|
||||
|
||||
def test_stop_when_idle_is_a_noop(self) -> None:
|
||||
h = self._make(count=5, running=True)
|
||||
h._finalize_recording_on_stop() # nothing armed
|
||||
self.assertEqual(len(h._store.created), 0)
|
||||
self.assertFalse(h._recording_active)
|
||||
|
||||
def test_pending_maps_are_bounded(self) -> None:
|
||||
with mock.patch.object(rec, "_RECORDING_MAX_PENDING", 4):
|
||||
h = self._make(count=1000, running=True)
|
||||
h._start_run_with_recording()
|
||||
for cid in range(50): # raws whose results never arrive
|
||||
h._record_collection("raw", _Collection(cid, monotonic_ns=h._recording_since_ns + 1))
|
||||
self.assertLessEqual(len(h._recording_pending_raw), 4)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -34,6 +34,7 @@ from python_app.storage.npz.vna_history_json import ( # noqa: E402
|
||||
_normalize_channel,
|
||||
build_vna_history_payload,
|
||||
)
|
||||
from python_app.webui.controller import WebActionError # noqa: E402
|
||||
from python_app.webui.streaming import RingBroadcaster # noqa: E402
|
||||
|
||||
|
||||
@@ -178,21 +179,48 @@ class WebControllerTest(unittest.TestCase):
|
||||
self.assertEqual(self.controller.peek_frame(), {"seq": 1}) # keeps the last frame
|
||||
|
||||
def test_controls_emit_signals(self) -> None:
|
||||
# Controls are synchronous now: each emits a call the GUI slot must finalize.
|
||||
# In-thread, emit() runs the slot directly, so finalizing it returns at once.
|
||||
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"))
|
||||
|
||||
def handler(label):
|
||||
def slot(call):
|
||||
fired.append(label)
|
||||
call.done.set()
|
||||
return slot
|
||||
|
||||
self.controller.start_requested.connect(handler("start"))
|
||||
self.controller.stop_requested.connect(handler("stop"))
|
||||
self.controller.single_capture_requested.connect(handler("single"))
|
||||
self.controller.capture_requested.connect(handler("capture"))
|
||||
self.controller.start()
|
||||
self.controller.stop()
|
||||
self.controller.single_capture()
|
||||
self.controller.capture_tmp_reference()
|
||||
self.assertEqual(fired, ["start", "stop", "single", "capture"])
|
||||
|
||||
def test_action_error_is_raised_to_the_caller(self) -> None:
|
||||
# An error the GUI slot records on the call surfaces as WebActionError (HTTP 400).
|
||||
self.controller.start_requested.connect(
|
||||
lambda call: (setattr(call, "error", "destination already exists"), call.done.set())
|
||||
)
|
||||
with self.assertRaisesRegex(WebActionError, "destination already exists"):
|
||||
self.controller.start()
|
||||
|
||||
def test_start_recording_forwards_path_name_count(self) -> None:
|
||||
received: list[tuple[str, str, int]] = []
|
||||
self.controller.start_recording_requested.connect(
|
||||
lambda p, n, c, call: (received.append((p, n, c)), call.done.set())
|
||||
)
|
||||
self.controller.start_recording("/tmp/out", "run1", 250)
|
||||
self.assertEqual(received, [("/tmp/out", "run1", 250)])
|
||||
|
||||
def test_lists_configs_sorted_and_load_emits_signal(self) -> None:
|
||||
self.assertEqual(self.controller.list_configs(), ["alpha.json", "beta.json"])
|
||||
requested: list[str] = []
|
||||
self.controller.load_config_requested.connect(requested.append)
|
||||
self.controller.load_config_requested.connect(
|
||||
lambda name, call: (requested.append(name), call.done.set())
|
||||
)
|
||||
self.controller.load_config("beta.json")
|
||||
self.assertEqual(requested, ["beta.json"])
|
||||
|
||||
@@ -204,7 +232,9 @@ class WebControllerTest(unittest.TestCase):
|
||||
|
||||
def test_save_dataset_forwards_path_and_name(self) -> None:
|
||||
received: list[tuple[str, str]] = []
|
||||
self.controller.save_dataset_requested.connect(lambda p, n: received.append((p, n)))
|
||||
self.controller.save_dataset_requested.connect(
|
||||
lambda p, n, call: (received.append((p, n)), call.done.set())
|
||||
)
|
||||
self.controller.save_dataset("/tmp/out", "run1")
|
||||
self.assertEqual(received, [("/tmp/out", "run1")])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user