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
+29
View File
@@ -0,0 +1,29 @@
"""Construct desktop fixtures without touching a running application's resources."""
from pathlib import Path
import json
import os
import tempfile
from unittest import mock
from python_app.gui.app_window import AppWindow
def isolated_window(add_cleanup) -> AppWindow:
temporary = tempfile.TemporaryDirectory(prefix="radar_gui_test_")
add_cleanup(temporary.cleanup)
root = Path(temporary.name)
source = Path(__file__).resolve().parents[2]
payload = json.loads((source / "run_config_examples/run_config_simulator.example.json").read_text(encoding="utf-8"))
for name, ring in payload["rings"].items():
ring["name"] = f"/{root.name}_{name}"
payload["run"]["processing_live_config_path"] = str(root / "python_app/runtime/processing_live.json")
payload["control_button"]["enabled"] = False
profile = root / "run_config.json"
profile.write_text(json.dumps(payload), encoding="utf-8")
with mock.patch.dict(os.environ, {
"RADAR_SYSTEM_PROFILE": str(profile), "RADAR_SYSTEM_AUTO_START": "0",
"RADAR_SYSTEM_AUTO_APPLY_RADAR": "0",
}), mock.patch.object(AppWindow, "_init_web_ui"):
window = AppWindow(root)
return window
+134
View File
@@ -0,0 +1,134 @@
"""Geometry edits preserve XYZ and reject stale or invalid processing results."""
from __future__ import annotations
from dataclasses import asdict
import os
from pathlib import Path
import unittest
from unittest import mock
import numpy as np
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtCore import QSignalBlocker
from PyQt6.QtWidgets import QApplication
from python_app.gui.antenna_geometry_editor import AntennaGeometryEditor
from python_app.tests.gui_test_support import isolated_window
from python_app.models.dataset_model import ResultCollection, ResultPayload
from python_app.models.gui_profile_model import GuiProfileModel
from python_app.models.run_config_model import GprTxGeometryModel, GprRxGeometryModel
class AntennaGeometryTest(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.app = QApplication.instance() or QApplication([])
def test_coordinates_keep_full_precision_and_default_missing_axes(self) -> None:
entry = GprTxGeometryModel(output_pos=2, x_m=0.12345678912345, y_m=-2.5, z_m=3.5)
editor = AntennaGeometryEditor("output_pos", [entry], position_count=4)
self.assertEqual(editor.records(), [asdict(entry)])
editor.set_records([{"output_pos": 1, "x_m": -1}])
self.assertEqual(editor.records(), [{"output_pos": 1, "x_m": -1.0, "y_m": 0.0, "z_m": 0.0}])
def test_invalid_edit_rolls_back_without_emitting_a_change(self) -> None:
editor = AntennaGeometryEditor("input_pos", [GprRxGeometryModel(input_pos=0, x_m=1.0)], position_count=2)
changed = mock.Mock()
editor.changed.connect(changed)
with mock.patch("python_app.gui.antenna_geometry_editor.QMessageBox.warning") as warning:
editor._table.item(0, 1).setText("nan")
warning.assert_called_once()
changed.assert_not_called()
self.assertEqual(editor.records()[0]["x_m"], 1.0)
def test_full_table_disables_add_and_removal_frees_a_position(self) -> None:
editor = AntennaGeometryEditor("input_pos", [GprRxGeometryModel(input_pos=0, x_m=1.0)], position_count=1)
self.assertFalse(editor._add_button.isEnabled())
editor._add_row()
self.assertEqual(len(editor.records()), 1)
editor._table.selectRow(0)
editor._remove_button.click()
self.assertEqual(editor.records(), [])
self.assertTrue(editor._add_button.isEnabled())
editor._add_button.click()
self.assertEqual(editor.records()[0]["input_pos"], 0)
def test_web_geometry_rejects_duplicates_and_out_of_range_before_mutating(self) -> None:
editor = AntennaGeometryEditor("output_pos", [], position_count=2)
for records in ([{"output_pos": 2}], [{"output_pos": 0}, {"output_pos": 0}],
[{"output_pos": True}], [{"output_pos": 0, "x_m": True}]):
with self.subTest(records=records), self.assertRaises(ValueError):
editor.set_records(records)
self.assertEqual(editor.records(), [])
class WindowGeometryTest(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.app = QApplication.instance() or QApplication([])
cls.window = isolated_window(cls.addClassCleanup)
cls.window._timer.stop()
cls.window._shutdown_web_ui()
cls.initial_config = cls.window._build_config()
@classmethod
def tearDownClass(cls) -> None:
cls.window.close()
def setUp(self) -> None:
self.w = self.window
self.w._apply_loaded_profile(GuiProfileModel(run_config=self.initial_config.clone()), Path("run_config.json"))
def test_profile_and_live_config_preserve_all_coordinates(self) -> None:
config = self.initial_config.clone()
config.gpr.tx_geometry = [GprTxGeometryModel(output_pos=0, x_m=1.25, y_m=-2.0, z_m=3.0)]
config.gpr.rx_geometry = [GprRxGeometryModel(input_pos=0, x_m=-1.25, y_m=4.0, z_m=-5.0)]
self.w._apply_loaded_profile(GuiProfileModel(run_config=config), Path("run_config.json"))
rebuilt = self.w._build_config()
self.assertEqual(rebuilt.gpr, config.gpr)
live = self.w._live_processing_config().to_dict()
self.assertEqual(live["tx_geometry"], [asdict(entry) for entry in config.gpr.tx_geometry])
self.assertEqual(live["rx_geometry"], [asdict(entry) for entry in config.gpr.rx_geometry])
schema = self.w._web_settings_schema()
self.assertFalse(any(entry.get("applies_on_start") for entry in schema))
def test_malformed_web_batch_restores_every_changed_widget(self) -> None:
original_gain = self.w._bscan_gain.value()
original_mode = self.w._processing_mode.currentText()
with mock.patch.object(self.w, "_show_exception") as error, mock.patch.object(self.w._live_config_writer, "write") as write:
self.w._apply_web_live_settings({
"processor_mode": "bscan", "bscan_gain": original_gain + 1,
"tx_geometry": [{"output_pos": 0}, {"output_pos": 0}],
})
error.assert_called_once()
write.assert_not_called()
self.assertEqual(self.w._bscan_gain.value(), original_gain)
self.assertEqual(self.w._processing_mode.currentText(), original_mode)
def test_stale_results_cannot_draw_over_new_geometry(self) -> None:
revision = (1 << 63) + 123456789
self.w._required_processing_revision = revision
with QSignalBlocker(self.w._processing_mode):
self.w._processing_mode.setCurrentText("gpr")
words = [(revision >> shift) & 0xffff for shift in range(0, 64, 16)]
fresh = ResultCollection(collection_id=1, monotonic_ns=1, collection_payloads=[ResultPayload(
processing_name="processing_revision", kind=4, table=np.asarray([words], dtype=np.float32),
)])
with mock.patch.object(self.w, "_draw_gpr_map", return_value=True) as draw:
self.assertFalse(self.w._draw_results(ResultCollection(collection_id=1, monotonic_ns=1)))
self.assertTrue(self.w._draw_results(fresh))
draw.assert_called_once_with(fresh)
self.assertFalse(self.w._result_collection_has_trace(fresh))
def test_invalid_or_mixed_history_commands_do_not_clear_data(self) -> None:
with mock.patch.object(self.w, "_show_exception") as error, mock.patch.object(self.w, "_clear_all_runtime_history") as clear:
self.w._apply_web_live_settings({"history_command": "bogus"})
self.w._apply_web_live_settings({"history_command": "clear_all", "bscan_gain": 2})
self.assertEqual(error.call_count, 2)
clear.assert_not_called()
if __name__ == "__main__":
unittest.main()
@@ -15,7 +15,6 @@ from __future__ import annotations
import os
import unittest
from pathlib import Path
import numpy as np
@@ -23,6 +22,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import QApplication # noqa: E402
from python_app.gui.app_window import AppWindow # noqa: E402
from python_app.tests.gui_test_support import isolated_window
from collections import deque # noqa: E402
from python_app.gui.runtime.history import record_result_history # noqa: E402
@@ -40,7 +40,7 @@ _window: AppWindow | None = None
def setUpModule() -> None:
global _app, _window
_app = QApplication.instance() or QApplication([])
_window = AppWindow(Path("."))
_window = isolated_window(unittest.addModuleCleanup)
def tearDownModule() -> None:
+2 -1
View File
@@ -26,6 +26,7 @@ from PyQt6.QtCore import QSignalBlocker # noqa: E402
from PyQt6.QtWidgets import QApplication # noqa: E402
from python_app.gui.app_window import AppWindow # noqa: E402
from python_app.tests.gui_test_support import isolated_window
from python_app.gui.runtime.history import collection_key # noqa: E402
from python_app.models.dataset_model import ( # noqa: E402
ComboKey,
@@ -50,7 +51,7 @@ _window: AppWindow | None = None
def setUpModule() -> None:
global _app, _window
_app = QApplication.instance() or QApplication([])
_window = AppWindow(Path("."))
_window = isolated_window(unittest.addModuleCleanup)
def tearDownModule() -> None:
+88
View File
@@ -0,0 +1,88 @@
"""Run firmware logic on the host; timing and physical I/O still need the board."""
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
PROJECT = Path(__file__).resolve().parents[2]
ARDUINO_STUB = r'''
#pragma once
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstring>
inline uint32_t test_time = 0;
inline int transmissions = 0;
inline uint32_t millis() { return test_time; }
inline int analogRead(uint32_t pin) { return pin == 0 ? 2014 : 1981; }
inline void analogReadResolution(int) {}
inline void pinMode(uint32_t, int) {}
inline float constrain(float v, float lo, float hi) { return std::clamp(v, lo, hi); }
struct SerialStub {
void begin(int) {}
template<class T> void print(T, int = 0) {}
template<class T> void println(T) {}
};
inline SerialStub Serial;
struct GPIO_InitTypeDef { int Pin, Mode, Pull, Speed, Alternate; };
struct UART_HandleTypeDef {
int Instance;
struct { int BaudRate, WordLength, StopBits, Parity, Mode, HwFlowCtl, OverSampling; } Init;
struct { int AdvFeatureInit, TxPinLevelInvert; } AdvancedInit;
};
enum { PA0, PA1, GPIO_PIN_9, GPIO_MODE_AF_PP, GPIO_NOPULL, GPIO_SPEED_FREQ_LOW,
GPIO_AF7_USART1, GPIOA, USART1, UART_WORDLENGTH_9B, UART_STOPBITS_2,
UART_PARITY_EVEN, UART_MODE_TX, UART_HWCONTROL_NONE, UART_OVERSAMPLING_16,
UART_ADVFEATURE_TXINVERT_INIT, UART_ADVFEATURE_TXINV_ENABLE, INPUT_ANALOG };
inline void __HAL_RCC_GPIOA_CLK_ENABLE() {}
inline void __HAL_RCC_USART1_CLK_ENABLE() {}
inline void HAL_GPIO_Init(int, GPIO_InitTypeDef*) {}
inline void HAL_UART_Init(UART_HandleTypeDef*) {}
inline void HAL_UART_Transmit(UART_HandleTypeDef*, uint8_t*, int, int) { ++transmissions; }
'''
@unittest.skipUnless(shutil.which('g++'), 'requires a host C++ compiler')
class CartFirmwareTest(unittest.TestCase):
def test_schedule_rollover_and_sbus_encoding(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / 'Arduino.h').write_text(ARDUINO_STUB)
source = root / 'check.cpp'
firmware = PROJECT / 'device_firmware/cart_firmware/src/main.cpp'
source.write_text(f'#include "{firmware}"\n' + r'''
#include <cassert>
int main() {
test_time = UINT32_MAX - 100;
loop(); assert(transmissions == 1);
test_time = 1;
loop(); assert(transmissions == 2);
test_time += 49;
loop(); assert(transmissions == 2);
++test_time;
loop(); assert(transmissions == 3);
assert(axisNorm(X_MID, X_FWD, X_MID, X_BACK) == 0);
assert(axisNorm(X_FWD, X_FWD, X_MID, X_BACK) == 1);
assert(driveToSbus(0, CH2_SPAN, CH2_DB, POWER_FWD, POWER_BACK) == SBUS_MID);
uint16_t channels[16];
for (int n = 0; n < 16; ++n) channels[n] = n * 127;
uint8_t packed[25]; sbusPack(packed, channels);
assert(packed[0] == 0x0f && packed[23] == 0 && packed[24] == 0);
for (int n = 0; n < 16; ++n) {
uint16_t value = 0;
for (int b = 0; b < 11; ++b) {
const int bit = n * 11 + b;
value |= ((packed[1 + bit / 8] >> (bit % 8)) & 1) << b;
}
assert(value == channels[n]);
}
}
''')
binary = root / 'check'
built = subprocess.run(['g++', '-std=c++20', '-Wall', '-Wextra', '-Werror', f'-I{root}',
str(source), '-o', str(binary)], capture_output=True, text=True, timeout=15)
self.assertEqual(built.returncode, 0, built.stderr)
subprocess.run([str(binary)], check=True, timeout=5)
+131
View File
@@ -0,0 +1,131 @@
"""Desktop channel navigation and fixed-size manual saves."""
from __future__ import annotations
import os
from pathlib import Path
from types import SimpleNamespace
import unittest
from unittest import mock
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 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
class ComboNavigationTest(unittest.TestCase):
@classmethod
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()
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")
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()
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_navigation_wraps_with_output_changing_first(self) -> None:
keys = self.w._available_display_combo_keys()
visited = [(0, 0)]
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.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")
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_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__":
unittest.main()
+66
View File
@@ -0,0 +1,66 @@
"""Exercise updates and launch guards using isolated files and command stubs."""
import os
from pathlib import Path
import subprocess
import tarfile
import tempfile
import unittest
PROJECT = Path(__file__).resolve().parents[2]
class DeploymentTest(unittest.TestCase):
def test_update_preserves_state_and_an_independent_rollback_environment(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
target = root / 'radar_system'
release = root / 'release/radar_system'
for tree in (target, release):
(tree / 'python_app').mkdir(parents=True)
(tree / 'run_config_examples').mkdir(parents=True)
(tree / 'run_config_examples/device.json').write_text(tree.name)
(tree / 'run_config.json').write_text(str(tree))
(release / 'start.sh').write_text('#!/bin/bash\n')
(target / 'run_config_examples/device.json').write_text('device settings')
(target / 'loose_recording.bin').write_bytes(b'original data')
(target / 'python_app/data/calibration').mkdir(parents=True)
(target / 'python_app/data/calibration/set.npz').write_bytes(b'calibration')
(target / 'python_app/runtime/logs').mkdir(parents=True)
(target / 'python_app/runtime/gui_session_state.json').write_text('session')
(target / 'python_app/runtime/logs/old.log').write_text('log')
(target / '.venv/bin').mkdir(parents=True)
(target / '.venv/state').write_text('old environment')
interpreter = target / '.venv/bin/python'
interpreter.write_text('''#!/bin/bash
if [[ "$1" == --version ]]; then echo Python-test; exit; fi
printf 'updated environment' > "$(dirname -- "$0")/../state"
''')
interpreter.chmod(0o755)
commands = root / 'commands'
commands.mkdir()
for name, text in {'systemctl': 'exit 3', 'make': 'printf "%s\\n" "$@" > "$RADAR_TEST_BUILD_LOG"'}.items():
path = commands / name
path.write_text('#!/bin/bash\n' + text + '\n')
path.chmod(0o755)
archive = root / 'release.tar.gz'
with tarfile.open(archive, 'w:gz') as handle:
handle.add(release, arcname='radar_system')
log = root / 'build.log'
completed = subprocess.run(
['bash', str(PROJECT / 'deploy/update.sh'), archive.name], cwd=root,
env={**os.environ, 'RADAR_DIR': str(target), 'PATH': f'{commands}:{os.environ["PATH"]}',
'RADAR_TEST_BUILD_LOG': str(log)}, capture_output=True, text=True, timeout=10,
)
self.assertEqual(completed.returncode, 0, completed.stdout + completed.stderr)
backup = root / 'radar_system1'
self.assertEqual((target / '.venv/state').read_text(), 'updated environment')
self.assertEqual((backup / '.venv/state').read_text(), 'old environment')
self.assertEqual((target / 'loose_recording.bin').read_bytes(), b'original data')
self.assertEqual((target / 'python_app/data/calibration/set.npz').read_bytes(), b'calibration')
self.assertEqual((target / 'run_config.json').read_text(), str(target))
self.assertEqual((target / 'run_config_examples/device.json').read_text(), 'device settings')
self.assertTrue((target / 'run_config_examples/device.json.new').exists())
self.assertEqual((target / 'python_app/runtime/gui_session_state.json').read_text(), 'session')
self.assertFalse((target / 'python_app/runtime/logs').exists())
self.assertIn('kamil_adc_collector', log.read_text().splitlines())
+44
View File
@@ -0,0 +1,44 @@
"""Settings commits retain valid files on failure and concurrent writes."""
from concurrent.futures import ThreadPoolExecutor
import json
from pathlib import Path
import tempfile
import unittest
from unittest import mock
from python_app.file_io import atomic_write_json
from python_app.orchestration.gui_session_state import GuiSessionStateStore
class AtomicJsonTest(unittest.TestCase):
def setUp(self) -> None:
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
self.path = Path(directory.name) / "settings.json"
atomic_write_json(self.path, {"old": True})
def test_failed_sync_or_commit_preserves_previous_file(self) -> None:
for operation in ('fsync', 'replace'):
with self.subTest(operation=operation), \
mock.patch(f'python_app.file_io.os.{operation}', side_effect=OSError('disk failure')), \
self.assertRaises(OSError):
atomic_write_json(self.path, {'new': True}, durable=True)
self.assertEqual(json.loads(self.path.read_text()), {'old': True})
self.assertEqual(list(self.path.parent.glob('*.tmp')), [])
def test_nonfinite_json_cannot_overwrite_settings(self) -> None:
with self.assertRaises(ValueError):
atomic_write_json(self.path, {'bad': float('nan')})
self.assertEqual(json.loads(self.path.read_text()), {'old': True})
def test_concurrent_writers_publish_complete_json_and_leave_no_temp_files(self) -> None:
payloads = [{'index': i, 'values': [i] * 1000} for i in range(32)]
with ThreadPoolExecutor(max_workers=8) as executor:
list(executor.map(lambda payload: atomic_write_json(self.path, payload), payloads))
self.assertIn(json.loads(self.path.read_text()), payloads)
self.assertEqual(list(self.path.parent.glob('*.tmp')), [])
def test_invalid_utf8_session_cache_uses_defaults(self) -> None:
self.path.write_bytes(b'\xff')
self.assertEqual(GuiSessionStateStore(self.path).load().last_profile_path, '')
+45
View File
@@ -0,0 +1,45 @@
"""Standalone generator configuration and independent resource cleanup."""
from dataclasses import replace
import unittest
from unittest import mock
from python_app.generator_sweep.config import GeneratorSweepConfig, resolve_generator_sweep_config
from python_app.generator_sweep.runner import GeneratorSweepRunner
class GeneratorSweepTest(unittest.TestCase):
def test_frequency_grid_includes_stop_with_and_without_an_exact_step(self) -> None:
for step, expected in ((2, (10, 12, 14, 15)), (5, (10, 15))):
config = GeneratorSweepConfig(start_hz=10, stop_hz=15, step_hz=step)
self.assertEqual(resolve_generator_sweep_config(config).frequencies_hz, expected)
config = GeneratorSweepConfig(start_hz=10, stop_hz=15, points=3)
self.assertEqual(resolve_generator_sweep_config(config).frequencies_hz, (10, 12, 15))
def test_malformed_types_nonfinite_values_and_excessive_grids_fail_before_open(self) -> None:
valid = GeneratorSweepConfig(points=3)
for changes in ({'points': 3.5}, {'port': True}, {'sweep_start_pin': 5.5},
{'power_dbm': float('inf')}, {'hold_time_ms': float('nan')},
{'points': 1_000_001}, {'points': 0, 'step_hz': 1, 'stop_hz': 10 ** 10}):
with self.subTest(changes=changes), self.assertRaises(ValueError):
resolve_generator_sweep_config(replace(valid, **changes))
def test_each_cleanup_failure_still_releases_the_other_resources(self) -> None:
for failure in ('pwm_stop', 'device_idle', 'device_close', 'marker_reset', 'marker_close'):
with self.subTest(failure=failure):
runner = GeneratorSweepRunner(GeneratorSweepConfig(points=3))
runner._pwm = mock.Mock()
runner._markers = mock.Mock()
device = runner._device = mock.Mock(is_connected=True)
actions = {
'pwm_stop': runner._pwm.disable, 'device_idle': device.generator.set_idle,
'device_close': device.disconnect, 'marker_reset': runner._markers.reset_to_idle,
'marker_close': runner._markers.close,
}
actions[failure].side_effect = OSError('cleanup failure')
with self.assertLogs('python_app.generator_sweep.runner', level='WARNING'):
runner._close()
for action in actions.values():
action.assert_called_once()
runner._pwm.close.assert_called_once()
self.assertIsNone(runner._device)
@@ -0,0 +1,297 @@
"""Regression coverage for saves, full history drains and interruptible GUI actions."""
from __future__ import annotations
from collections import deque
import itertools
import os
from pathlib import Path
import tempfile
from types import SimpleNamespace
import unittest
from unittest import mock
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import QApplication # noqa: E402
from PyQt6.QtCore import QTimer # noqa: E402
from python_app.gui.app_window import AppWindow # noqa: E402
from python_app.gui.controllers.app_window_config.profile_io_mixin import AppWindowConfigProfileIOMixin # noqa: E402
from python_app.gui.controllers.app_window_pipeline_mixin import AppWindowPipelineMixin # noqa: E402
from python_app.gui.controllers.app_window_preprocess_mixin import AppWindowPreprocessMixin # noqa: E402
from python_app.gui.controllers.app_window_snapshot_mixin import AppWindowSnapshotMixin # noqa: E402
from python_app.models.dataset_model import ResultCollection, SweepCollection # noqa: E402
from python_app.models.gui_profile_model import GuiProfileModel # noqa: E402
from python_app.models.run_config_model import RunConfigModel # noqa: E402
from python_app.storage.npz_store import NpzStore # noqa: E402
from python_app.workflows.kamil_adc_neutral_preprocess import build_neutral_s21_sets # noqa: E402
def setUpModule() -> None:
global _app
_app = QApplication.instance() or QApplication([])
def _reader(method: str, collections: list):
pending = deque(collections)
return SimpleNamespace(**{method: lambda: pending.popleft() if pending else None, "close": mock.Mock()})
class _Runtime(AppWindowPipelineMixin, AppWindowSnapshotMixin, AppWindowPreprocessMixin):
def __init__(self) -> None:
self._raw_reader = self._pre_reader = self._result_reader = None
self._raw_history = deque(maxlen=2)
self._pre_history = deque(maxlen=2)
self._result_history = deque(maxlen=2)
self._max_pop_per_poll = self._max_pop_per_snapshot_drain = 1
self._single_capture_active = True
self._single_capture_start_ns = 10
self._single_capture_seen_raw = False
self._single_capture_target_collection_id = None
self._capture_session = None
self._preprocess_capture_busy = self._closing = False
self._resume_pipeline_after_capture = True
self._stop_request_seq = 0
self._pipeline_metrics = mock.Mock()
self._record_collection = mock.Mock()
self._supervisor = mock.Mock()
self._supervisor.is_running.return_value = False
self._supervisor.is_processor_running.return_value = True
self._status_label = mock.Mock()
for name in ("_log", "_log_debug", "_log_exception", "_show_error", "_show_exception",
"_update_history_indicator", "_finalize_recording_on_stop", "_web_update_snapshot",
"_build_config", "_update_capture_dialog_state", "close"):
setattr(self, name, mock.Mock())
self._pump_events_during_drain = mock.Mock()
class NeutralSetCreationTest(unittest.TestCase):
def test_failed_second_set_preserves_gui_selection_and_previous_data(self) -> None:
w = _Runtime()
config = RunConfigModel.from_dict({"radar": {
"model": "librevna", "driver_mode": "mock", "sweep": {"points": 7},
}})
w._build_config.return_value = config
w._ensure_preprocess_dialog = mock.Mock(return_value=SimpleNamespace(set_name=lambda: "new_neutral"))
w._radar_key = mock.Mock(return_value="radar")
w._selected_preprocess_sets = {"s21_calibration": "old_cal", "s21_reference": "old_ref"}
w._selected_preprocess_radar_key = "previous_radar"
w._processor_run_signature = "processor_signature"
w._history_run_signature = "history_signature"
w._refresh_sets = mock.Mock()
w._reset_runtime_history = mock.Mock()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
w._store = NpzStore(root)
calibration, reference = build_neutral_s21_sets(config)
w._store.save_set("s21_calibration", "radar", "old_cal", calibration)
w._store.save_set("s21_reference", "radar", "old_ref", reference)
before = {path: path.read_bytes() for path in root.rglob("*") if path.is_file()}
save = w._store.save_set
def save_set(kind, *args):
if kind == "s21_reference":
raise OSError("reference write failed")
return save(kind, *args)
with mock.patch.object(w._store, "save_set", side_effect=save_set):
w._create_neutral_sets()
self.assertEqual({path: path.read_bytes() for path in root.rglob("*") if path.is_file()}, before)
self.assertEqual(w._selected_preprocess_sets, {"s21_calibration": "old_cal", "s21_reference": "old_ref"})
self.assertEqual(w._selected_preprocess_radar_key, "previous_radar")
self.assertEqual((w._processor_run_signature, w._history_run_signature), ("processor_signature", "history_signature"))
w._show_exception.assert_called_once()
w._refresh_sets.assert_not_called()
w._reset_runtime_history.assert_not_called()
class SnapshotDrainTest(unittest.TestCase):
def test_save_drain_preserves_recording_and_single_capture_tracking(self) -> None:
w = _Runtime()
raw = SweepCollection(collection_id=5, monotonic_ns=20, traces=[], capture_start_ns=1, capture_end_ns=8)
preprocessed = SweepCollection(collection_id=5, monotonic_ns=20, traces=[])
result = ResultCollection(collection_id=5, monotonic_ns=20, blocks=[])
w._raw_reader = _reader("pop_raw_collection", [raw])
w._pre_reader = _reader("pop_preprocessed_collection", [preprocessed])
w._result_reader = _reader("pop_result_collection", [result, result])
w._drain_runtime_rings_for_snapshot()
self.assertEqual(w._record_collection.call_args_list, [
mock.call("raw", raw), mock.call("preprocessed", preprocessed), mock.call("results", result),
])
self.assertTrue(w._single_capture_seen_raw)
self.assertEqual(w._single_capture_target_collection_id, 5)
w._pipeline_metrics.record.assert_any_call("acquisition", 7)
self.assertEqual(list(w._result_history), [result])
w._log_exception.assert_not_called()
def test_continuous_producer_cannot_prevent_snapshot_timeout(self) -> None:
w = _Runtime()
collection = SweepCollection(collection_id=1, monotonic_ns=20, traces=[])
pop = mock.Mock(return_value=collection)
w._raw_reader = SimpleNamespace(pop_raw_collection=pop)
ticks = itertools.count(0.0, 0.1)
with mock.patch("python_app.gui.controllers.app_window_snapshot_mixin.time.monotonic", side_effect=lambda: next(ticks)):
w._drain_runtime_rings_for_snapshot()
self.assertGreater(pop.call_count, 1)
self.assertLess(pop.call_count, 15)
w._log_exception.assert_not_called()
def test_stop_drain_reads_the_whole_tail_when_history_is_full(self) -> None:
w = _Runtime()
w._raw_history = deque([SweepCollection(collection_id=0, monotonic_ns=0, traces=[])], maxlen=1)
tail = [SweepCollection(collection_id=i, monotonic_ns=i, traces=[]) for i in range(1, 11)]
w._raw_reader = _reader("pop_raw_collection", tail)
clock = [0.0]
w._pump_events_during_drain.side_effect = lambda pause: clock.__setitem__(0, clock[0] + pause)
with mock.patch("python_app.gui.controllers.app_window_pipeline_mixin.time.monotonic", side_effect=lambda: clock[0]):
w._drain_rings_until_quiet(timeout_s=1.0, poll_s=0.01)
self.assertEqual([call.args[1].collection_id for call in w._record_collection.call_args_list], list(range(1, 11)))
self.assertEqual(w._raw_history[-1].collection_id, 10)
class ShutdownReentryTest(unittest.TestCase):
def test_reader_failure_on_stop_is_reported_and_cancels_automatic_resume(self) -> None:
w = _Runtime()
w._supervisor.is_running.return_value = True
w._drain_rings_until_quiet = mock.Mock(side_effect=ValueError("corrupt ring payload"))
raw = w._raw_reader = SimpleNamespace(close=mock.Mock())
preprocessed = w._pre_reader = SimpleNamespace(close=mock.Mock())
result = w._result_reader = SimpleNamespace(close=mock.Mock(), _ring_name="/results")
expected_stop_seq = w._stop_request_seq + 1
with mock.patch("python_app.gui.controllers.app_window_pipeline_mixin.ShmRingReader") as reopen:
w._stop_run()
w._supervisor.stop.assert_called_once()
w._supervisor.stop_all.assert_not_called()
w._finalize_recording_on_stop.assert_called_once()
for reader in (raw, preprocessed, result):
reader.close.assert_called_once()
self.assertIsNone(w._raw_reader)
self.assertIsNone(w._pre_reader)
self.assertIs(w._result_reader, reopen.return_value)
reopen.assert_called_once_with("/results")
w._show_exception.assert_called_once()
self.assertIn("corrupt ring", str(w._show_exception.call_args.args[1]))
self.assertFalse(w._stopping_run)
self.assertFalse(w._single_capture_active)
self.assertEqual(w._status_label.setText.call_args.args[0], "Status: error")
self.assertFalse(w._pipeline_resume_allowed(expected_stop_seq))
def test_unsupported_kamil_adc_mock_fails_before_starting_any_process(self) -> None:
w = _Runtime()
config = RunConfigModel()
config.radar.model = "kamil_adc"
config.apply_device_model_constraints()
w._build_config.return_value = config
w._stop_all_processes = mock.Mock()
w._start_run(single_capture=True)
w._supervisor.start.assert_not_called()
w._show_exception.assert_called_once()
self.assertIn("native", str(w._show_exception.call_args.args[1]))
def test_stop_blocks_nested_start_poll_and_duplicate_stop(self) -> None:
w = _Runtime()
w._supervisor.is_running.return_value = True
def reenter(**_kwargs):
w._start_run()
w._poll_rings()
w._stop_run()
w._drain_rings_until_quiet = mock.Mock(side_effect=reenter)
w._stop_run()
w._supervisor.stop_orchestrator.assert_called_once()
w._supervisor.stop_preprocessor.assert_called_once()
w._finalize_recording_on_stop.assert_called_once()
w._build_config.assert_not_called()
w._web_update_snapshot.assert_not_called()
self.assertFalse(w._stopping_run)
self.assertFalse(w._resume_pipeline_after_capture)
def test_close_during_stop_waits_until_tail_is_finalized(self) -> None:
w = _Runtime()
w._supervisor.is_running.return_value = True
event = mock.Mock()
ordering = []
def close_during_drain(**_kwargs):
AppWindow.closeEvent(w, event)
w._drain_rings_until_quiet = mock.Mock(side_effect=close_during_drain)
w._finalize_recording_on_stop.side_effect = lambda: ordering.append("finalize")
w.close.side_effect = lambda: ordering.append("close")
w._stop_run()
event.ignore.assert_called()
w._supervisor.stop_all.assert_called_once()
self.assertEqual(ordering, ["finalize", "close"])
self.assertFalse(w._close_after_stop)
def test_explicit_stop_during_apply_pause_cancels_apply_and_restart(self) -> None:
w = _Runtime()
w._supervisor.is_running.return_value = True
w._start_run = mock.Mock()
w._drain_rings_until_quiet = mock.Mock(side_effect=lambda **_kwargs: w._stop_run())
w._apply_radar_settings()
w._build_config.assert_not_called()
w._start_run.assert_not_called()
class CaptureBatchControlTest(unittest.TestCase):
def test_stop_between_combos_keeps_captures_and_allows_later_resume(self) -> None:
w = _Runtime()
captured = []
session = SimpleNamespace(
kind="s21_reference", is_complete=lambda: len(captured) == 3,
state=lambda: SimpleNamespace(total_count=3, captured_count=len(captured)),
capture_current_combo=lambda: captured.append(len(captured)),
)
w._capture_session = session
dialog = mock.Mock()
w._ensure_preprocess_dialog = lambda: dialog
w._record_preprocess_capture = mock.Mock()
w._pump_events_during_drain = lambda pause: AppWindowPipelineMixin._pump_events_during_drain(w, pause)
QTimer.singleShot(0, w._stop_run)
with mock.patch("python_app.gui.controllers.app_window_preprocess_mixin.QTimer.singleShot", side_effect=lambda _delay, fn: fn()):
w._capture_all_remaining()
self.assertEqual(captured, [0])
self.assertIs(w._capture_session, session)
self.assertFalse(w._preprocess_capture_busy)
self.assertIn("paused", dialog.set_status.call_args.args[0])
w._capture_all_remaining()
self.assertEqual(captured, [0, 1, 2])
self.assertEqual(w._record_preprocess_capture.call_count, 3)
class GuiProfileWriteTest(unittest.TestCase):
def test_failed_profile_replace_preserves_existing_profile_and_cleans_temporary(self) -> None:
writer = AppWindowConfigProfileIOMixin()
writer._build_gui_profile = lambda: GuiProfileModel()
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "profile.json"
previous = b'{"previous": "profile"}'
path.write_bytes(previous)
with mock.patch("python_app.file_io.os.replace", side_effect=OSError("disk failure")):
with self.assertRaisesRegex(OSError, "disk failure"):
writer._write_gui_profile_to_path(path)
self.assertEqual(path.read_bytes(), previous)
self.assertEqual(list(path.parent.iterdir()), [path])
class HistoryCommandRestoreTest(unittest.TestCase):
def test_malformed_or_exhausted_counter_cannot_prevent_gui_start(self) -> None:
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "live.json"
for content in ('[]', 'null', 'invalid', '{"history_command_seq": NaN}',
'{"history_command_seq": Infinity}', '{"history_command_seq": true}',
'{"history_command_seq": 1.5}', '{"history_command_seq": -1}',
'{"history_command_seq": 18446744073709551615}'):
path.write_text(content)
with self.subTest(content=content):
self.assertEqual(AppWindow._load_history_command_seq(path), 0)
path.write_text('{"history_command_seq": 9876543210}')
self.assertEqual(AppWindow._load_history_command_seq(path), 9876543210)
if __name__ == "__main__":
unittest.main()
@@ -54,6 +54,22 @@ class GuiProfileCodecTest(unittest.TestCase):
twice = GuiProfileModel.from_dict(once).to_dict()
self.assertEqual(once["gui"], twice["gui"])
def test_legacy_save_count_is_ignored_without_losing_save_destination(self) -> None:
decoded = GuiProfileModel.from_dict({
"gui": {"data_actions": {
"save_count": 7,
"save_path": "/tmp/measurements",
"save_name": "scene",
"record_count": 250,
}},
})
encoded = decoded.to_dict()["gui"]["data_actions"]
self.assertNotIn("save_count", encoded)
self.assertEqual(encoded, {
"save_path": "/tmp/measurements", "save_name": "scene", "record_count": 250,
})
def test_sar3d_section_round_trips(self) -> None:
sar3d = GuiSar3dStateModel(
input_positions="0,2",
+324
View File
@@ -0,0 +1,324 @@
"""Failure-path coverage without physical instruments or protocol changes."""
from __future__ import annotations
import json
import os
import pty
from pathlib import Path
import tempfile
import threading
import tty
import unittest
from dataclasses import dataclass
from unittest.mock import Mock, patch
import numpy as np
from python_app.hardware_full.kamil_adc.tty_reader import KamilAdcTtyReader
from python_app.hardware_full.laser_control.protocol import Protocol
from python_app.hardware_full.librevna_backends import NativeLibreVnaBackend
from python_app.hardware_full.librevna_driver.enums import PacketType
from python_app.hardware_full.librevna_driver.exceptions import DeviceDisconnectedError
from python_app.hardware_full.librevna_driver.models import Packet
from python_app.hardware_full.librevna_driver.session import LibreVNASession
from python_app.hardware_full.librevna_driver.transport.usb import USBTransport
from python_app.hardware_full.switch_drivers.h7992_driver import H7992Driver
from python_app.hardware_full.switch_drivers.hmc349a_driver import HMC349ADriver
from python_app.hardware_full.switched_matrix_radar_service import SwitchedMatrixRadarService
from python_app.models.run_config_model import RunConfigModel
from python_app.models.dataset_model import ComboKey, TraceData
from python_app.scripts import kamil_adc_raw_producer, matrix_raw_producer, replay_raw_producer
from python_app.workflows.radar_config_variants import scan_radar_config_variants
@dataclass
class _SweepPoint:
point_number: int
cycle: str
class HardwareCleanupTest(unittest.TestCase):
def test_partial_matrix_open_closes_every_component(self) -> None:
inner, output, input_ = Mock(), Mock(), Mock()
failure = OSError("input open failed")
input_.open.side_effect = failure
output.close.side_effect = OSError("output close failed")
service = SwitchedMatrixRadarService(inner, output, input_, 2, 4)
with self.assertRaises(OSError) as raised:
service.open()
self.assertIs(raised.exception, failure)
for resource in (inner, output, input_):
resource.close.assert_called_once()
def test_native_open_failure_allows_fresh_retry(self) -> None:
backend = NativeLibreVnaBackend(None, 14)
failed, working = Mock(), Mock()
failed.connect.side_effect = OSError("USB unavailable")
failed.disconnect.side_effect = OSError("disconnect failed")
backend._libre_vna_device = Mock(side_effect=[failed, working])
with self.assertRaisesRegex(OSError, "USB unavailable"):
backend.open()
self.assertFalse(backend.is_open)
backend.open()
self.assertIs(backend._device, working)
working.disconnect.side_effect = OSError("device removed")
with self.assertRaises(OSError):
backend.close()
self.assertFalse(backend.is_open)
def test_gpio_initialization_failure_releases_lines(self) -> None:
for driver_type, module in ((H7992Driver, "h7992_driver"), (HMC349ADriver, "hmc349a_driver")):
with self.subTest(driver=driver_type.__name__):
lines = Mock(set_values=Mock(side_effect=OSError("GPIO write failed")))
driver = driver_type("test")
with patch(f"python_app.hardware_full.switch_drivers.{module}.GpioOutputLines", return_value=lines):
with self.assertRaisesRegex(OSError, "GPIO write failed"):
driver.open()
lines.close.assert_called_once()
self.assertFalse(driver._is_open)
self.assertIsNone(driver._lines)
def test_serial_connect_is_idempotent_and_failed_close_clears_state(self) -> None:
connection = Mock(is_open=True)
protocol = Protocol("/dev/test")
with patch("serial.Serial", return_value=connection) as factory:
protocol.connect()
protocol.connect()
factory.assert_called_once()
connection.close.side_effect = OSError("serial removed")
with self.assertRaises(OSError):
protocol.disconnect()
self.assertFalse(protocol.is_connected)
class SessionLifecycleTest(unittest.TestCase):
def test_acquisition_discards_tail_before_first_point_zero(self) -> None:
self._assert_complete_cycle([(1, "old"), (2, "old"), (0, "new"), (1, "new"), (2, "new")])
def test_acquisition_discards_incomplete_cycle_on_next_point_zero(self) -> None:
self._assert_complete_cycle([(0, "old"), (1, "old"), (0, "new"), (1, "new"), (2, "new")])
def _assert_complete_cycle(self, sequence) -> None:
session = LibreVNASession()
packets = [Packet(PacketType.VNA_DATAPOINT, _SweepPoint(point_number=index, cycle=cycle)) for index, cycle in sequence]
with patch.object(session, "wait_for_packet", side_effect=packets):
points = session.collect_indexed_payloads(
packet_type=PacketType.VNA_DATAPOINT, expected_points=3, timeout_s=1,
payload_type=_SweepPoint, payload_error="Unexpected datapoint",
)
self.assertEqual([p.point_number for p in points], [0, 1, 2])
self.assertEqual([p.cycle for p in points], ["new"] * 3)
def test_disconnect_wakes_packet_and_ack_waiters(self) -> None:
session = LibreVNASession()
write_started = threading.Event()
session._transport = Mock(write=Mock(side_effect=lambda *_, **__: write_started.set()))
errors = []
def waiting(action):
try:
action()
except Exception as exc:
errors.append(exc)
workers = [
threading.Thread(target=waiting, args=(lambda: session.wait_for_packet(PacketType.DEVICE_INFO, timeout_s=20),)),
threading.Thread(target=waiting, args=(lambda: session.send(Packet(PacketType.REQUEST_DEVICE_INFO), timeout_s=20),)),
]
for worker in workers:
worker.start()
self.assertTrue(write_started.wait(1))
session.disconnect()
for worker in workers:
worker.join(timeout=1)
self.assertFalse(worker.is_alive())
self.assertEqual(len(errors), 2)
self.assertTrue(all(isinstance(exc, DeviceDisconnectedError) for exc in errors))
self.assertFalse(session._awaiting_ack)
def test_write_failure_resets_ack_state(self) -> None:
session = LibreVNASession()
session._transport = Mock(write=Mock(side_effect=OSError("write failed")))
with self.assertRaises(OSError):
session.send(Packet(PacketType.REQUEST_DEVICE_INFO))
self.assertFalse(session._awaiting_ack)
self.assertIsNone(session._ack_result)
def test_stream_subscription_has_bounded_unused_queue(self) -> None:
with patch("python_app.hardware_full.librevna_driver.session._PACKET_QUEUE_MAX", 4):
session = LibreVNASession()
callback = Mock()
session.subscribe(PacketType.VNA_DATAPOINT, callback)
for index in range(20):
session._dispatch_packet(Packet(PacketType.VNA_DATAPOINT, index))
self.assertEqual(callback.call_count, 20)
self.assertEqual([p.payload for p in session._incoming[PacketType.VNA_DATAPOINT]], [16, 17, 18, 19])
self.assertIsNone(session._fatal_error)
def test_idle_free_running_sweep_does_not_poison_next_acquisition(self) -> None:
with patch("python_app.hardware_full.librevna_driver.session._PACKET_QUEUE_MAX", 2):
session = LibreVNASession()
for index in range(10):
session._dispatch_packet(Packet(PacketType.VNA_DATAPOINT, index))
self.assertEqual(len(session._incoming[PacketType.VNA_DATAPOINT]), 2)
self.assertIsNone(session._fatal_error)
session.clear_queue(PacketType.VNA_DATAPOINT)
session._dispatch_packet(Packet(PacketType.VNA_DATAPOINT, 0))
self.assertEqual(session.wait_for_packet(PacketType.VNA_DATAPOINT).payload, 0)
def test_synchronous_queue_overflow_is_reported(self) -> None:
with patch("python_app.hardware_full.librevna_driver.session._PACKET_QUEUE_MAX", 1):
session = LibreVNASession()
session._dispatch_packet(Packet(PacketType.DEVICE_INFO))
session._dispatch_packet(Packet(PacketType.DEVICE_INFO))
with self.assertRaisesRegex(DeviceDisconnectedError, "queue overflow"):
session.wait_for_packet(PacketType.DEVICE_INFO)
class UsbLifecycleTest(unittest.TestCase):
def test_failed_enumeration_releases_context(self) -> None:
transport = USBTransport(on_data=Mock())
context = Mock()
with patch("usb1.USBContext", return_value=context), patch.object(transport, "_iter_matching_devices", side_effect=OSError("enumeration failed")):
with self.assertRaisesRegex(OSError, "enumeration failed"):
transport.connect()
context.close.assert_called_once()
self.assertIsNone(transport._ctx)
self.assertFalse(transport.is_connected)
def test_failed_candidate_identity_read_closes_handle_and_context(self) -> None:
transport = USBTransport(on_data=Mock())
context, handle = Mock(), Mock(getSerialNumber=Mock(side_effect=OSError("identity failed")))
device = Mock(open=Mock(return_value=handle))
with patch("usb1.USBContext", return_value=context), patch.object(transport, "_iter_matching_devices", return_value=[(device, 0, 0)]):
with self.assertRaisesRegex(OSError, "identity failed"):
transport.connect()
handle.close.assert_called_once()
context.close.assert_called_once()
self.assertFalse(transport.is_connected)
def test_failed_thread_start_releases_claimed_handle_and_context(self) -> None:
transport = USBTransport(on_data=Mock())
context, handle = Mock(), Mock(kernelDriverActive=Mock(return_value=False))
device = Mock(open=Mock(return_value=handle))
with patch("usb1.USBContext", return_value=context), patch.object(transport, "_iter_matching_devices", return_value=[(device, 0, 0)]), patch("threading.Thread.start", side_effect=RuntimeError("thread failed")):
with self.assertRaisesRegex(RuntimeError, "thread failed"):
transport.connect()
handle.releaseInterface.assert_called_once_with(transport.INTERFACE)
handle.close.assert_called_once()
context.close.assert_called_once()
self.assertIsNone(transport._rx_thread)
self.assertIsNone(transport._ctx)
self.assertFalse(transport.is_connected)
class TtyLifecycleTest(unittest.TestCase):
def test_close_interrupts_both_waiting_read_methods(self) -> None:
for combination in (None, (0, 0)):
with self.subTest(combination=combination):
master, slave = pty.openpty()
tty.setraw(slave)
reader = KamilAdcTtyReader(os.ttyname(slave))
errors = []
started = threading.Event()
reader.open()
def wait():
started.set()
try:
if combination is None:
reader.read_sweep(timeout_s=20)
else:
reader.read_sweep_for(combination, timeout_s=20)
except Exception as exc:
errors.append(exc)
worker = threading.Thread(target=wait)
try:
worker.start()
self.assertTrue(started.wait(1))
reader.close()
worker.join(timeout=1)
self.assertFalse(worker.is_alive())
self.assertEqual(len(errors), 1)
self.assertIsInstance(errors[0], RuntimeError)
finally:
reader.close()
os.close(master)
os.close(slave)
def test_failed_reader_thread_start_closes_descriptor(self) -> None:
read_fd, write_fd = os.pipe()
reader = KamilAdcTtyReader("/dev/test")
try:
with patch("os.open", return_value=read_fd), patch("threading.Thread.start", side_effect=RuntimeError("thread failed")):
with self.assertRaises(RuntimeError):
reader.open()
self.assertIsNone(reader._fd)
self.assertIsNone(reader._thread)
with self.assertRaises(OSError):
os.fstat(read_fd)
finally:
os.close(write_fd)
class ProducerCleanupTest(unittest.TestCase):
def test_replay_rejects_small_slots_before_publishing_any_data(self) -> None:
module = replay_raw_producer
config = RunConfigModel()
config.radar.replay.dataset_dir = "/test/data"
config.rings.raw.slot_size_bytes = 1
trace = TraceData(
ComboKey(0, 0), np.array([1, 2], dtype=np.float32),
np.array([0, 0], dtype=np.complex64), np.array([1, 2], dtype=np.complex64),
)
dataset = module.ReplayDataset([[trace]], [20])
raw, tap = Mock(), Mock()
with patch.object(module.RunConfigModel, "load_from_path", return_value=config), patch.object(module.signal, "signal"), patch.object(module, "_load_dataset", return_value=dataset), patch.object(module, "ShmRingWriter", side_effect=[raw, tap]), patch("sys.argv", [module.__name__, "--config", "/test/config.json"]):
with self.assertRaisesRegex(ValueError, "Replay requires .* bytes per slot"):
module.main()
raw.push.assert_not_called()
tap.push.assert_not_called()
raw.close.assert_called_once()
tap.close.assert_called_once()
def test_partial_ring_initialization_closes_first_writer(self) -> None:
for module, model in ((matrix_raw_producer, "librevna_multi"), (kamil_adc_raw_producer, "kamil_adc"), (replay_raw_producer, "librevna")):
with self.subTest(producer=module.__name__):
config = RunConfigModel()
config.radar.model = model
config.radar.replay.dataset_dir = "/test/data"
writer = Mock()
with patch.object(module.RunConfigModel, "load_from_path", return_value=config), patch.object(module.signal, "signal"), patch.object(module, "ShmRingWriter", side_effect=[writer, OSError("second ring failed")]), patch("sys.argv", [module.__name__, "--config", "/test/config.json"]):
if module is replay_raw_producer:
with patch.object(module, "_load_dataset", return_value=Mock(median_gap_ns=1, __len__=Mock(return_value=1))):
with self.assertRaisesRegex(OSError, "second ring failed"):
module.main()
else:
with self.assertRaisesRegex(OSError, "second ring failed"):
module.main()
writer.close.assert_called_once()
class RadarVariantValidationTest(unittest.TestCase):
def test_scan_rejects_malformed_sweeps_and_keeps_valid_overlay(self) -> None:
with tempfile.TemporaryDirectory() as directory:
source = Path(directory)
for index, sweep in enumerate((
{"points": 1.5}, {"points": True}, {"if_bandwidth_hz": -1},
{"start_hz": 10, "stop_hz": 5}, {"start_hz": float("nan")},
{"points": 2, "stimulus_power_dbm": -20},
)):
(source / f"variant{index}.json").write_text(json.dumps({"radar": {"sweep": sweep}}))
base = RunConfigModel()
variants, summary = scan_radar_config_variants(directory, base_config=base)
self.assertEqual(summary.valid_variant_count, 1)
self.assertEqual(summary.skipped_file_count, 5)
self.assertEqual(variants[0].config.radar.sweep.points, 2)
self.assertEqual(variants[0].config.radar.sweep.power_dbm, -20)
self.assertEqual(base.radar.sweep.points, 1)
if __name__ == "__main__":
unittest.main()
@@ -1,4 +1,4 @@
"""Tests for Kamil ADC neutral preprocessing-set generation."""
"""Neutral preprocessing grids must preserve the actual radar data unchanged."""
from __future__ import annotations
@@ -6,8 +6,9 @@ import unittest
import numpy as np
from python_app.hardware_full.point_target_mock_service import PointTargetMockService
from python_app.models.run_config_model import RunConfigModel
from python_app.workflows.kamil_adc_neutral_preprocess import build_kamil_adc_neutral_s21_sets
from python_app.workflows.kamil_adc_neutral_preprocess import build_neutral_s21_sets, supports_neutral_preprocess_sets
def _kamil_config(*, points: int = 5) -> RunConfigModel:
@@ -27,7 +28,7 @@ def _kamil_config(*, points: int = 5) -> RunConfigModel:
class KamilAdcNeutralPreprocessTest(unittest.TestCase):
def test_builds_passthrough_s21_sets_on_band_grid(self) -> None:
calibration, reference = build_kamil_adc_neutral_s21_sets(_kamil_config(points=5))
calibration, reference = build_neutral_s21_sets(_kamil_config(points=5))
expected_frequency = np.linspace(2_100_000_000.0, 5_500_000_000.0, 5).astype(np.float32)
self.assertEqual(len(calibration.traces), 2)
@@ -53,20 +54,15 @@ class KamilAdcNeutralPreprocessTest(unittest.TestCase):
config = _kamil_config(points=17)
processor = KamilAdcSweepProcessor(KamilAdcProcessingParams.from_kamil_model(config.radar.kamil_adc))
calibration, _reference = build_kamil_adc_neutral_s21_sets(config)
calibration, _reference = build_neutral_s21_sets(config)
np.testing.assert_array_equal(calibration.traces[0].frequency_hz, processor.grid_hz)
def test_rejects_non_kamil_config(self) -> None:
config = RunConfigModel.from_dict({"radar": {"model": "librevna"}})
with self.assertRaisesRegex(ValueError, "kamil_adc"):
build_kamil_adc_neutral_s21_sets(config)
def test_rejects_degenerate_band(self) -> None:
with self.assertRaisesRegex(ValueError, "points must be >= 2"):
build_kamil_adc_neutral_s21_sets(_kamil_config(points=1))
build_neutral_s21_sets(_kamil_config(points=1))
def test_dtypes_are_float32_and_complex64(self) -> None:
calibration, _reference = build_kamil_adc_neutral_s21_sets(_kamil_config())
calibration, _reference = build_neutral_s21_sets(_kamil_config())
trace = calibration.traces[0]
self.assertEqual(trace.frequency_hz.dtype, np.float32)
self.assertEqual(trace.s21.dtype, np.complex64)
@@ -75,10 +71,29 @@ class KamilAdcNeutralPreprocessTest(unittest.TestCase):
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(_kamil_config())
calibration, _reference = build_neutral_s21_sets(_kamil_config())
for trace in calibration.traces:
self.assertTrue(bool(np.all(trace.s21 != 0)))
def test_single_mock_grid_is_exact_and_neutral_s21_is_passthrough(self) -> None:
config = RunConfigModel.from_dict({"radar": {
"model": "librevna", "driver_mode": "mock",
"sweep": {"start_hz": 1e6, "stop_hz": 6e9, "points": 201},
}})
self.assertTrue(supports_neutral_preprocess_sets(config))
radar = PointTargetMockService(config)
radar.configure(config.radar.sweep)
measured = radar.acquire_collection()
calibration, reference = build_neutral_s21_sets(config)
for trace, cal, ref in zip(measured.traces, calibration.traces, reference.traces):
np.testing.assert_array_equal(trace.frequency_hz, cal.frequency_hz)
np.testing.assert_array_equal(trace.frequency_hz, ref.frequency_hz)
np.testing.assert_array_equal(trace.s21 / cal.s21 - ref.s21, trace.s21)
def test_native_single_axis_remains_outside_verified_shortcut(self) -> None:
config = RunConfigModel.from_dict({"radar": {"model": "librevna", "driver_mode": "native"}})
self.assertFalse(supports_neutral_preprocess_sets(config))
if __name__ == "__main__":
unittest.main()
@@ -346,7 +346,7 @@ class TransientRetryTierTest(unittest.TestCase):
@staticmethod
def _stub(behavior) -> _StubService:
service = _StubService(master_serial="M", slave_serials=["A", "B"], backend_mode="mock")
service = _StubService(master_serial="M", slave_serials=["A", "B"], backend_mode="native")
service.behavior = behavior
service.collection_calls = 0
service.reopen_calls = 0
@@ -517,15 +517,9 @@ class PerPointStimulusSwitchingTest(unittest.TestCase):
def test_service_reports_four_outputs_when_switching(self) -> None:
service = MultiDeviceLibreVnaService(
master_serial="M", slave_serials=["A", "B"], backend_mode="mock", switch_substage_count=2
master_serial="M", slave_serials=["A", "B"], backend_mode="native", switch_substage_count=2
)
self.assertEqual(service.output_position_count, 4)
service.configure(RadarSweepModel(start_hz=1e6, stop_hz=2e6, points=4, if_bandwidth_hz=1e3))
collection = service.acquire_collection(collection_id=1)
self.assertEqual(len(collection.traces), 4 * 4)
self.assertEqual(
sorted({trace.combo.output for trace in collection.traces}), [0, 1, 2, 3]
)
if __name__ == "__main__":
@@ -16,7 +16,6 @@ from python_app.gui.controllers.app_window_config.live_processing_mixin import (
_NON_WIDGET_LIVE_FIELDS,
_WEB_DISPLAY_SCHEMA,
_WEB_LIVE_GETTERS,
_WEB_STABLE_SCHEMA,
web_apply_field_names,
)
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
@@ -44,10 +43,8 @@ class LiveSettingsSchemaCoverageTest(unittest.TestCase):
# A field is either widget-backed (in the schema) or explicitly non-widget — never both.
self.assertEqual(set(_WEB_LIVE_GETTERS) & _NON_WIDGET_LIVE_FIELDS, set())
def test_display_and_stable_fields_are_not_live_config(self) -> None:
# Display toggles (GUI profile) and stable fields (run_config) must never collide
# with live ProcessingLiveConfig fields — they take different write paths.
extra = {f for f, *_ in _WEB_DISPLAY_SCHEMA} | {f for f, *_ in _WEB_STABLE_SCHEMA}
def test_display_fields_are_not_live_config(self) -> None:
extra = {f for f, *_ in _WEB_DISPLAY_SCHEMA}
self.assertEqual(extra & self._config_fields(), set())
self.assertEqual(extra & set(_WEB_LIVE_GETTERS), set())
@@ -55,7 +52,6 @@ class LiveSettingsSchemaCoverageTest(unittest.TestCase):
form_fields = (
set(_WEB_LIVE_GETTERS)
| {f for f, *_ in _WEB_DISPLAY_SCHEMA}
| {f for f, *_ in _WEB_STABLE_SCHEMA}
)
# The web may apply every form field plus the history command, and nothing else.
self.assertEqual(web_apply_field_names(), form_fields | {"history_command"})
+36
View File
@@ -0,0 +1,36 @@
"""The locator test client's framing survives fragmented timeout polls."""
import socket
import threading
import unittest
from unittest.mock import Mock
from locator_test_client import encode_packet, read_packet, recv_exactly
class LocatorClientTest(unittest.TestCase):
def test_partial_header_and_payload_survive_timeouts(self) -> None:
payload = {"vlc": 5.5}
packet = encode_packet(payload, 7)
sock = Mock()
sock.recv.side_effect = [packet[:3], socket.timeout(), packet[3:8],
packet[8:10], socket.timeout(), packet[10:]]
self.assertEqual(read_packet(sock), (7, payload))
def test_timeout_wait_can_be_stopped_without_losing_thread(self) -> None:
stop = threading.Event()
sock = Mock()
def receive(_size):
stop.set()
raise socket.timeout()
sock.recv.side_effect = receive
with self.assertRaisesRegex(ConnectionError, "stopped"):
recv_exactly(sock, 8, stop)
def test_eof_in_the_middle_of_packet_is_reported(self) -> None:
sock = Mock()
sock.recv.side_effect = [b"123", b""]
with self.assertRaisesRegex(ConnectionError, "closed"):
read_packet(sock)
@@ -0,0 +1,126 @@
"""Neutral pair creation must report incomplete sets and roll back failed writes."""
from __future__ import annotations
from pathlib import Path
import tempfile
import unittest
from unittest import mock
import numpy as np
from python_app.models.run_config_model import RunConfigModel
from python_app.scripts import create_neutral_preprocess_sets as cli
from python_app.storage.npz_store import NpzStore
from python_app.workflows.kamil_adc_neutral_preprocess import build_neutral_s21_sets
class NeutralPreprocessCliTest(unittest.TestCase):
def setUp(self) -> None:
self.directory = self.enterContext(tempfile.TemporaryDirectory())
self.root = Path(self.directory)
self.store = NpzStore(self.root)
self.config = RunConfigModel.from_dict({"radar": {
"model": "librevna", "driver_mode": "mock", "sweep": {"points": 7},
}})
calibration, reference = build_neutral_s21_sets(self.config)
calibration.traces[0].s21[:] = 3 + 1j
reference.traces[0].s21[:] = 2 - 1j
self.previous = {"s21_calibration": calibration, "s21_reference": reference}
def _main(self, *options: str) -> int:
with (
mock.patch.object(cli.RunConfigModel, "load_from_path", return_value=self.config),
mock.patch.object(cli, "radar_key_from_config", return_value="radar"),
mock.patch.object(cli, "NpzStore", return_value=self.store),
mock.patch("sys.argv", ["neutral", "--config", "profile.json", "--set-name", "neutral", *options]),
):
return cli.main()
def _files(self) -> dict[str, bytes]:
return {str(path.relative_to(self.root)): path.read_bytes() for path in self.root.rglob("*") if path.is_file()}
def _fail_second_archive(self):
save_archive = np.savez
count = 0
def write(*args, **kwargs):
nonlocal count
count += 1
if count == 2:
raise OSError("reference write failed")
return save_archive(*args, **kwargs)
return mock.patch("python_app.storage.npz.store.np.savez", side_effect=write)
def test_partial_existing_pair_reports_missing_kind_without_overwriting(self) -> None:
for present, missing in [("s21_calibration", "s21_reference"), ("s21_reference", "s21_calibration")]:
with self.subTest(present=present):
self.store = NpzStore(self.root / present)
self.store.save_set(present, "radar", "neutral", self.previous[present])
before = self._files()
with self.assertRaisesRegex(RuntimeError, f"missing {missing}; use --force to replace both"):
self._main()
self.assertEqual(self._files(), before)
def test_complete_existing_pair_is_skipped(self) -> None:
self.store.save_sets("radar", "neutral", self.previous)
before = self._files()
with mock.patch.object(self.store, "save_sets", wraps=self.store.save_sets) as save:
self.assertEqual(self._main(), 0)
save.assert_not_called()
self.assertEqual(self._files(), before)
def test_force_replaces_both_existing_sets_with_neutral_data(self) -> None:
self.store.save_sets("radar", "neutral", self.previous)
self.assertEqual(self._main("--force"), 0)
for kind, value in [("s21_calibration", 1), ("s21_reference", 0)]:
np.testing.assert_array_equal(self.store.load_set(kind, "radar", "neutral").traces[0].s21, value)
self.assertEqual(list(self.root.glob(".sets-*")), [])
def test_force_completes_either_partial_pair(self) -> None:
for present in self.previous:
with self.subTest(present=present):
self.store = NpzStore(self.root / present)
self.store.save_set(present, "radar", "neutral", self.previous[present])
self.assertEqual(self._main("--force"), 0)
for kind, value in [("s21_calibration", 1), ("s21_reference", 0)]:
np.testing.assert_array_equal(self.store.load_set(kind, "radar", "neutral").traces[0].s21, value)
def test_second_archive_failure_restores_previous_pair_byte_for_byte(self) -> None:
self.store.save_sets("radar", "neutral", self.previous)
before = self._files()
with self._fail_second_archive(), self.assertRaisesRegex(OSError, "reference write failed"):
self._main("--force")
self.assertEqual(self._files(), before)
def test_second_archive_failure_removes_new_pair(self) -> None:
with self._fail_second_archive(), self.assertRaisesRegex(OSError, "reference write failed"):
self._main()
self.assertEqual(self._files(), {})
def test_failed_rollback_keeps_the_previous_archive_for_recovery(self) -> None:
self.store.save_sets("radar", "neutral", self.previous)
previous_archive = (self.root / "s21_calibration/radar/neutral.npz").read_bytes()
replace = Path.replace
def replace_file(path: Path, destination: Path):
if path.parent.name.startswith(".sets-") and path.name == "s21_calibration.npz":
raise OSError("restore failed")
return replace(path, destination)
with (
self._fail_second_archive(), mock.patch.object(Path, "replace", replace_file),
self.assertRaisesRegex(RuntimeError, "rollback was incomplete; previous files are retained") as error,
):
self._main("--force")
backups = list(self.root.glob(".sets-*/s21_calibration.npz"))
self.assertEqual(len(backups), 1)
self.assertEqual(backups[0].read_bytes(), previous_archive)
self.assertIn(str(backups[0].parent), str(error.exception))
def test_native_single_is_rejected_before_any_write(self) -> None:
self.config.radar.driver_mode = "native"
with self.assertRaisesRegex(RuntimeError, "not supported for this radar configuration"):
self._main()
self.assertEqual(self._files(), {})
+146 -12
View File
@@ -13,13 +13,16 @@ from __future__ import annotations
import json
import os
import signal
import sys
import tempfile
import time
import unittest
from pathlib import Path
from unittest import mock
from python_app.models.run_config_model import RunConfigModel
from python_app.gui.controllers.app_window_pipeline_mixin import AppWindowPipelineMixin
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
@@ -53,6 +56,64 @@ class RestartPolicyTest(unittest.TestCase):
self.assertTrue(RestartPolicy().should_restart_now(now_s=5_000.0, last_restart_s=0.0, consecutive_failures=0))
class PipelineRecoveryPollingTest(unittest.TestCase):
def setUp(self) -> None:
self.window = mock.Mock(spec=AppWindowPipelineMixin)
self.window._pipeline_should_run = True
self.window._supervisor = mock.Mock()
self.window._supervisor.collect_exit_reports.return_value = []
def test_missing_child_keeps_retrying_without_fresh_exit_reports(self) -> None:
self.window._supervisor.pids.return_value = {"data_processor": 1, "data_preprocessor": 2}
for _ in range(3):
AppWindowPipelineMixin._handle_process_exit_reports(self.window)
self.assertEqual(self.window._recover_pipeline_after_crash.call_count, 3)
def test_failed_start_with_no_children_keeps_retrying(self) -> None:
self.window._supervisor.pids.return_value = {}
for _ in range(3):
AppWindowPipelineMixin._handle_process_exit_reports(self.window)
self.assertEqual(self.window._recover_pipeline_after_crash.call_count, 3)
def test_failed_restarts_retry_after_backoff_without_new_exit_reports(self) -> None:
self.window._supervisor.pids.return_value = {}
self.window._supervisor.start.side_effect = RuntimeError("device unavailable")
self.window._active_run_config = RunConfigModel()
self.window._active_run_config_path = Path("run_config.json")
self.window._pipeline_restart_count = 0
self.window._last_pipeline_restart_s = 0.0
self.window._RESTART_POLICY = RestartPolicy()
self.window._log_error = mock.Mock()
self.window._log_exception = mock.Mock()
self.window._recover_pipeline_after_crash = (
lambda: AppWindowPipelineMixin._recover_pipeline_after_crash(self.window)
)
with mock.patch(
"python_app.gui.controllers.app_window_pipeline_mixin.time.monotonic",
side_effect=[100.0, 101.0, 106.0],
):
for _ in range(3):
AppWindowPipelineMixin._handle_process_exit_reports(self.window)
self.assertEqual(self.window._supervisor.start.call_count, 2)
self.assertEqual(self.window._pipeline_restart_count, 2)
self.assertTrue(self.window._pipeline_should_run)
def test_healthy_pipeline_does_not_restart(self) -> None:
self.window._supervisor.pids.return_value = {
"data_processor": 1, "data_preprocessor": 2, "sweep_orchestrator": 3,
}
AppWindowPipelineMixin._handle_process_exit_reports(self.window)
self.window._recover_pipeline_after_crash.assert_not_called()
def test_explicit_stop_does_not_restart_missing_children(self) -> None:
self.window._pipeline_should_run = False
self.window._supervisor.pids.return_value = {}
AppWindowPipelineMixin._handle_process_exit_reports(self.window)
self.window._recover_pipeline_after_crash.assert_not_called()
class ConfigWriterTest(unittest.TestCase):
def setUp(self) -> None:
self._dir = tempfile.TemporaryDirectory()
@@ -88,23 +149,32 @@ class RadarModelCommandTest(unittest.TestCase):
self.root = Path(self._dir.name)
self.supervisor = ProcessSupervisor(self.root)
def _config_with_model(self, model: str) -> Path:
def _config_with_model(self, model: str, driver_mode: str = "native") -> Path:
path = self.root / "cfg.json"
path.write_text(json.dumps({"radar": {"model": model}}))
path.write_text(json.dumps({"radar": {"model": model, "driver_mode": driver_mode}}))
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:
def test_malformed_root_falls_back_to_native_producer(self) -> None:
path = self.root / "bad.json"
path.write_text("[]") # not an object
self.assertEqual(ProcessSupervisor._read_radar_model(path), "librevna")
path.write_text("[]")
self.assertTrue(self.supervisor._acquisition_command(path)[0].endswith("sweep_orchestrator"))
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_null_replay_directory_does_not_select_replay(self) -> None:
path = self._config_with_model("librevna")
path.write_text(json.dumps({"radar": {"replay": {"dataset_dir": None}}}))
self.assertEqual(self.supervisor._acquisition_command(path)[:3],
[sys.executable, "-m", "python_app.scripts.matrix_raw_producer"])
def test_replay_overrides_every_model(self) -> None:
for model in ("librevna", "librevna_multi", "kamil_adc"):
path = self._config_with_model(model)
path.write_text(json.dumps({"radar": {"model": model, "replay": {"dataset_dir": "capture"}}}))
self.assertEqual(self.supervisor._acquisition_command(path)[:3],
[sys.executable, "-m", "python_app.scripts.replay_raw_producer"])
def test_matrix_producer_for_multi(self) -> None:
cmd = self.supervisor._acquisition_command(self._config_with_model("librevna_multi"))
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"))
@@ -114,6 +184,30 @@ class RadarModelCommandTest(unittest.TestCase):
cmd = self.supervisor._acquisition_command(self._config_with_model("librevna"))
self.assertTrue(cmd[0].endswith("build/bin/sweep_orchestrator"))
def test_single_and_multi_mock_share_the_simulator(self) -> None:
for model in ("librevna", "librevna_multi"):
command = self.supervisor._acquisition_command(self._config_with_model(model, "mock"))
self.assertEqual(command[:3], [sys.executable, "-m", "python_app.scripts.matrix_raw_producer"])
def test_omitted_driver_mode_uses_the_default_simulator(self) -> None:
path = self._config_with_model("librevna")
path.write_text(json.dumps({"radar": {"model": "librevna"}}))
self.assertEqual(self.supervisor._acquisition_command(path)[2], "python_app.scripts.matrix_raw_producer")
def test_missing_or_null_radar_fields_keep_schema_defaults(self) -> None:
path = self.root / "defaults.json"
for payload in ({}, {"radar": None}, {"radar": {}}, {"radar": {"model": None}},
{"radar": {"driver_mode": None}}, {"radar": {"driver_mode": "mock"}}):
with self.subTest(payload=payload):
path.write_text(json.dumps(payload))
self.assertEqual(self.supervisor._acquisition_command(path)[:3],
[sys.executable, "-m", "python_app.scripts.matrix_raw_producer"])
def test_omitted_model_with_native_mode_uses_native_producer(self) -> None:
path = self.root / "native.json"
path.write_text(json.dumps({"radar": {"driver_mode": "native"}}))
self.assertTrue(self.supervisor._acquisition_command(path)[0].endswith("sweep_orchestrator"))
class ProcessExitReportTest(unittest.TestCase):
@staticmethod
@@ -192,6 +286,46 @@ class SupervisorLifecycleTest(unittest.TestCase):
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
def test_stale_pid_guard_matches_executable_or_module_arguments(self) -> None:
processor = str(self.supervisor._project_root / "build/bin/data_processor")
cases = (
(f"{processor}\x00--config\x00config.json\x00", True),
("python\x00-m\x00python_app.scripts.matrix_raw_producer\x00", True),
(f"bash\x00-c\x00echo {processor}\x00", False),
("python\x00-c\x00print('python_app.scripts.matrix_raw_producer')\x00", False),
("/other/project/build/bin/data_processor\x00", False),
)
for cmdline, expected in cases:
with (
self.subTest(cmdline=cmdline),
mock.patch.object(Path, "read_bytes", return_value=cmdline.encode()),
):
self.assertEqual(self.supervisor._is_stale_pipeline_pid(123), expected)
def test_signal_group_does_not_kill_a_non_leaders_group(self) -> None:
with (
mock.patch.object(os, "getpgid", return_value=456),
mock.patch.object(os, "killpg") as group_kill,
mock.patch.object(os, "kill") as kill,
):
self.supervisor._signal_group(123, signal.SIGTERM)
group_kill.assert_not_called()
kill.assert_called_once_with(123, signal.SIGTERM)
def test_collector_reaper_does_not_match_command_text(self) -> None:
entries = [Path("/proc/123"), Path("/proc/456")]
cmdlines = [
b"bash\x00-c\x00echo kamil_adc_collector\x00",
b"/project/build/bin/kamil_adc_collector\x00--config\x00config.json\x00",
]
with (
mock.patch.object(Path, "iterdir", return_value=entries),
mock.patch.object(Path, "read_bytes", side_effect=cmdlines),
mock.patch.object(ProcessSupervisor, "_signal_group") as signal_group,
):
self.supervisor._kill_kamil_collectors()
signal_group.assert_called_once_with(456, signal.SIGKILL)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,349 @@
"""Persistence invariants: complete data, compatible sets and recoverable write failures."""
from __future__ import annotations
import json
from pathlib import Path
import tempfile
import unittest
from unittest import mock
import numpy as np
from python_app.models.dataset_model import (
ComboKey, ResultBlock, ResultCollection, ResultPayload, SweepCollection, TraceData,
)
from python_app.storage.npz.store import NpzStore
from python_app.storage.npz.snapshot_numpy import select_aligned_histories
from python_app.storage.npz.vna_history_json import build_vna_history_payload
from python_app.scripts.replay_raw_producer import _load_dataset
from python_app.scripts.convert_snapshot_to_vna_history import _collection_dirs, _load_stage_records, _build_sweep_history
from python_app.workflows.sequential_capture_workflow import combine_collections_via_median, combine_traces_via_median
def _sweep(cid: int = 1, ns: int = 20, *, start: int = 10, end: int = 15) -> SweepCollection:
return SweepCollection(
collection_id=cid, monotonic_ns=ns, capture_start_ns=start, capture_end_ns=end,
traces=[TraceData(
combo=ComboKey(0, 0), frequency_hz=np.array([1e9, 2e9], dtype=np.float32),
s11=np.array([1 + 2j, 3 - 4j], dtype=np.complex64),
s21=np.array([5 - 6j, 7 + 8j], dtype=np.complex64),
capture_start_ns=start, capture_end_ns=end,
)],
)
def _payloads() -> list[ResultPayload]:
return [
ResultPayload('trace', 1, frequency_hz=np.array([1, 2], dtype=np.float32),
trace=np.array([1 + 2j, 3 - 4j], dtype=np.complex64)),
ResultPayload('scalar', 2, scalar_value=3.5),
ResultPayload('image', 3, image_x_axis=np.array([1, 2], dtype=np.float32),
image_y_axis=np.array([3], dtype=np.float32), image=np.array([[4, 5]], dtype=np.float32)),
ResultPayload('table', 4, table=np.array([[1, 2, 3], [4, 5, 6]], dtype=np.float32)),
]
class PersistenceIntegrityTest(unittest.TestCase):
def setUp(self) -> None:
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.store = NpzStore(self.root / 'sets')
self.output = self.root / 'output'
def test_all_calibration_and_reference_kinds_preserve_arrays_and_timing(self) -> None:
for kind in ('s21_calibration', 's21_reference', 's11_open', 's11_short', 's11_load', 's11_reference'):
with self.subTest(kind=kind):
original = _sweep(7, 100)
second = _sweep().traces[0]
second.combo = ComboKey(2, 3)
original.traces.append(second)
self.store.save_set(kind, 'radar', 'full', original)
loaded = self.store.load_set(kind, 'radar', 'full')
self.assertEqual((loaded.collection_id, loaded.monotonic_ns), (7, 100))
self.assertEqual((loaded.capture_start_ns, loaded.capture_end_ns), (10, 15))
self.assertTrue(self.store.has_combo_coverage(kind, 'radar', 'full', [ComboKey(0, 0), ComboKey(2, 3)]))
self.assertFalse(self.store.has_combo_coverage(kind, 'radar', 'full', [ComboKey(9, 9)]))
for before, after in zip(original.traces, loaded.traces, strict=True):
self.assertEqual(before.combo, after.combo)
self.assertEqual((after.capture_start_ns, after.capture_end_ns), (10, 15))
for field in ('frequency_hz', 's11', 's21'):
np.testing.assert_array_equal(getattr(before, field), getattr(after, field))
def test_new_set_remains_loadable_without_json_sidecar(self) -> None:
self.store.save_set('s21_reference', 'radar', 'full', _sweep())
(self.root / 'sets/s21_reference/radar/full.json').unlink()
loaded = self.store.load_set('s21_reference', 'radar', 'full')
np.testing.assert_array_equal(loaded.traces[0].s21, _sweep().traces[0].s21)
def test_legacy_npz_json_pair_is_still_loadable(self) -> None:
self.store.save_set('s21_reference', 'radar', 'full', _sweep())
archive = self.root / 'sets/s21_reference/radar/full.npz'
with np.load(archive, allow_pickle=False) as arrays:
legacy = {key: arrays[key] for key in arrays.files if key != 'metadata_json'}
np.savez(archive, **legacy)
self.assertEqual(self.store.load_set('s21_reference', 'radar', 'full').monotonic_ns, 20)
def test_failed_set_commit_preserves_previous_data_and_sidecar(self) -> None:
self.store.save_set('s21_reference', 'radar', 'full', _sweep(1, 20))
original_meta = (self.root / 'sets/s21_reference/radar/full.json').read_bytes()
replace = Path.replace
def fail_archive_commit(path, destination):
if Path(destination).suffix == '.npz':
raise OSError('disk full during commit')
return replace(path, destination)
with mock.patch.object(Path, 'replace', fail_archive_commit), self.assertRaises(OSError):
self.store.save_set('s21_reference', 'radar', 'full', _sweep(2, 200))
self.assertEqual(self.store.load_set('s21_reference', 'radar', 'full').collection_id, 1)
self.assertEqual((self.root / 'sets/s21_reference/radar/full.json').read_bytes(), original_meta)
self.assertEqual(list(self.root.rglob('*.tmp')), [])
def test_failed_new_set_commit_does_not_publish_a_sidecar_only_set(self) -> None:
replace = Path.replace
def fail_archive_commit(path, destination):
if Path(destination).suffix == '.npz':
raise OSError('commit failed')
return replace(path, destination)
with mock.patch.object(Path, 'replace', fail_archive_commit), self.assertRaises(OSError):
self.store.save_set('s21_reference', 'radar', 'full', _sweep())
self.assertEqual(self.store.list_sets('s21_reference', 'radar'), [])
self.assertEqual(list(self.root.rglob('*.json')), [])
self.assertEqual(list(self.root.rglob('*.tmp')), [])
def test_failed_sidecar_commit_restores_previous_archive_without_rewriting_it(self) -> None:
self.store.save_set('s21_reference', 'radar', 'full', _sweep(1, 20))
original = (self.root / 'sets/s21_reference/radar/full.npz').read_bytes()
replace = Path.replace
def fail_metadata_commit(path, destination):
if Path(destination).suffix == '.json':
raise OSError('metadata commit failed')
return replace(path, destination)
with mock.patch.object(Path, 'replace', fail_metadata_commit), self.assertRaises(OSError):
self.store.save_set('s21_reference', 'radar', 'full', _sweep(2, 200))
self.assertEqual((self.root / 'sets/s21_reference/radar/full.npz').read_bytes(), original)
self.assertEqual(self.store.load_set('s21_reference', 'radar', 'full').collection_id, 1)
self.assertEqual(list(self.root.rglob('*.tmp')), [])
def test_invalid_sets_are_rejected_before_overwriting_valid_data(self) -> None:
self.store.save_set('s21_calibration', 'radar', 'full', _sweep())
invalid = [_sweep(), _sweep(), _sweep(), _sweep()]
invalid[0].traces.append(invalid[0].traces[0])
invalid[1].traces[0].s21 = np.array([1], dtype=np.complex64)
invalid[2].traces[0].frequency_hz = np.array([[1, 2]], dtype=np.float32)
invalid[3].traces[0].s11[0] = complex(float('nan'), 0)
for collection in invalid:
with self.subTest(collection=collection), self.assertRaises(ValueError):
self.store.save_set('s21_calibration', 'radar', 'full', collection)
self.assertEqual(self.store.load_set('s21_calibration', 'radar', 'full').monotonic_ns, 20)
def test_set_names_cannot_escape_store(self) -> None:
for name in ('../escape', 'sub/name', '..', '.', '', r'sub\name'):
with self.subTest(name=name), self.assertRaises(ValueError):
self.store.save_set('s21_reference', 'radar', name, _sweep())
def test_invalid_channel_identifiers_are_rejected_without_truncation(self) -> None:
for position in (0.5, True, -1, 2 ** 32):
sweep = _sweep()
sweep.traces[0].combo = ComboKey(position, 0)
with self.subTest(position=position), self.assertRaises(ValueError):
self.store.save_set('s21_reference', 'radar', 'invalid', sweep)
self.assertEqual(self.store.list_sets('s21_reference', 'radar'), [])
def test_corrupted_metadata_types_are_rejected_without_truncation(self) -> None:
self.store.save_set('s21_reference', 'radar', 'full', _sweep())
archive = self.root / 'sets/s21_reference/radar/full.npz'
with np.load(archive, allow_pickle=False) as stored:
arrays = {key: stored[key] for key in stored.files}
original = json.loads(str(arrays['metadata_json'].item()))
for malformed in ([], {**original, 'collection_id': 1.5},
{**original, 'monotonic_ns': True},
{**original, 'combos': [{**original['combos'][0], 'input': 0.5}]}):
with self.subTest(metadata=malformed):
np.savez(archive, **{**arrays, 'metadata_json': np.asarray(json.dumps(malformed))})
with self.assertRaisesRegex(RuntimeError, 'Corrupted preprocess set'):
self.store.load_set('s21_reference', 'radar', 'full')
def test_complete_snapshot_keeps_unmatched_stage_tails(self) -> None:
raw = [_sweep(1, 10), _sweep(2, 30)]
pre = [_sweep(1, 20), _sweep(3, 40)]
result = [ResultCollection(1, 20)]
directory, summary = self.store.save_runtime_snapshot_numpy(
self.output, 'full', raw, pre, result, 1000, include_unaligned=True,
)
self.assertEqual((summary['raw_count'], summary['preprocessed_count'], summary['result_count']), (2, 2, 1))
for stage, ids in [('raw', [1, 2]), ('preprocessed', [1, 3]), ('results', [1])]:
metadata = [json.loads(path.read_text()) for path in sorted((directory / stage).glob('*/meta.json'))]
self.assertEqual([item['collection_id'] for item in metadata], ids)
def test_snapshot_result_payloads_preserve_every_kind_at_both_scopes(self) -> None:
payloads = _payloads()
result = ResultCollection(1, 20, processing_duration_ns=12345,
collection_payloads=payloads, blocks=[ResultBlock(ComboKey(0, 0), payloads)])
directory, _ = self.store.save_runtime_snapshot_numpy(self.output, 'full', [], [], [result], 1000)
collection_dir = next((directory / 'results').iterdir())
meta = json.loads((collection_dir / 'meta.json').read_text())
self.assertEqual(meta['processing_duration_ns'], 12345)
for base, entries in [(collection_dir, meta['collection_payloads']),
(collection_dir / meta['blocks'][0]['dir'], meta['blocks'][0]['payloads'])]:
self.assertEqual([entry['kind'] for entry in entries], [1, 2, 3, 4])
np.testing.assert_array_equal(np.load(base / entries[0]['freq_file']), payloads[0].frequency_hz)
np.testing.assert_array_equal(np.load(base / entries[0]['trace_file']), payloads[0].trace)
np.testing.assert_array_equal(np.load(base / entries[1]['scalar_file']), np.array([3.5], dtype=np.float32))
np.testing.assert_array_equal(np.load(base / entries[2]['x_axis_file']), payloads[2].image_x_axis)
np.testing.assert_array_equal(np.load(base / entries[2]['y_axis_file']), payloads[2].image_y_axis)
np.testing.assert_array_equal(np.load(base / entries[2]['image_file']), payloads[2].image)
np.testing.assert_array_equal(np.load(base / entries[3]['table_file']), payloads[3].table)
def test_bad_result_payload_rolls_back_entire_snapshot(self) -> None:
for payload in (ResultPayload('unknown', 9), ResultPayload('bad_table', 4, table=np.array([1, 2]))):
with self.subTest(kind=payload.kind), self.assertRaises(ValueError):
self.store.save_runtime_snapshot_numpy(
self.output, 'failed', [_sweep()], [], [ResultCollection(1, 20, collection_payloads=[payload])], 1000,
)
self.assertFalse((self.output / 'failed').exists())
self.assertEqual(list(self.output.iterdir()), [])
def test_snapshot_write_failure_can_be_retried_with_same_name(self) -> None:
with mock.patch('numpy.save', side_effect=OSError('disk full')), self.assertRaises(OSError):
self.store.save_runtime_snapshot_numpy(self.output, 'retry', [_sweep()], [], [], 1000)
directory, _ = self.store.save_runtime_snapshot_numpy(self.output, 'retry', [_sweep()], [], [], 1000)
self.assertTrue(directory.exists())
with self.assertRaises(FileExistsError):
self.store.save_runtime_snapshot_numpy(self.output, 'retry', [_sweep()], [], [], 1000)
def test_stream_failure_keeps_prior_chunks_and_retries_without_partial_data(self) -> None:
writer = self.store.create_snapshot_stream(self.output, 'stream')
writer.append([_sweep()], [], [ResultCollection(1, 20)])
with mock.patch('numpy.save', side_effect=OSError('disk full')), self.assertRaises(OSError):
writer.append([_sweep(2, 30)], [], [ResultCollection(2, 40)])
self.assertEqual(writer.result_count, 1)
self.assertEqual(len(list((writer.directory / 'raw').iterdir())), 1)
self.assertEqual(list(writer.directory.glob('.chunk-*')), [])
writer.append([_sweep(2, 30)], [], [ResultCollection(2, 40)])
self.assertEqual(writer.result_count, 2)
self.assertEqual([path.name.split('_')[0] for path in sorted((writer.directory / 'raw').iterdir())], ['0000', '0001'])
def test_json_export_preserves_unmatched_raw_sweeps_and_matches_reused_ids_by_timing(self) -> None:
raw_old = _sweep(1, 10, start=1, end=5)
raw_new = _sweep(1, 100, start=90, end=95)
raw_new.traces[0].s21[:] = 99
pre_new = _sweep(1, 110, start=90, end=95)
payload = build_vna_history_payload([raw_old, raw_new], [pre_new], [], input_index=0, output_index=0)
self.assertEqual(len(payload['sweep_history']), 2)
self.assertEqual(payload['sweep_history'][-1]['sweep_points'], [[99.0, 0.0], [99.0, 0.0]])
self.assertEqual(payload['sweep_history'][-1]['calibrated_points'], [[5.0, -6.0], [7.0, 8.0]])
def test_json_batch_failure_does_not_publish_partial_export(self) -> None:
sweep = _sweep()
second = _sweep().traces[0]
second.combo = ComboKey(0, 1)
sweep.traces.append(second)
write_text = Path.write_text
writes = 0
def fail_second_file(path, *args, **kwargs):
nonlocal writes
writes += 1
if writes == 2:
raise OSError('disk full')
return write_text(path, *args, **kwargs)
with mock.patch.object(Path, 'write_text', fail_second_file), self.assertRaises(OSError):
self.store.save_runtime_vna_history_json_batch(self.output, 'export', [sweep], [], [], 1000)
self.assertEqual(list(self.output.iterdir()), [])
paths, summary = self.store.save_runtime_vna_history_json_batch(self.output, 'export', [sweep], [], [], 1000, channel='s11')
self.assertEqual(summary['combo_count'], 2)
self.assertEqual(len(paths), 2)
for path in paths:
self.assertEqual(json.loads(path.read_text())['channel'], 's11')
def test_saved_dataset_replay_preserves_s11_and_accepts_legacy_s21_only(self) -> None:
directory, _ = self.store.save_runtime_snapshot_numpy(self.output, 'replay', [_sweep()], [], [], 1000)
dataset = _load_dataset(directory / 'raw', [ComboKey(0, 0)], 2)
np.testing.assert_array_equal(dataset.snapshots[0][0].s11, _sweep().traces[0].s11)
self.assertEqual(dataset.snapshots[0][0].capture_end_ns, 15)
next((directory / 'raw').glob('*/i0_o0_s11.npy')).unlink()
legacy = _load_dataset(directory / 'raw', [ComboKey(0, 0)], 2)
np.testing.assert_array_equal(legacy.snapshots[0][0].s11, np.zeros(2, dtype=np.complex64))
def test_long_recording_replay_and_converter_use_numeric_directory_order(self) -> None:
writer = self.store.create_snapshot_stream(self.output, 'long')
writer.append([_sweep(1, 10), _sweep(2, 20), _sweep(3, 30)], [], [])
folders = sorted((writer.directory / 'raw').iterdir())
for folder, index in zip(folders, (9998, 9999, 10000), strict=True):
folder.rename(folder.with_name(f'{index}_{folder.name.split("_", 1)[1]}'))
dataset = _load_dataset(writer.directory / 'raw', [ComboKey(0, 0)], 2)
self.assertEqual(dataset.timestamps_ns, [10, 20, 30])
self.assertEqual([int(path.name.split('_')[0]) for path in _collection_dirs(writer.directory / 'raw')], [9998, 9999, 10000])
def test_offline_and_runtime_json_exports_share_matching_and_preserve_orphan_sweeps(self) -> None:
raw = [_sweep(1, 10, start=1, end=5), _sweep(1, 100, start=90, end=95)]
pre = [_sweep(1, 110, start=90, end=95)]
directory, _ = self.store.save_runtime_snapshot_numpy(
self.output, 'convert', raw, pre, [], 1000, include_unaligned=True,
)
records = {
stage: _load_stage_records(directory, stage, input_index=0, output_index=0, channel='s21')
for stage in ('raw', 'preprocessed')
}
offline = _build_sweep_history(records['raw'], records['preprocessed'], channel='s21', primary_stage='preprocessed')
runtime = build_vna_history_payload(raw, pre, [], input_index=0, output_index=0)
self.assertEqual(offline, runtime['sweep_history'])
self.assertEqual(len(offline), 2)
class SnapshotAlignmentTest(unittest.TestCase):
def test_reused_ids_and_missing_frames_do_not_cross_align_runs(self) -> None:
raw_old = _sweep(1, 10, start=1, end=5)
pre_old = _sweep(1, 20, start=1, end=5)
pre_new = _sweep(1, 200, start=150, end=175)
selected_raw, selected_pre, results, summary = select_aligned_histories(
[raw_old], [pre_old, pre_new], [ResultCollection(1, 200)], 1,
)
self.assertEqual(selected_raw, [])
self.assertEqual(selected_pre, [pre_new])
self.assertEqual(summary['raw_missing_count'], 1)
self.assertEqual(results[0].monotonic_ns, 200)
def test_result_does_not_pair_with_wrong_preprocessed_timestamp(self) -> None:
_, pre, _, summary = select_aligned_histories([], [_sweep(1, 20)], [ResultCollection(1, 200)], 1000)
self.assertEqual(pre, [])
self.assertEqual(summary['preprocessed_missing_count'], 1)
def test_independent_stage_limit_is_exactly_requested_tail(self) -> None:
raw = [_sweep(cid=index, ns=index) for index in range(1005)]
selected_raw, _, _, _ = select_aligned_histories(raw, [], [], 1000, include_unaligned=True)
self.assertEqual(len(selected_raw), 1000)
self.assertEqual((selected_raw[0].collection_id, selected_raw[-1].collection_id), (5, 1004))
class CalibrationMedianIntegrityTest(unittest.TestCase):
def test_rejects_frequency_axes_that_changed_between_captures(self) -> None:
first, second = _sweep(), _sweep()
second.traces[0].frequency_hz[1] += 1e8
with self.assertRaisesRegex(RuntimeError, 'frequency-axis'):
combine_traces_via_median([first.traces[0], second.traces[0]])
def test_rejects_extra_combinations_instead_of_silently_discarding_them(self) -> None:
first, second = _sweep(), _sweep()
extra = _sweep().traces[0]
extra.combo = ComboKey(0, 1)
second.traces.append(extra)
with self.assertRaisesRegex(RuntimeError, 'trace counts'):
combine_collections_via_median([first, second])
def test_collection_and_trace_windows_span_all_median_captures(self) -> None:
first, second = _sweep(start=10, end=15), _sweep(start=30, end=35)
combined = combine_collections_via_median([first, second])
self.assertEqual((combined.capture_start_ns, combined.capture_end_ns), (10, 35))
self.assertEqual((combined.traces[0].capture_start_ns, combined.traces[0].capture_end_ns), (10, 35))
if __name__ == '__main__':
unittest.main()
+198
View File
@@ -0,0 +1,198 @@
"""Analytical checks for one coherent moving reflector across mock radar modes."""
from __future__ import annotations
from pathlib import Path
import unittest
from unittest import mock
import numpy as np
from python_app.hardware_full.matrix_radar_service import create_acquisition_service
from python_app.hardware_full.point_target_mock_service import PointTargetMockService
from python_app.hardware_full.single_radar_service import create_single_radar_service
from python_app.models.dataset_model import ComboKey
from python_app.models.run_config_model import RunConfigModel
from python_app.models.run_config_schema import GprRxGeometryModel, GprTxGeometryModel
from python_app.workflows.multi_radar_capture_workflow import MultiRadarSequentialCaptureSession
from python_app.workflows.radar_config_variants import RadarConfigVariant
from python_app.workflows.sequential_capture_workflow import SequentialCaptureSession
_EPOCH_NS = 10_000_000_000
_CLOCK = "python_app.hardware_full.point_target_mock_service.time.monotonic_ns"
_SLEEP = "python_app.hardware_full.point_target_mock_service.time.sleep"
def _config() -> RunConfigModel:
config = RunConfigModel()
config.radar.model = RunConfigModel.LIBREVNA_MODEL
config.radar.driver_mode = "mock"
config.radar.sweep.start_hz = 300e6
config.radar.sweep.stop_hz = 900e6
config.radar.sweep.points = 32
config.input_switch.positions = config.output_switch.positions = 2
config.input_switch.driver_mode = config.output_switch.driver_mode = "mock"
config.runtime.settling_ms = 0
config.combos = config.build_runtime_combos()
config.gpr.tx_geometry = [GprTxGeometryModel(0, -1), GprTxGeometryModel(1, 1)]
config.gpr.rx_geometry = [GprRxGeometryModel(0, -.4, .1, .2), GprRxGeometryModel(1, .5, -.1, .3)]
return config
class PointTargetMockTest(unittest.TestCase):
def setUp(self) -> None:
self.clock = self.enterContext(mock.patch(_CLOCK, return_value=_EPOCH_NS))
self.sleep = self.enterContext(mock.patch(_SLEEP))
self.config = _config()
self.service = PointTargetMockService(self.config)
self.service.configure(self.config.radar.sweep)
def test_all_channels_follow_bistatic_and_monostatic_distance(self) -> None:
collection = self.service.acquire_collection(collection_id=43)
target = np.array([0, .25, 3.75])
self.assertEqual(collection.collection_id, 43)
self.assertEqual([(t.combo.input, t.combo.output) for t in collection.traces], [(0, 0), (1, 0), (0, 1), (1, 1)])
for trace in collection.traces:
tx = self.config.gpr.tx_geometry[trace.combo.output]
rx = self.config.gpr.rx_geometry[trace.combo.input]
r_tx = np.linalg.norm(target - [tx.x_m, tx.y_m, tx.z_m])
r_rx = np.linalg.norm(target - [rx.x_m, rx.y_m, rx.z_m])
phase_per_m = -2j * np.pi * trace.frequency_hz.astype(np.float64) / 3e8
np.testing.assert_allclose(trace.s21, .5 / (r_tx * r_rx) * np.exp(phase_per_m * (r_tx + r_rx)), rtol=1e-6)
np.testing.assert_allclose(trace.s11, .5 / r_tx**2 * np.exp(phase_per_m * 2 * r_tx), rtol=1e-6)
self.assertEqual((trace.capture_start_ns, trace.capture_end_ns), (_EPOCH_NS, _EPOCH_NS))
self.assertEqual(trace.s21.dtype, np.dtype(np.complex64))
self.assertEqual((collection.capture_start_ns, collection.capture_end_ns, collection.monotonic_ns), (_EPOCH_NS,) * 3)
def test_coincident_antennas_have_identical_traces_regardless_of_channel_number(self) -> None:
self.config.gpr.tx_geometry = [GprTxGeometryModel(0, 1), GprTxGeometryModel(1, 1)]
self.config.gpr.rx_geometry = [GprRxGeometryModel(0, -.5), GprRxGeometryModel(1, -.5)]
traces = self.service.acquire_collection().traces
for trace in traces[1:]:
np.testing.assert_array_equal(trace.s21, traces[0].s21)
np.testing.assert_array_equal(trace.s11, traces[0].s11)
def test_geometry_changes_receiver_path_without_changing_transmitter_echo(self) -> None:
before = self.service.acquire_collection().traces[0]
self.config.gpr.rx_geometry[0].z_m = 1
after = self.service.acquire_collection().traces[0]
self.assertFalse(np.array_equal(before.s21, after.s21))
np.testing.assert_array_equal(before.s11, after.s11)
def test_motion_uses_elapsed_time_and_returns_after_eight_seconds(self) -> None:
tx = {0: (-1., 0., 0.), 1: (1., 0., 0.)}
rx = {0: (-.4, .1, .2), 1: (.5, -.1, .3)}
for seconds, displacement in [(0, 0), (2, 1), (4, 0), (6, -1), (8, 0)]:
np.testing.assert_allclose(
self.service._target_position(_EPOCH_NS + seconds * 1_000_000_000, tx, rx),
[1.25 * displacement, .25, 3.75 + .5 * displacement], atol=1e-12,
)
first = self.service.acquire_collection(1)
repeated = self.service.acquire_collection(999)
for a, b in zip(first.traces, repeated.traces):
np.testing.assert_array_equal(a.s21, b.s21)
self.clock.return_value = _EPOCH_NS + 2_000_000_000
moved = self.service.acquire_collection(1)
self.assertFalse(np.array_equal(first.traces[0].s21, moved.traces[0].s21))
def test_pacing_excludes_wait_from_measurement_window(self) -> None:
current_ns = _EPOCH_NS
def sleep(seconds: float) -> None:
nonlocal current_ns
current_ns += round(seconds * 1e9)
self.clock.side_effect = lambda: current_ns
self.sleep.side_effect = sleep
first = self.service.acquire_collection()
current_ns += 20_000_000
second = self.service.acquire_collection()
self.sleep.assert_called_once()
self.assertAlmostEqual(self.sleep.call_args.args[0], .03)
self.assertEqual(second.capture_start_ns - first.capture_start_ns, 50_000_000)
self.assertEqual(second.capture_start_ns, second.capture_end_ns)
def test_full_motion_stays_inside_original_sweep_unambiguous_range(self) -> None:
# The existing 201-point 1 MHz–6 GHz profile aliases paths beyond about 5 m.
# Check its actual 4x4 geometry throughout the cycle, including both extremes.
self.config.input_switch.positions = self.config.output_switch.positions = 4
self.config.combos = self.config.build_runtime_combos()
tx = {i: (x, 0., 0.) for i, x in enumerate([.905, -.905, .905, -.905])}
rx = {i: (x, 0., 0.) for i, x in enumerate([-.18, .485, -.49, .185])}
self.config.gpr.tx_geometry = [GprTxGeometryModel(i, *position) for i, position in tx.items()]
self.config.gpr.rx_geometry = [GprRxGeometryModel(i, *position) for i, position in rx.items()]
service = PointTargetMockService(self.config)
unambiguous_range_m = 3e8 / (2 * ((6e9 - 1e6) / (201 - 1)))
ranges = []
for seconds in np.linspace(0, 8, 81):
target = service._target_position(_EPOCH_NS + round(seconds * 1e9), tx, rx)
for transmitter in tx.values():
for receiver in rx.values():
ranges.append((np.linalg.norm(target - transmitter) + np.linalg.norm(target - receiver)) / 2)
self.assertLess(max(ranges), unambiguous_range_m)
def test_single_manual_capture_selects_actual_antenna_and_keeps_capture_time(self) -> None:
session = SequentialCaptureSession(self.config, "s21_calibration", "mock_geometry")
session.open()
try:
first = session.capture_current_combo()
second = session.capture_current_combo()
finally:
session.close()
self.assertEqual((first.combo, second.combo), (ComboKey(0, 0), ComboKey(1, 0)))
self.assertFalse(np.array_equal(first.s21, second.s21))
self.assertEqual((second.capture_start_ns, second.capture_end_ns), (_EPOCH_NS, _EPOCH_NS))
def test_factories_share_scene_and_mock_widened_matrix_needs_no_hardware(self) -> None:
single = create_single_radar_service(self.config)
self.assertIsInstance(single, PointTargetMockService)
single.configure(self.config.radar.sweep)
self.assertIsInstance(create_acquisition_service(self.config), PointTargetMockService)
self.config.radar.model = RunConfigModel.LIBREVNA_MULTI_MODEL
self.config.radar.multi_device.stimulus_switch_positions = 2
self.config.radar.multi_device.input_switch_positions = 2
self.config.radar.multi_device.output_switch_positions = 2
self.config.apply_device_model_constraints()
matrix = create_acquisition_service(self.config)
self.assertIsInstance(matrix, PointTargetMockService)
matrix.open()
matrix.configure(self.config.radar.sweep)
collection = matrix.acquire_collection()
matrix.close()
self.assertEqual(len(collection.traces), 64)
self.assertEqual([(t.combo.input, t.combo.output) for t in collection.traces], [(i, o) for o in range(8) for i in range(8)])
np.testing.assert_array_equal(collection.traces[0].s21, single.acquire().s21)
def test_multi_variant_single_capture_drains_each_variant_before_sampling(self) -> None:
radar = mock.Mock(wraps=self.service)
events = []
radar.drain_after_switch = mock.Mock(side_effect=lambda: events.append("drain"))
radar.acquire.side_effect = lambda: (events.append("acquire"), self.service.acquire())[1]
variants = [RadarConfigVariant(Path(name), name, self.config, name) for name in ("a", "b")]
with mock.patch(
"python_app.workflows.multi_radar_capture_workflow.create_single_radar_service",
return_value=radar,
):
session = MultiRadarSequentialCaptureSession(
base_config=self.config, kind="s21_calibration", set_name="variants", radar_variants=variants,
)
session.open()
try:
session.capture_current_combo()
batch = session.capture_current_combo()
finally:
session.close()
self.assertEqual(events, ["drain", "acquire"] * 4)
self.assertEqual([trace.combo for trace in batch.traces], [ComboKey(1, 0)] * 2)
for trace in batch.traces:
self.assertEqual((trace.capture_start_ns, trace.capture_end_ns), (_EPOCH_NS, _EPOCH_NS))
def test_invalid_or_unconfigured_mock_fails_clearly(self) -> None:
with self.assertRaisesRegex(ValueError, "outside the antenna matrix"):
self.service.acquire_combo_collection(input_pos=2, output_pos=0)
with self.assertRaisesRegex(RuntimeError, "not configured"):
PointTargetMockService(self.config).acquire_collection()
self.config.input_switch.positions = 0
self.config.combos = []
with self.assertRaisesRegex(ValueError, "Switch positions must be positive integers"):
PointTargetMockService(self.config)
+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()
+183 -8
View File
@@ -1,8 +1,8 @@
"""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,
The recorder preserves each received stage independently, 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.
"""
@@ -10,11 +10,17 @@ hardware, or disk needed.
from __future__ import annotations
from pathlib import Path
import threading
import tempfile
import unittest
from unittest import mock
import numpy as np
from python_app.gui.controllers import app_window_recording_mixin as rec
from python_app.gui.controllers.app_window_recording_mixin import AppWindowRecordingMixin
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.storage.npz.store import NpzStore
class _Collection:
@@ -112,6 +118,7 @@ class _Harness(AppWindowRecordingMixin):
def _start_run(self) -> None:
self.started = True
self._supervisor._running = True
def _show_error(self, message, *, details=None) -> None:
self.errors.append(message)
@@ -148,6 +155,46 @@ class _RecordingTestBase(unittest.TestCase):
class RecordingArmTest(_RecordingTestBase):
def test_writer_thread_start_failure_cleans_new_dataset_and_allows_retry(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
h = self._make(count=5, running=True)
h._store = NpzStore(Path(temporary) / "sets")
h._save_path_input = _Text(str(Path(temporary) / "datasets"))
destination = Path(temporary) / "datasets/pref_run"
def write_profile(path, allow_overwrite):
path.write_text("fresh profile", encoding="utf-8")
with mock.patch.object(h, "_write_gui_profile_to_path", side_effect=write_profile):
with mock.patch.object(rec.threading.Thread, "start", side_effect=RuntimeError("cannot start new thread")):
h._start_run_with_recording()
self.assertFalse(destination.exists())
self.assertFalse(h._recording_active)
self.assertFalse(h._recording_collecting)
self.assertIsNone(h._recording_thread)
self.assertIsNone(h._recording_writer)
self.assertIsNone(h._recording_queue)
self.assertEqual(h._recording_target, 0)
self.assertEqual(len(h.exceptions), 1)
h._start_run_with_recording()
self.assertTrue(h._recording_active)
self.assertEqual((destination / "config_profile.json").read_text(), "fresh profile")
def test_existing_profile_is_preserved_when_recording_destination_collides(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
h = self._make(count=5, running=True)
h._store = NpzStore(Path(temporary) / "sets")
h._save_path_input = _Text(str(Path(temporary) / "datasets"))
destination = Path(temporary) / "datasets/pref_run"
destination.mkdir(parents=True)
profile = destination / "config_profile.json"
profile.write_bytes(b"existing profile")
with mock.patch.object(rec.threading.Thread, "start", side_effect=RuntimeError("cannot start new thread")) as start:
h._start_run_with_recording()
start.assert_not_called()
self.assertEqual(profile.read_bytes(), b"existing profile")
self.assertFalse(h._recording_active)
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()
@@ -178,6 +225,43 @@ class RecordingArmTest(_RecordingTestBase):
self.assertEqual(len(h.errors), 1)
self.assertTrue(h._recording_active)
def test_failed_pipeline_start_does_not_leave_an_empty_recorder_armed(self) -> None:
h = self._make(count=5, running=False)
with mock.patch.object(h, '_start_run'), mock.patch.object(rec.shutil, 'rmtree') as remove:
h._start_run_with_recording()
self.assertFalse(h._recording_active)
self.assertFalse(h._recording_collecting)
self.assertIsNone(h._recording_thread)
remove.assert_called_once_with(h._store.writer.directory)
self.assertTrue(any('acquisition did not start' in message for message in h.logs))
def test_failed_pipeline_start_retains_received_raw_data_and_profile(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
h = self._make(count=5, running=False)
h._store = NpzStore(Path(temporary) / 'sets')
h._save_path_input = _Text(str(Path(temporary) / 'datasets'))
samples = np.array([1 + 2j], dtype=np.complex64)
def failed_start():
h._record_collection('raw', SweepCollection(
collection_id=1, monotonic_ns=h._recording_since_ns + 1,
traces=[TraceData(ComboKey(0, 0), np.array([1e9], dtype=np.float32), samples, samples)],
))
def write_profile(path, allow_overwrite):
path.write_text('retained configuration', encoding='utf-8')
with mock.patch.object(h, '_start_run', side_effect=failed_start), mock.patch.object(
h, '_write_gui_profile_to_path', side_effect=write_profile,
):
h._start_run_with_recording()
destination = Path(temporary) / 'datasets/pref_run'
self.assertFalse(h._recording_active)
self.assertEqual((destination / 'config_profile.json').read_text(), 'retained configuration')
files = list((destination / 'raw').glob('*/i0_o0_s21.npy'))
self.assertEqual(len(files), 1)
np.testing.assert_array_equal(np.load(files[0]), samples)
class RecordingStreamTest(_RecordingTestBase):
def test_ignores_collections_from_before_arming(self) -> None:
@@ -208,8 +292,8 @@ class RecordingStreamTest(_RecordingTestBase):
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(tuple(sum(chunk[stage] for chunk in writer.appends) for stage in range(3)), (5, 5, 5))
self.assertTrue(all(max(chunk) <= 2 for chunk in writer.appends))
self.assertEqual(writer.result_count, 5)
self.assertFalse(h._recording_active)
@@ -252,13 +336,104 @@ class RecordingStreamTest(_RecordingTestBase):
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):
def test_raw_stage_streams_when_processor_has_not_returned_any_results(self) -> None:
with mock.patch.object(rec, "_RECORDING_CHUNK_SIZE", 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)
self.assertLess(len(h._recording_chunk_raw), 4)
h._finalize_recording_on_stop()
self.assertEqual(sum(chunk[0] for chunk in h._store.writer.appends), 50)
self.assertEqual(h._store.writer.result_count, 0)
def test_shutdown_flushes_data_without_results(self) -> None:
h = self._make(count=1000, running=True)
h._start_run_with_recording()
h._record_collection("raw", _Collection(1, h._recording_since_ns + 1))
h._record_collection("preprocessed", _Collection(1, h._recording_since_ns + 2))
h._shutdown_recording()
self.assertEqual(h._store.writer.appends, [(1, 1, 0)])
self.assertFalse(h._recording_active)
def test_full_queue_does_not_drop_the_finish_sentinel(self) -> None:
with mock.patch.object(rec, "_RECORDING_QUEUE_CHUNKS", 1), mock.patch.object(rec, "_RECORDING_CHUNK_SIZE", 1):
h = self._make(count=1000, running=True)
h._start_run_with_recording()
entered, release = threading.Event(), threading.Event()
self.addCleanup(release.set)
append = h._store.writer.append
def slow_append(*args):
entered.set()
release.wait(timeout=2)
append(*args)
h._store.writer.append = slow_append
h._record_collection("raw", _Collection(1, h._recording_since_ns + 1))
self.assertTrue(entered.wait(timeout=1))
h._record_collection("preprocessed", _Collection(1, h._recording_since_ns + 2))
self.assertTrue(h._recording_queue.full())
timer = threading.Timer(0.05, release.set)
timer.start()
h._stop_collecting()
timer.join(timeout=1)
self._drain_and_finalize(h)
self.assertEqual(h._store.writer.appends, [(1, 0, 0), (0, 1, 0)])
def test_stop_keeps_a_slow_writer_alive_until_accepted_queue_is_written(self) -> None:
h = self._make(count=1000, running=True)
h._start_run_with_recording()
entered, release = threading.Event(), threading.Event()
self.addCleanup(release.set)
append = h._store.writer.append
def slow_append(*args):
entered.set()
release.wait(timeout=2)
append(*args)
h._store.writer.append = slow_append
h.feed(1)
h._flush_recording_chunk()
self.assertTrue(entered.wait(timeout=1))
h.feed(2)
thread = h._recording_thread
with mock.patch.object(thread, 'join'):
h._finalize_recording_on_stop()
self.assertTrue(h._recording_active)
self.assertIs(h._recording_thread, thread)
self.assertFalse(h._recording_stop.is_set())
release.set()
self._drain_and_finalize(h)
self.assertEqual(h._store.writer.result_count, 2)
def test_write_failure_with_full_queue_does_not_block_the_producer(self) -> None:
with mock.patch.object(rec, "_RECORDING_QUEUE_CHUNKS", 1), mock.patch.object(rec, "_RECORDING_CHUNK_SIZE", 1):
h = self._make(count=1000, running=True)
h._start_run_with_recording()
entered, release = threading.Event(), threading.Event()
self.addCleanup(release.set)
def fail_append(*args):
entered.set()
release.wait(timeout=2)
raise OSError('disk full')
h._store.writer.append = fail_append
h._record_collection("raw", _Collection(1, h._recording_since_ns + 1))
self.assertTrue(entered.wait(timeout=1))
h._record_collection("preprocessed", _Collection(1, h._recording_since_ns + 2))
producer = threading.Thread(
target=h._record_collection,
args=("results", _Collection(1, h._recording_since_ns + 3)), daemon=True,
)
producer.start()
release.set()
producer.join(timeout=1)
self.assertFalse(producer.is_alive())
self.assertFalse(h._recording_active)
self.assertEqual(len(h.errors), 1)
if __name__ == "__main__":
+114
View File
@@ -25,6 +25,7 @@ from python_app.models.run_config_schema import (
)
from python_app.models.run_config_validation import (
parse_combos_from_text,
validate_acquisition_config,
validate_combos,
validate_gpr_model,
validate_ring_endpoint,
@@ -78,6 +79,11 @@ class StrictNumericTypingTest(unittest.TestCase):
class StructuralTypingTest(unittest.TestCase):
"""A present-but-wrong-shaped section fails loudly instead of being dropped."""
def test_root_must_be_an_object(self) -> None:
for payload in (None, [], 'bad', 42):
with self.subTest(payload=payload), self.assertRaisesRegex(ValueError, 'root'):
RunConfigModel.from_dict(payload)
def test_combos_must_be_an_array(self) -> None:
payload = _valid_payload()
payload["run"]["combos"] = {"input": 0, "output": 0}
@@ -85,7 +91,103 @@ class StructuralTypingTest(unittest.TestCase):
RunConfigModel.from_dict(payload)
class AcquisitionValidationTest(unittest.TestCase):
def test_default_combo_expansion_is_bounded_before_allocation(self) -> None:
payload = _valid_payload()
payload["switches"]["port1"]["positions"] = 65536
payload["run"]["combos"] = []
with self.assertRaisesRegex(ValueError, "Too many combos"):
RunConfigModel.from_dict(payload)
config = self.config("librevna_multi")
config.radar.multi_device.input_switch_positions = 1 << 32
with self.assertRaisesRegex(ValueError, "Too many combos"):
config.apply_device_model_constraints()
def config(self, model: str = "librevna") -> RunConfigModel:
config = RunConfigModel()
config.radar.model = model
config.radar.sweep.points = 32
for port, switch in enumerate((config.output_switch, config.input_switch), 1):
switch.radar_port = port
switch.driver = "h7992"
switch.gpio_chip = "/dev/gpiochip0"
switch.pin_a, switch.pin_b = 17, 27
for name in ("raw", "preprocessed", "raw_tap", "preprocessed_tap"):
getattr(config.rings, name).slot_size_bytes = 1 << 20
config.ensure_combos()
return config
def test_undersized_trace_rings_fail_before_start(self) -> None:
config = self.config("librevna_multi")
for name in ("raw", "raw_tap", "preprocessed", "preprocessed_tap"):
ring = getattr(config.rings, name)
ring.name = "/test"
original = ring.slot_size_bytes
ring.slot_size_bytes = 4096
with self.subTest(ring=name), self.assertRaisesRegex(ValueError, f"rings.{name}"):
validate_acquisition_config(config)
ring.slot_size_bytes = original
validate_acquisition_config(config)
def test_kamil_requires_native_mode_except_for_replay(self) -> None:
config = self.config("kamil_adc")
with self.assertRaisesRegex(ValueError, "native"):
validate_acquisition_config(config)
config.radar.replay.dataset_dir = "recorded"
validate_acquisition_config(config)
def test_matrix_validates_physical_switch_instead_of_virtual_axis(self) -> None:
config = self.config("librevna_multi")
config.radar.driver_mode = "native"
config.radar.multi_device.output_switch_positions = 4
config.radar.multi_device.input_switch_positions = 4
config.ensure_combos()
self.assertEqual(config.output_switch.positions, 8)
self.assertEqual(config.input_switch.positions, 16)
validate_acquisition_config(config)
config.radar.multi_device.output_switch_positions = 5
config.ensure_combos()
with self.assertRaisesRegex(ValueError, "supports 1..4"):
validate_acquisition_config(config)
config.radar.driver_mode = "mock"
validate_acquisition_config(config)
def test_bad_switch_settings_are_reported_before_hardware_open(self) -> None:
for field, value in (
("driver_mode", "typo"), ("driver", "typo"), ("default_position", 1),
("radar_port", 0), ("pin_a", -1), ("pin_b", 17), ("gpio_chip", ""),
):
config = self.config()
config.output_switch.driver_mode = "native"
setattr(config.output_switch, field, value)
with self.subTest(field=field), self.assertRaises(ValueError):
validate_acquisition_config(config)
class NotchValidationTest(unittest.TestCase):
def test_malformed_bands_and_tapers_match_native_rejection(self) -> None:
for band in (("1", 2), (True, 2), (None, 2), (1, float("nan")), (2, 1), (1, 1e100)):
payload = _valid_payload()
payload["preprocess"]["notch"]["bands_hz"] = [list(band)]
with self.subTest(band=band), self.assertRaises(ValueError):
RunConfigModel.from_dict(payload)
for field, value in (("taper_width_hz", -1), ("taper_type", "typo")):
payload = _valid_payload()
payload["preprocess"]["notch"][field] = value
with self.subTest(field=field), self.assertRaises(ValueError):
RunConfigModel.from_dict(payload)
class SweepValidationTest(unittest.TestCase):
def test_nonfinite_fields_and_wire_overflow_are_rejected(self) -> None:
for field in ('start_hz', 'stop_hz', 'if_bandwidth_hz', 'power_dbm'):
for value in (float('nan'), float('inf')):
sweep = RadarSweepModel()
setattr(sweep, field, value)
with self.subTest(field=field, value=value), self.assertRaisesRegex(ValueError, field):
validate_sweep_model(sweep)
with self.assertRaisesRegex(ValueError, 'points'):
validate_sweep_model(RadarSweepModel(points=2 ** 32))
def test_points_must_be_positive(self) -> None:
for points in (0, -1):
with self.subTest(points=points), self.assertRaisesRegex(ValueError, "points"):
@@ -102,6 +204,10 @@ class SweepValidationTest(unittest.TestCase):
class ComboValidationTest(unittest.TestCase):
def test_direct_model_positions_cannot_be_silently_truncated(self) -> None:
for value in (0.5, True, '0'):
with self.subTest(value=value), self.assertRaisesRegex(ValueError, 'integer'):
validate_combos([ComboModel(value, 0)], input_positions=2, output_positions=2)
def test_within_bounds_passes(self) -> None:
validate_combos(
[ComboModel(input=0, output=0), ComboModel(input=3, output=1)],
@@ -136,6 +242,9 @@ class ComboValidationTest(unittest.TestCase):
class RingValidationTest(unittest.TestCase):
def test_slot_stride_must_keep_atomic_fields_aligned(self) -> None:
with self.assertRaisesRegex(ValueError, 'multiple of 8'):
validate_ring_endpoint(RingEndpointModel(name='r', slot_size_bytes=9))
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))
@@ -189,6 +298,11 @@ class GprValidationTest(unittest.TestCase):
rx_geometry=[GprRxGeometryModel(input_pos=0, x_m=0.0, y_m=0.0, z_m=0.0)],
))
def test_direct_geometry_positions_cannot_be_silently_truncated(self) -> None:
for value in (0.5, True, '0'):
with self.subTest(value=value), self.assertRaisesRegex(ValueError, 'integer'):
self._validate(self._gpr(tx_geometry=[GprTxGeometryModel(output_pos=value)]))
class ParseCombosFromTextTest(unittest.TestCase):
def test_parses_pairs(self) -> None:
+157
View File
@@ -0,0 +1,157 @@
"""SAR3D projection rendering and visible processor failures, without live resources."""
from __future__ import annotations
import os
from types import SimpleNamespace
import unittest
from unittest import mock
import numpy as np
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtCore import Qt # noqa: E402
from PyQt6.QtWidgets import QApplication, QStackedWidget # noqa: E402
from python_app.gui.controllers.app_window_pipeline_mixin import AppWindowPipelineMixin # noqa: E402
from python_app.gui.controllers.app_window_config.live_processing_mixin import AppWindowLiveProcessingMixin # noqa: E402
from python_app.gui.controllers.app_window_plot_mixin import AppWindowPlotMixin # noqa: E402
from python_app.gui.controllers.app_window_plot.sar3d_plot_mixin import SAR3D_VIEWS, sar3d_status # noqa: E402
from python_app.gui.controllers.app_window_ui_mixin import AppWindowUiMixin # noqa: E402
from python_app.models.dataset_model import ResultCollection, ResultPayload # noqa: E402
def _status(code: int, message: str) -> ResultPayload:
return ResultPayload(processing_name="sar3d_status", kind=4,
table=np.array([[code, *message.encode("utf-8")]], dtype=np.float32))
def _collection(*payloads: ResultPayload) -> ResultCollection:
return ResultCollection(collection_id=5, monotonic_ns=1500, collection_payloads=list(payloads))
def _projections() -> ResultCollection:
x, y, z = np.array([-1, 0, 1]), np.array([-0.5, 0.5]), np.array([1, 2, 3])
axes = [(x, y), (z, y), (x, z), (x, y)]
images = [ResultPayload(processing_name=name, kind=3, image_x_axis=xx, image_y_axis=yy,
image=np.ones((len(yy), len(xx)), dtype=np.float32))
for name, (xx, yy) in zip(SAR3D_VIEWS, axes)]
return _collection(*images, _status(0, "CUDA"),
ResultPayload(processing_name="sar3d_oblique_basis", kind=4,
table=np.array([[1, 0, 0], [0, 1, 0]], dtype=np.float32)),
ResultPayload(processing_name="sar3d_peak", kind=4,
table=np.array([[2, 0.5, -0.2, 2]], dtype=np.float32)))
class _View(AppWindowPlotMixin, AppWindowUiMixin, AppWindowPipelineMixin, AppWindowLiveProcessingMixin):
def __init__(self) -> None:
self._processing_mode = SimpleNamespace(currentText=lambda: "sar3d")
self._plot_stack = QStackedWidget()
self._build_sar3d_plot_page()
class Sar3dPlotTest(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.app = QApplication.instance() or QApplication([])
def setUp(self) -> None:
self.view = _View()
self.addCleanup(self.view._plot_stack.close)
def test_all_four_projections_have_visible_images_and_correct_peak_axes(self) -> None:
self.assertTrue(self.view._draw_results(_projections()))
self.assertEqual(self.view._sar3d_status_label.text(), "SAR3D: ready — CUDA")
positions = {"sar3d_front": (0.5, -0.2), "sar3d_side": (2, -0.2),
"sar3d_top": (0.5, 2), "sar3d_oblique": (0.5, -0.2)}
for name, position in positions.items():
image = self.view._sar3d_images[name]
self.assertTrue(image.isVisible(), name)
self.assertGreater(image.boundingRect().width(), 0)
self.assertGreater(image.boundingRect().height(), 0)
xx, yy = self.view._sar3d_peaks[name].getData()
np.testing.assert_allclose([xx[0], yy[0]], position)
self.assertTrue(all(label.isVisible() for label in self.view._sar3d_axis_labels.values()))
def test_unavailable_warning_clears_previous_images_and_labels_and_shows_reason(self) -> None:
self.view._draw_results(_projections())
message = "CUDA unavailable: драйвер <libcuda>"
self.assertFalse(self.view._draw_results(_collection(_status(3, message))))
self.assertEqual(self.view._sar3d_status_label.text(), f"SAR3D: warning — {message}")
self.assertEqual(self.view._sar3d_status_label.textFormat(), Qt.TextFormat.PlainText)
self.assertTrue(all(not image.isVisible() for image in self.view._sar3d_images.values()))
self.assertTrue(all(not label.isVisible() for label in self.view._sar3d_axis_labels.values()))
def test_no_signal_and_missing_projections_explain_empty_panels(self) -> None:
self.assertFalse(self.view._draw_results(_collection(_status(1, "No signal in selected range"))))
self.assertIn("no signal", self.view._sar3d_status_label.text())
self.assertFalse(self.view._draw_results(_collection()))
self.assertEqual(self.view._sar3d_status_label.text(), "SAR3D: waiting for projection data")
def test_ready_cuda_status_and_clear_restores_waiting(self) -> None:
self.assertTrue(self.view._draw_results(_projections()))
self.assertEqual(self.view._sar3d_status_label.text(), "SAR3D: ready — CUDA")
self.view._clear_sar3d_plot()
self.assertEqual(self.view._sar3d_status_label.text(), "SAR3D: waiting for measurements")
def test_malformed_status_is_a_visible_error_instead_of_a_qt_exception(self) -> None:
for table in (np.empty((0, 0)), np.array([[2, np.nan]]), np.array([[2, 256]]),
np.array([[2, 0.5]]), np.array([[99]]), np.array([2])):
with self.subTest(table=table):
payload = _status(2, "failure")
payload.table = table
self.assertEqual(sar3d_status(_collection(payload)), (2, "Invalid SAR3D status payload"))
self.assertFalse(self.view._draw_results(_collection(payload)))
def test_single_capture_finishes_reported_failure_without_losing_its_result(self) -> None:
for code in (1, 2, 3):
with self.subTest(code=code):
result = _collection(_status(code, "processor explanation"))
self.view._result_history = [result]
self.view._single_capture_active = True
self.view._single_capture_start_ns = 1000
self.view._single_capture_seen_raw = True
self.view._single_capture_target_collection_id = 5
self.view._log = mock.Mock()
self.view._log_error = mock.Mock()
self.view._log_warning = mock.Mock()
self.view._status_label = mock.Mock()
self.view._stop_run = mock.Mock(side_effect=lambda: setattr(self.view, "_single_capture_active", False))
self.assertTrue(self.view._finish_single_capture_if_ready())
self.assertFalse(self.view._single_capture_active)
self.assertEqual(self.view._result_history, [result])
self.assertIn("processor explanation", self.view._sar3d_status_label.text())
state = {1: "no signal in selected range", 2: "error", 3: "warning"}[code]
self.view._status_label.setText.assert_called_once_with(f"Status: {state}")
log = self.view._log_error if code == 2 else self.view._log_warning
log.assert_called_once_with(
f"Single capture: SAR3D {state}: processor explanation")
def test_live_edit_retains_failure_reason_then_recovers_to_ready_images(self) -> None:
self.view._result_history = []
self.view._write_live_processing_config = mock.Mock()
self.view._show_exception = mock.Mock()
for result, expected in ((_collection(_status(2, "CUDA kernel failure")), "error"),
(_collection(_status(3, "Driver initialization unavailable")), "warning"),
(_collection(_status(2, "fewer than two frequency points")), "error"),
(_collection(_status(1, "No signal in selected depth range")), "no signal"),
(_projections(), "ready — CUDA")):
self.view._drain_results_until_quiet = mock.Mock(side_effect=[None, result])
self.view._on_processing_live_settings_changed()
self.assertIn(expected, self.view._sar3d_status_label.text())
self.view._show_exception.assert_not_called()
self.assertTrue(all(image.isVisible() for image in self.view._sar3d_images.values()))
def test_history_redraw_preserves_error_but_stale_revision_clears_old_images(self) -> None:
self.view._result_history = [_collection(_status(2, "processor error"))]
self.view._redraw_after_history_deletion()
self.assertEqual(self.view._sar3d_status_label.text(), "SAR3D: error — processor error")
self.view._draw_results(_projections())
self.view._required_processing_revision = 10
self.view._redraw_after_history_deletion()
self.assertEqual(self.view._sar3d_status_label.text(), "SAR3D: waiting for measurements")
self.assertTrue(all(not image.isVisible() for image in self.view._sar3d_images.values()))
if __name__ == "__main__":
unittest.main()
+144
View File
@@ -13,9 +13,12 @@ Pins the agreed contract:
from __future__ import annotations
import struct
import threading
import time
import unittest
from contextlib import suppress
from pathlib import Path
from unittest import mock
import numpy as np
@@ -119,6 +122,28 @@ class TraceCollectionRoundTripTest(unittest.TestCase):
class ResultCollectionRoundTripTest(unittest.TestCase):
def test_empty_tables_and_images_retain_their_declared_dimensions(self) -> None:
for shape in ((0, 3), (2, 0), (0, 0)):
for kind in (3, 4):
with self.subTest(shape=shape, kind=kind):
payload = ResultPayload('empty', kind, table=np.empty(shape, dtype=np.float32),
image=np.empty(shape, dtype=np.float32),
image_x_axis=np.zeros(shape[1], dtype=np.float32),
image_y_axis=np.zeros(shape[0], dtype=np.float32))
if kind == 4 and shape == (2, 0):
with self.assertRaises(ValueError):
serialize_result_collection(ResultCollection(1, 2, collection_payloads=[payload]))
continue
result = decode_result_collection(serialize_result_collection(
ResultCollection(1, 2, collection_payloads=[payload])))
decoded = result.collection_payloads[0]
self.assertEqual((decoded.image if kind == 3 else decoded.table).shape, shape)
def test_multidimensional_trace_cannot_be_serialized_as_a_flat_trace(self) -> None:
payload = ResultPayload('bad', 1, frequency_hz=np.ones((1, 2)), trace=np.ones((1, 2)))
with self.assertRaises(ValueError):
serialize_result_collection(ResultCollection(1, 2, collection_payloads=[payload]))
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)
@@ -240,6 +265,26 @@ class RingRoundTripTest(_RingTestCase):
class RingOverflowTest(_RingTestCase):
def test_reader_cannot_rewind_cursor_after_producer_laps_during_commit(self) -> None:
from python_app.orchestration.shm import atomics
name = self._ring_name()
writer = self._writer(name, capacity=4, slot_size=64)
reader = self._reader(name)
writer.push(b"f0")
advance = atomics.advance
def lap_before_reader_commit(buffer, offset, desired):
if buffer is reader._mmap and desired == 1:
for i in range(1, 6):
writer.push(f"f{i}".encode())
advance(buffer, offset, desired)
with mock.patch.object(atomics, "advance", side_effect=lap_before_reader_commit):
self.assertEqual(reader.pop_payload(), b"f0")
self.assertEqual(reader._read_u64(32), 2)
self.assertEqual([reader.pop_payload() for _ in range(4)], [b"f2", b"f3", b"f4", b"f5"])
def test_latest_wins_drops_oldest(self) -> None:
name = self._ring_name()
writer = self._writer(name, capacity=4, slot_size=64)
@@ -254,6 +299,26 @@ class RingOverflowTest(_RingTestCase):
class PeekLatestTest(_RingTestCase):
def test_peek_rejects_a_slot_while_it_is_being_overwritten(self) -> None:
name = self._ring_name()
writer = self._writer(name, capacity=1, slot_size=8)
reader = self._reader(name)
writer.push(b"aaaaaaaa")
observations = []
write_size = writer._write_u32
def overwrite_in_progress(offset: int, value: int) -> None:
write_size(offset, value)
if offset == 64:
writer._mmap[80:84] = b"bbbb"
observations.append(reader.peek_latest_payload())
with mock.patch.object(writer, "_write_u32", side_effect=overwrite_in_progress):
writer.push(b"bbbbbbbb")
self.assertEqual(observations, [None])
self.assertEqual(reader.peek_latest_payload(), b"bbbbbbbb")
def test_peek_is_latest_and_non_consuming(self) -> None:
name = self._ring_name()
writer = self._writer(name, capacity=8, slot_size=64)
@@ -269,6 +334,85 @@ class PeekLatestTest(_RingTestCase):
class RingOpenTest(_RingTestCase):
def test_invalid_geometry_and_names_fail_before_opening_files(self) -> None:
for capacity, slot in ((True, 8), (1.5, 8), (1, 9), (2 ** 32, 8)):
with self.subTest(capacity=capacity, slot=slot), self.assertRaises(ValueError):
ShmRingWriter(self._ring_name(), capacity, slot)
for name in ("/", "/../tmp/ring", "/nested/ring", "/bad\x00ring"):
with self.subTest(name=name):
with self.assertRaises(ValueError):
ShmRingWriter(name, 1, 8)
with self.assertRaises(ValueError):
ShmRingReader(name, open_timeout_s=0)
def test_failed_mapping_closes_opened_file(self) -> None:
from python_app.orchestration.shm import ring_writer
name = self._ring_name()
opened = []
fdopen = ring_writer.os.fdopen
def track_fd(fd, *args, **kwargs):
handle = fdopen(fd, *args, **kwargs)
opened.append(handle)
return handle
self.addCleanup(lambda: (Path('/dev/shm') / name[1:]).unlink(missing_ok=True))
with mock.patch.object(ring_writer.os, 'fdopen', side_effect=track_fd), \
mock.patch.object(ring_writer.mmap, 'mmap', side_effect=OSError('mapping failed')), \
self.assertRaises(OSError):
ShmRingWriter(name, 1, 8)
self.assertTrue(opened[0].closed)
def test_owner_replaces_wrong_size_without_truncating_existing_mappings(self) -> None:
name = self._ring_name()
writer = self._writer(name, capacity=8, slot_size=64)
reader = self._reader(name)
writer.push(b"old frame")
path = Path("/dev/shm") / name[1:]
old_inode = path.stat().st_ino
replacement = ShmRingWriter(name, capacity=1, slot_size_bytes=8)
self.addCleanup(self._safe_close, replacement)
self.assertNotEqual(path.stat().st_ino, old_inode)
self.assertEqual(reader.pop_payload(), b"old frame")
self.assertTrue(replacement.push(b"new"))
self.assertEqual(self._reader(name).pop_payload(), b"new")
def test_waits_for_created_but_unallocated_ring(self) -> None:
name = self._ring_name()
path = Path("/dev/shm") / name[1:]
path.touch()
self.addCleanup(lambda: path.unlink(missing_ok=True))
writers = []
def initialize() -> None:
time.sleep(0.02)
writers.append(ShmRingWriter(name, capacity=1, slot_size_bytes=8))
creator = threading.Thread(target=initialize)
creator.start()
def cleanup_creator() -> None:
creator.join()
for writer in writers:
writer.close()
self.addCleanup(cleanup_creator)
reader = self._reader(name)
creator.join()
self.assertEqual(reader.capacity, 1)
self.assertEqual(reader.slot_size_bytes, 8)
def test_header_geometry_outside_mapping_is_rejected(self) -> None:
name = self._ring_name()
writer = self._writer(name, capacity=1, slot_size=8)
writer._write_u32(12, 100)
with self.assertRaisesRegex(RuntimeError, "geometry mismatch"):
ShmRingReader(name)
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)
+11 -2
View File
@@ -11,7 +11,6 @@ from __future__ import annotations
import os
import unittest
from pathlib import Path
import numpy as np
@@ -19,6 +18,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import QApplication # noqa: E402
from python_app.gui.app_window import AppWindow # noqa: E402
from python_app.tests.gui_test_support import isolated_window
from python_app.models.dataset_model import ( # noqa: E402
ComboKey,
ResultBlock,
@@ -33,7 +33,7 @@ _window: AppWindow | None = None
def setUpModule() -> None:
global _app, _window
_app = QApplication.instance() or QApplication([])
_window = AppWindow(Path("."))
_window = isolated_window(unittest.addModuleCleanup)
def tearDownModule() -> None:
@@ -89,6 +89,15 @@ class SingleCaptureBoundaryTest(unittest.TestCase):
self.assertTrue(self.w._finish_single_capture_if_ready())
self.assertFalse(self.w._single_capture_active) # _stop_run cleared the flag
def test_empty_processed_trace_completes_capture_instead_of_waiting_for_timeout(self) -> None:
result = _result(5, 1500)
payload = result.blocks[0].payloads[0]
payload.frequency_hz = np.empty(0, dtype=np.float32)
payload.trace = np.empty(0, dtype=np.complex64)
self.w._result_history.append(result)
self.assertTrue(self.w._finish_single_capture_if_ready())
self.assertFalse(self.w._single_capture_active)
if __name__ == "__main__":
unittest.main()
@@ -10,12 +10,12 @@ from __future__ import annotations
import os
import unittest
from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import QApplication # noqa: E402
from python_app.gui.app_window import AppWindow # noqa: E402
from python_app.tests.gui_test_support import isolated_window
_app: QApplication | None = None
_window: AppWindow | None = None
@@ -31,7 +31,7 @@ _PAGES = {
def setUpModule() -> None:
global _app, _window
_app = QApplication.instance() or QApplication([])
_window = AppWindow(Path("."))
_window = isolated_window(unittest.addModuleCleanup)
def tearDownModule() -> None:
+149 -2
View File
@@ -15,16 +15,19 @@ import os
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
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 fastapi import HTTPException, WebSocketDisconnect # noqa: E402
from python_app.gui.controllers.app_window_web_mixin import ( # noqa: E402
AppWindowWebController,
AppWindowWebMixin,
_WebActionCall,
)
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData # noqa: E402
from python_app.storage.npz.paths import radar_config_filename_prefix # noqa: E402
@@ -35,6 +38,7 @@ from python_app.storage.npz.vna_history_json import ( # noqa: E402
build_vna_history_payload,
)
from python_app.webui.controller import WebActionError # noqa: E402
from python_app.webui.routes import post_live_settings, ws # noqa: E402
from python_app.webui.streaming import RingBroadcaster # noqa: E402
@@ -160,10 +164,24 @@ class WebControllerTest(unittest.TestCase):
def test_known_field_emits_and_returns_snapshot(self) -> None:
received: list[dict] = []
self.controller.apply_settings_requested.connect(received.append)
updated = [{"name": "gpr_object_min_frac", "value": 0.5}]
def apply(fields, call):
received.append(fields)
self.controller.update_snapshot(status={}, live_settings=updated, frame=None)
call.done.set()
self.controller.apply_settings_requested.connect(apply)
out = self.controller.apply_live_settings({"gpr_object_min_frac": 0.5})
self.assertEqual(received, [{"gpr_object_min_frac": 0.5}])
self.assertIsInstance(out, list)
self.assertEqual(out, updated)
def test_live_settings_error_is_raised_to_the_caller(self) -> None:
self.controller.apply_settings_requested.connect(
lambda fields, call: (setattr(call, "error", "Duplicate antenna position: 0"), call.done.set())
)
with self.assertRaisesRegex(WebActionError, "Duplicate antenna position"):
self.controller.apply_live_settings({"tx_geometry": []})
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})
@@ -238,6 +256,85 @@ class WebControllerTest(unittest.TestCase):
self.controller.save_dataset("/tmp/out", "run1")
self.assertEqual(received, [("/tmp/out", "run1")])
def test_combo_controls_forward_selection_and_next_to_gui(self) -> None:
received: list = []
self.controller.select_combo_requested.connect(
lambda inp, out, call: (received.append((inp, out)), call.done.set())
)
self.controller.next_combo_requested.connect(
lambda call: (received.append("next"), call.done.set())
)
self.controller.select_combo(0, 1)
self.controller.next_combo()
self.assertEqual(received, [(0, 1), "next"])
def test_invalid_recording_count_is_rejected_without_dispatch(self) -> None:
fired = mock.Mock()
self.controller.start_recording_requested.connect(fired)
for count in (0, -1, True, 1.5, "2"):
with self.subTest(count=count), self.assertRaisesRegex(ValueError, "positive integer"):
self.controller.start_recording("/tmp", "run", count)
fired.assert_not_called()
def test_timed_out_queued_action_cannot_run_later(self) -> None:
queued = []
self.controller.start_requested.connect(queued.append)
with mock.patch("python_app.gui.controllers.app_window_web_mixin._WEB_ACTION_TIMEOUT_S", 0):
with self.assertRaisesRegex(WebActionError, "cancelled"):
self.controller.start()
w = AppWindowWebMixin()
action = mock.Mock()
w._run_web_action(queued[0], action)
action.assert_not_called()
self.assertFalse(self.controller._pending_calls)
def test_timeout_reports_an_already_running_action_without_repeating_it(self) -> None:
started = []
self.controller.start_requested.connect(lambda call: (call.begin(), started.append(call)))
with mock.patch("python_app.gui.controllers.app_window_web_mixin._WEB_ACTION_TIMEOUT_S", 0):
with self.assertRaisesRegex(WebActionError, "still running"):
self.controller.start()
self.assertFalse(started[0].done.is_set())
started[0].finish()
self.assertTrue(started[0].done.is_set())
def test_shutdown_releases_waiting_requests_and_rejects_new_controls(self) -> None:
queued = []
self.controller.start_requested.connect(lambda call: (queued.append(call), self.controller.shutdown()))
with self.assertRaisesRegex(WebActionError, "closing"):
self.controller.start()
self.assertTrue(queued[0].done.is_set())
self.assertFalse(queued[0].begin())
with self.assertRaisesRegex(WebActionError, "closing"):
self.controller.next_combo()
class WebActionLifecycleTest(unittest.TestCase):
def test_closed_window_does_not_run_queued_control(self) -> None:
w = AppWindowWebMixin()
w._closing = True
call = _WebActionCall()
action = mock.Mock()
w._run_web_action(call, action)
action.assert_not_called()
self.assertTrue(call.done.is_set())
self.assertEqual(call.error, "The application is closing")
def test_out_of_range_recording_count_does_not_change_destination_or_arm(self) -> None:
w = AppWindowWebMixin()
w._record_count = SimpleNamespace(minimum=lambda: 1, maximum=lambda: 100, setValue=mock.Mock())
w._save_path_input = SimpleNamespace(setText=mock.Mock())
w._save_name_input = SimpleNamespace(setText=mock.Mock())
w._start_run_with_recording = mock.Mock()
with self.assertRaisesRegex(ValueError, "from 1 to 100"):
w._start_web_recording("/tmp/new", "run", 101)
w._save_path_input.setText.assert_not_called()
w._save_name_input.setText.assert_not_called()
w._record_count.setValue.assert_not_called()
w._start_run_with_recording.assert_not_called()
class WebPortTest(unittest.TestCase):
def _port_with_env(self, value: str | None) -> int:
@@ -257,9 +354,59 @@ class WebPortTest(unittest.TestCase):
self.assertEqual(self._port_with_env("9000"), 9000)
class WebLiveSettingsRouteTest(unittest.TestCase):
def setUp(self) -> None:
self.controller = mock.Mock()
self.request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(controller=self.controller)))
self.enterContext(mock.patch(
"python_app.webui.routes.asyncio.to_thread",
new=mock.AsyncMock(side_effect=lambda func, *args: func(*args)),
))
def test_response_contains_schema_after_the_action_finishes(self) -> None:
updated = [{"name": "bscan_gain", "value": 2.0}]
self.controller.apply_live_settings.return_value = updated
response = asyncio.run(post_live_settings(self.request, {"bscan_gain": 2.0}))
self.assertEqual(response, updated)
self.controller.apply_live_settings.assert_called_once_with({"bscan_gain": 2.0})
def test_gui_failure_is_returned_as_http_error(self) -> None:
self.controller.apply_live_settings.side_effect = WebActionError("Invalid antenna geometry")
with self.assertRaises(HTTPException) as caught:
asyncio.run(post_live_settings(self.request, {"tx_geometry": []}))
self.assertEqual(caught.exception.status_code, 400)
self.assertEqual(caught.exception.detail, "Invalid antenna geometry")
# --------------------------------------------------------------------------- #
# Latest-wins fan-out
# --------------------------------------------------------------------------- #
class WebSocketInitialFrameTest(unittest.TestCase):
def test_new_client_receives_cached_plot_even_when_no_new_frame_is_published(self) -> None:
self.enterContext(mock.patch(
"python_app.webui.routes.asyncio.to_thread",
new=mock.AsyncMock(side_effect=lambda func, *args: func(*args)),
))
for frame in ({"type": "frame", "seq": 7, "png_b64": "cached"}, None):
with self.subTest(frame=frame):
controller = SimpleNamespace(peek_frame=lambda: frame)
broadcaster = mock.Mock()
status = {"type": "status", "running": False}
broadcaster.register.side_effect = lambda queue: queue.put_nowait(status)
socket = SimpleNamespace(
app=SimpleNamespace(state=SimpleNamespace(controller=controller, broadcaster=broadcaster)),
accept=mock.AsyncMock(), close=mock.AsyncMock(),
send_json=mock.AsyncMock(side_effect=WebSocketDisconnect),
)
asyncio.run(ws(socket))
socket.send_json.assert_awaited_once_with(frame if frame is not None else status)
broadcaster.unregister.assert_called_once()
socket.close.assert_awaited_once()
class RingBroadcasterFanOutTest(unittest.TestCase):
def test_full_client_queue_drops_oldest(self) -> None:
async def scenario() -> None:
@@ -187,31 +187,23 @@ class ManualComboCaptureUsesTargetedAcquisitionTest(unittest.TestCase):
def test_manual_capture_runs_one_inner_collection_per_median_sweep(self) -> None:
config = self._switched_mock_config()
session = SequentialCaptureSession(
config=config,
kind="s21_calibration",
set_name="targeted_test",
median_sweep_count=2,
)
with mock.patch.object(
MultiDeviceLibreVnaService,
"acquire_collection",
autospec=True,
side_effect=MultiDeviceLibreVnaService.acquire_collection,
) as inner_acquire:
session.open()
try:
trace = session.capture_current_combo()
finally:
session.close()
service, inner, _ = _switched_service(input_steps=3)
with mock.patch(
"python_app.workflows.sequential_capture_workflow.create_matrix_radar_service",
return_value=service,
):
session = SequentialCaptureSession(
config=config, kind="s21_calibration", set_name="targeted_test", median_sweep_count=2,
)
session.open()
try:
trace = session.capture_current_combo()
finally:
session.close()
first_combo = config.combos[0]
self.assertEqual(
(trace.combo.input, trace.combo.output),
(first_combo.input, first_combo.output),
)
self.assertEqual((trace.combo.input, trace.combo.output), (first_combo.input, first_combo.output))
# 2 median sweeps of ONE physical step — not 2 x 3 full-matrix steps.
self.assertEqual(inner_acquire.call_count, 2)
self.assertEqual(inner.acquire_count, 2)
class PerPointStimulusSwitchingConfigTest(unittest.TestCase):
@@ -238,7 +230,9 @@ class PerPointStimulusSwitchingConfigTest(unittest.TestCase):
# The switch is stepped by the controller in the hardware trigger loop, so the
# host must acquire the whole 4x4 matrix in ONE sweep and never wrap the
# service in the sweep-per-step widener.
service = create_matrix_radar_service(self._config(2))
config = self._config(2)
config.radar.driver_mode = "native"
service = create_matrix_radar_service(config)
self.assertIsInstance(service, MultiDeviceLibreVnaService)
self.assertNotIsInstance(service, SwitchedMatrixRadarService)
self.assertEqual(service.output_position_count, 4)