Refactor radar system
This commit is contained in:
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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, '')
|
||||
@@ -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",
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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(), {})
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user