diff --git a/.gitignore b/.gitignore index a1b437c..6288676 100644 --- a/.gitignore +++ b/.gitignore @@ -226,4 +226,4 @@ python_app/runtime SHARE_INTERNET_TO_PI.md CLAUDE.md -./docs \ No newline at end of file +docs/ \ No newline at end of file diff --git a/python_app/gui/controllers/app_window_config/profile_io_mixin.py b/python_app/gui/controllers/app_window_config/profile_io_mixin.py index bfdd418..e9bf158 100644 --- a/python_app/gui/controllers/app_window_config/profile_io_mixin.py +++ b/python_app/gui/controllers/app_window_config/profile_io_mixin.py @@ -61,7 +61,7 @@ class AppWindowConfigProfileIOMixin: """Return the canonical virtual combo matrix shown for matrix-mode radars.""" return ",".join( f"{int(combo.input)}:{int(combo.output)}" - for combo in RunConfigModel.build_matrix_radar_virtual_combos() + for combo in self._defaults_config.build_runtime_combos() ) def _sync_pass_through_y_controls(self) -> None: diff --git a/python_app/gui/controllers/app_window_config/state_builders.py b/python_app/gui/controllers/app_window_config/state_builders.py index 8a0776d..294f043 100644 --- a/python_app/gui/controllers/app_window_config/state_builders.py +++ b/python_app/gui/controllers/app_window_config/state_builders.py @@ -125,7 +125,7 @@ class AppWindowConfigStateBuildersMixin: if config.is_matrix_radar: return ",".join( f"{int(combo.input)}:{int(combo.output)}" - for combo in RunConfigModel.build_matrix_radar_virtual_combos() + for combo in config.build_runtime_combos() ) combos = list(config.combos) full_combos = config.build_full_combos(config.input_switch.positions, config.output_switch.positions) diff --git a/python_app/hardware_full/matrix_radar_service.py b/python_app/hardware_full/matrix_radar_service.py index 755482a..6651b20 100644 --- a/python_app/hardware_full/matrix_radar_service.py +++ b/python_app/hardware_full/matrix_radar_service.py @@ -46,13 +46,31 @@ def create_matrix_radar_service(config: RunConfigModel) -> MatrixRadarService: if model == RunConfigModel.LIBREVNA_MULTI_MODEL: from python_app.hardware_full.multi_device_service import MultiDeviceLibreVnaService - return MultiDeviceLibreVnaService( + inner = MultiDeviceLibreVnaService( master_serial=config.radar.serial, slave_serials=list(config.radar.multi_device.slave_serials), force_external_reference=config.radar.multi_device.force_external_reference, recovery_attempts=config.radar.multi_device.recovery_attempts, backend_mode=config.radar.driver_mode, ) + out_physical = config.matrix_output_switch_positions + in_physical = config.matrix_input_switch_positions + if out_physical <= 1 and in_physical <= 1: + return inner + + from python_app.hardware_full.switched_matrix_radar_service import ( + SwitchedMatrixRadarService, + build_physical_switch, + ) + + return SwitchedMatrixRadarService( + inner=inner, + output_switch=build_physical_switch(config.output_switch, out_physical, config.radar.driver_mode), + input_switch=build_physical_switch(config.input_switch, in_physical, config.radar.driver_mode), + inner_output_positions=RunConfigModel.MULTI_DEVICE_OUTPUT_POSITIONS, + inner_input_positions=RunConfigModel.MULTI_DEVICE_INPUT_POSITIONS, + settling_ms=config.runtime.settling_ms, + ) if model == RunConfigModel.SN9000_MODEL: if config.radar.driver_mode != "native": diff --git a/python_app/hardware_full/switch_service.py b/python_app/hardware_full/switch_service.py index 1a2abc2..ce2c39b 100644 --- a/python_app/hardware_full/switch_service.py +++ b/python_app/hardware_full/switch_service.py @@ -66,6 +66,10 @@ class SwitchService: """Switch to requested position.""" self._driver.switch_to(position) + def position_count(self) -> int: + """Return number of positions supported by the backend driver.""" + return self._driver.position_count() + @property def current_position(self) -> int: """Return current switch position reported by backend driver.""" diff --git a/python_app/hardware_full/switched_matrix_radar_service.py b/python_app/hardware_full/switched_matrix_radar_service.py new file mode 100644 index 0000000..55cc543 --- /dev/null +++ b/python_app/hardware_full/switched_matrix_radar_service.py @@ -0,0 +1,130 @@ +"""Matrix radar behind real GPIO switches on the stimulus and/or receiver path.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +import logging +import time + +from python_app.hardware_full.matrix_radar_service import MatrixRadarService +from python_app.hardware_full.switch_service import SwitchService +from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData +from python_app.models.run_config_model import RadarSweepModel, SwitchModel + +logger = logging.getLogger(__name__) + + +@dataclass(slots=True) +class SwitchedMatrixRadarService: + """Widen a matrix radar's combo matrix with real switch positions. + + Implements the ``MatrixRadarService`` protocol, so the producer and the + capture workflows treat it as an ordinary matrix radar that simply reports + more positions. The hardware sweep is never stopped: switches are only ever + driven BETWEEN ``acquire_collection`` calls, and the inner service's + free-running collection discards any partially swept cycle. + """ + + inner: MatrixRadarService + output_switch: SwitchService | None + input_switch: SwitchService | None + inner_output_positions: int + inner_input_positions: int + settling_ms: int = 0 + + def open(self) -> None: + """Open the inner radar and both switches.""" + self.inner.open() + if self.output_switch is not None: + self.output_switch.open() + if self.input_switch is not None: + self.input_switch.open() + + def close(self) -> None: + """Close switches first, then the inner radar; never raises.""" + for switch in (self.input_switch, self.output_switch): + if switch is not None: + try: + switch.close() + except Exception as exc: # noqa: BLE001 — shutdown path + logger.warning("Switch close ignored error: %s", exc) + self.inner.close() + + def configure(self, sweep: RadarSweepModel) -> None: + """Apply sweep settings to the inner radar.""" + self.inner.configure(sweep) + + def recover(self) -> None: + """Reconnect the inner radar; switches are not on the USB transport.""" + self.inner.recover() + + def acquire_collection(self, collection_id: int = 1) -> SweepCollection: + """Acquire the full widened matrix, one inner collection per switch step. + + A partial failure raises instead of returning a short collection: the + preprocessor requires every runtime combo to be present, so half a matrix + is worse than a dropped frame. + """ + capture_start_ns = time.monotonic_ns() + out_steps = self.output_switch.position_count() if self.output_switch is not None else 1 + in_steps = self.input_switch.position_count() if self.input_switch is not None else 1 + total_inputs = in_steps * self.inner_input_positions + total_outputs = out_steps * self.inner_output_positions + + # Place each trace at its canonical index rather than appending. The GPR stage + # rejects a collection whose trace order differs from run.combos, and run.combos + # is built output-major (`build_full_combos`) while these loops run switch-major. + # Appending happens to agree for an output switch and to disagree for an input one. + slots: list[TraceData | None] = [None] * (total_inputs * total_outputs) + + for out_k in range(out_steps): + if self.output_switch is not None: + self.output_switch.switch_to(out_k) + for in_k in range(in_steps): + if self.input_switch is not None: + self.input_switch.switch_to(in_k) + # Settle AFTER the last switch change and BEFORE collecting, so the + # cycle we anchor on starts with the RF path already stable. + if self.settling_ms > 0: + time.sleep(self.settling_ms / 1000.0) + + sub = self.inner.acquire_collection(collection_id) + for trace in sub.traces: + input_pos = in_k * self.inner_input_positions + int(trace.combo.input) + output_pos = out_k * self.inner_output_positions + int(trace.combo.output) + slots[output_pos * total_inputs + input_pos] = replace( + trace, combo=ComboKey(input=input_pos, output=output_pos) + ) + + if any(trace is None for trace in slots): + missing = sum(1 for trace in slots if trace is None) + raise RuntimeError( + f"Switched matrix collection is incomplete: {missing} of {len(slots)} combos missing" + ) + + return SweepCollection( + collection_id=int(collection_id), + monotonic_ns=time.monotonic_ns(), + traces=[trace for trace in slots if trace is not None], + capture_start_ns=capture_start_ns, + capture_end_ns=time.monotonic_ns(), + ) + +def build_physical_switch( + model: SwitchModel, + physical_positions: int, + radar_driver_mode: str, +) -> SwitchService | None: + """Build the driver for a real switch described by a virtual switch section. + + The config section carries the LOGICAL axis size and a forced "mock" mode so + the C++ loader accepts it; the real driver needs the PHYSICAL position count + and native mode. Mock radar runs keep mock switches so the whole path can be + exercised without GPIO. + """ + if physical_positions <= 1: + return None + driver_mode = "mock" if radar_driver_mode.strip().lower() == "mock" else "native" + return SwitchService.from_model( + replace(model, positions=physical_positions, driver_mode=driver_mode) + ) \ No newline at end of file diff --git a/python_app/models/run_config_codec.py b/python_app/models/run_config_codec.py index d9b8cea..e007e56 100644 --- a/python_app/models/run_config_codec.py +++ b/python_app/models/run_config_codec.py @@ -219,6 +219,16 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel: "recovery_attempts", model.radar.multi_device.recovery_attempts, ) + model.radar.multi_device.output_switch_positions = _read_int( + multi_device_payload, + "output_switch_positions", + model.radar.multi_device.output_switch_positions, + ) + model.radar.multi_device.input_switch_positions = _read_int( + multi_device_payload, + "input_switch_positions", + model.radar.multi_device.input_switch_positions, + ) model.radar.kamil_adc.project_dir = _read_str( kamil_adc_payload, "project_dir", model.radar.kamil_adc.project_dir ) @@ -493,6 +503,8 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]: "slave_serials": list(model.radar.multi_device.slave_serials), "force_external_reference": model.radar.multi_device.force_external_reference, "recovery_attempts": model.radar.multi_device.recovery_attempts, + "output_switch_positions": model.radar.multi_device.output_switch_positions, + "input_switch_positions": model.radar.multi_device.input_switch_positions }, "kamil_adc": { "project_dir": model.radar.kamil_adc.project_dir, diff --git a/python_app/models/run_config_schema.py b/python_app/models/run_config_schema.py index 2250d73..961001f 100644 --- a/python_app/models/run_config_schema.py +++ b/python_app/models/run_config_schema.py @@ -40,6 +40,8 @@ class RadarMultiDeviceModel: slave_serials: list[str] = field(default_factory=list) force_external_reference: bool = True recovery_attempts: int = 3 + output_switch_positions: int = 1 # 1 = свитча нет + input_switch_positions: int = 1 # 1 = свитча нет @dataclass(slots=True) @@ -387,6 +389,24 @@ class RunConfigModel: """Return whether this config acquires the full virtual switch matrix per sweep.""" return self.is_multi_device or self.is_sn9000 + @property + def matrix_output_switch_positions(self) -> int: + """Physical positions of the real switch on the master stimulus path.""" + if not self.is_multi_device: + return 1 + return max(1, int(self.radar.multi_device.output_switch_positions)) + + @property + def matrix_input_switch_positions(self) -> int: + """Physical positions of the real switch on the slave receiver path.""" + if not self.is_multi_device: + return 1 + return max(1, int(self.radar.multi_device.input_switch_positions)) + + def build_runtime_combos(self) -> list[ComboModel]: + """Build the combo matrix from the effective switch axis sizes.""" + return self.build_full_combos(self.input_switch.positions, self.output_switch.positions) + @property def is_kamil_adc(self) -> bool: """Return whether this config targets the external Kamil ADC acquisition path.""" @@ -443,22 +463,25 @@ class RunConfigModel: if not self.is_matrix_radar: return self._apply_matrix_virtual_switches() - self.combos = self.build_matrix_radar_virtual_combos() + self.combos = self.build_runtime_combos() def _apply_matrix_virtual_switches(self) -> None: - """Pin the canonical 2x4 virtual switch matrix used by all matrix-mode radars.""" + """Pin the virtual switch matrix, widened by any real switch on the path.""" + out_physical = self.matrix_output_switch_positions + in_physical = self.matrix_input_switch_positions + self.output_switch.name = self.output_switch.name or "virtual_output" self.output_switch.driver_mode = "mock" self.output_switch.driver = self.output_switch.driver or "h7992" self.output_switch.radar_port = 1 - self.output_switch.positions = self.MULTI_DEVICE_OUTPUT_POSITIONS + self.output_switch.positions = out_physical * self.MULTI_DEVICE_OUTPUT_POSITIONS self.output_switch.default_position = 0 self.input_switch.name = self.input_switch.name or "virtual_input" self.input_switch.driver_mode = "mock" self.input_switch.driver = self.input_switch.driver or "h7992" self.input_switch.radar_port = 2 - self.input_switch.positions = self.MULTI_DEVICE_INPUT_POSITIONS + self.input_switch.positions = in_physical * self.MULTI_DEVICE_INPUT_POSITIONS self.input_switch.default_position = 0 def ensure_combos(self) -> None: diff --git a/python_app/workflows/multi_radar_capture_workflow.py b/python_app/workflows/multi_radar_capture_workflow.py index 09aa0d3..25bca17 100644 --- a/python_app/workflows/multi_radar_capture_workflow.py +++ b/python_app/workflows/multi_radar_capture_workflow.py @@ -81,14 +81,7 @@ class MultiRadarSequentialCaptureSession: self._manual_matrix_radar_capture = ( self._is_matrix_radar and kind in MATRIX_RADAR_MANUAL_CAPTURE_KINDS ) - self._combos = ( - RunConfigModel.build_matrix_radar_virtual_combos() - if self._is_matrix_radar - else RunConfigModel.build_full_combos( - base_config.input_switch.positions, - base_config.output_switch.positions, - ) - ) + self._combos = base_config.build_runtime_combos() if not self._combos: raise RuntimeError("No switch combinations available for capture") diff --git a/python_app/workflows/sequential_capture_workflow.py b/python_app/workflows/sequential_capture_workflow.py index 1a1beb5..2b58829 100644 --- a/python_app/workflows/sequential_capture_workflow.py +++ b/python_app/workflows/sequential_capture_workflow.py @@ -64,11 +64,7 @@ class SequentialCaptureSession: self._manual_matrix_radar_capture = ( self._is_matrix_radar and kind in MATRIX_RADAR_MANUAL_CAPTURE_KINDS ) - self._combos = ( - RunConfigModel.build_matrix_radar_virtual_combos() - if self._is_matrix_radar - else RunConfigModel.build_full_combos(config.input_switch.positions, config.output_switch.positions) - ) + self._combos = config.build_runtime_combos() if not self._combos: raise RuntimeError("No switch combinations available for capture") diff --git a/run_config.json b/run_config.json index fa3edaa..303bb76 100644 --- a/run_config.json +++ b/run_config.json @@ -1,6 +1,6 @@ { "radar": { - "model": "librevna", + "model": "librevna_multi", "serial": "", "remote_host": "127.0.0.1", "remote_port": 50209, @@ -8,9 +8,14 @@ "mock_signal_hz": 5000000.0, "visa_library": "", "multi_device": { - "slave_serials": [], + "slave_serials": [ + "20A1307D5532", + "2072306C5532" + ], "force_external_reference": false, - "recovery_attempts": 3 + "recovery_attempts": 3, + "output_switch_positions": 1, + "input_switch_positions": 3 }, "kamil_adc": { "project_dir": "", @@ -20,7 +25,18 @@ "env": {}, "startup_timeout_s": 5.0, "sweep_timeout_s": 5.0, - "stop_timeout_s": 2.0 + "stop_timeout_s": 2.0, + "phase_calibration": { + "phase0_rad": 0.0, + "freq0_hz": 2046000000.0, + "phase1_rad": 300.0, + "freq1_hz": 5612000000.0 + }, + "band": { + "start_hz": 2100000000.0, + "stop_hz": 5500000000.0, + "points": 2048 + } }, "laser_control": { "enabled": false, @@ -75,7 +91,7 @@ "driver_mode": "mock", "driver": "h7992", "radar_port": 2, - "positions": 4, + "positions": 12, "default_position": 0, "gpio_chip": "/dev/gpiochip0", "pin_a": 22, @@ -92,11 +108,14 @@ "debounce_ms": 50, "action": "capture_tmp_reference" }, + "logging": { + "level": "info" + }, "run": { "settling_ms": 0, "idle_sleep_ms": 2, "continuous": true, - "processing_live_config_path": "python_app/runtime/processing_live.json", + "processing_live_config_path": "/home/guriy/Documents/radar_system/python_app/runtime/processing_live.json", "locator_server": { "device_id": 3, "protocol_version": 1, @@ -123,6 +142,38 @@ "input": 3, "output": 0 }, + { + "input": 4, + "output": 0 + }, + { + "input": 5, + "output": 0 + }, + { + "input": 6, + "output": 0 + }, + { + "input": 7, + "output": 0 + }, + { + "input": 8, + "output": 0 + }, + { + "input": 9, + "output": 0 + }, + { + "input": 10, + "output": 0 + }, + { + "input": 11, + "output": 0 + }, { "input": 0, "output": 1 @@ -138,17 +189,49 @@ { "input": 3, "output": 1 + }, + { + "input": 4, + "output": 1 + }, + { + "input": 5, + "output": 1 + }, + { + "input": 6, + "output": 1 + }, + { + "input": 7, + "output": 1 + }, + { + "input": 8, + "output": 1 + }, + { + "input": 9, + "output": 1 + }, + { + "input": 10, + "output": 1 + }, + { + "input": 11, + "output": 1 } ] }, "preprocess": { "s21": { "calibration": { - "set_name": "smoke_cal", + "set_name": "smoke_cal3", "bundle_path": "" }, "reference": { - "set_name": "smoke_ref", + "set_name": "smoke_cal3", "bundle_path": "" } }, @@ -219,6 +302,54 @@ "x_m": 0.185, "y_m": 0.0, "z_m": 0.0 + }, + { + "input_pos": 4, + "x_m": 0.0, + "y_m": 0.0, + "z_m": 0.0 + }, + { + "input_pos": 5, + "x_m": 0.0, + "y_m": 0.0, + "z_m": 0.0 + }, + { + "input_pos": 6, + "x_m": 0.0, + "y_m": 0.0, + "z_m": 0.0 + }, + { + "input_pos": 7, + "x_m": 0.0, + "y_m": 0.0, + "z_m": 0.0 + }, + { + "input_pos": 8, + "x_m": 0.0, + "y_m": 0.0, + "z_m": 0.0 + }, + { + "input_pos": 9, + "x_m": 0.0, + "y_m": 0.0, + "z_m": 0.0 + }, + { + "input_pos": 10, + "x_m": 0.0, + "y_m": 0.0, + "z_m": 0.0 + }, + { + "input_pos": 11, + "x_m": 0.0, + "y_m": 0.0, + "z_m": 0.0 } ] }, @@ -253,7 +384,7 @@ "version": 1, "switches": { "combo_mode": "text", - "combos_text": "0:0,1:0,2:0,3:0,0:1,1:1,2:1,3:1", + "combos_text": "0:0,1:0,2:0,3:0,4:0,5:0,6:0,7:0,8:0,9:0,10:0,11:0,0:1,1:1,2:1,3:1,4:1,5:1,6:1,7:1,8:1,9:1,10:1,11:1", "single_input": "0", "single_output": "0" }, @@ -262,6 +393,7 @@ "pass_through": { "show_magnitude": true, "show_phase": false, + "unwrap_phase": false, "combo_filter": "", "fixed_y_enabled": false, "y_min_db": -100.0, @@ -333,7 +465,8 @@ "data_actions": { "save_count": 10, "save_path": "python_app/data/snapshots", - "save_name": "snapshot_simulator" + "save_name": "snapshot_simulator", + "record_count": 100 }, "preprocess_dialog": { "set_name": "smoke_cal", @@ -342,4 +475,4 @@ "median_sweep_count": 5 } } -} +} \ No newline at end of file