diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..05139b2 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,14 @@ +{ + "permissions": { + "allow": [ + "PowerShell(Get-Command pdftotext, pdftoppm, gswin64c -ErrorAction SilentlyContinue)", + "PowerShell(Get-Command pdftotext -ErrorAction SilentlyContinue)", + "PowerShell(Get-Command python -ErrorAction SilentlyContinue)", + "PowerShell(Write-Output \"py:\"; \\(Get-Command py -ErrorAction SilentlyContinue\\).Source; Write-Output \"python:\"; \\(Get-Command python -ErrorAction SilentlyContinue\\).Source; Write-Output \"pdftotext:\"; \\(Get-Command pdftotext -ErrorAction SilentlyContinue\\).Source)", + "PowerShell(py -c \"import pypdf; print\\(pypdf.__version__\\)\" 2>&1)", + "PowerShell(py -c \"import pdfplumber; print\\(pdfplumber.__version__\\)\" 2>&1)", + "PowerShell(py -c \"import fitz; print\\(fitz.__version__\\)\" 2>&1)", + "PowerShell(py -m pip install --quiet pymupdf 2>&1)" + ] + } +} diff --git a/data_acq_and_processing/sweep_orchestrator/src/main.cpp b/data_acq_and_processing/sweep_orchestrator/src/main.cpp index 954ac29..e5d6573 100644 --- a/data_acq_and_processing/sweep_orchestrator/src/main.cpp +++ b/data_acq_and_processing/sweep_orchestrator/src/main.cpp @@ -73,7 +73,11 @@ void install_signal_handlers() { } if (config.model == kLibreVnaMultiModel) { - throw std::runtime_error("librevna_multi is handled by python_app.scripts.multi_device_raw_producer"); + throw std::runtime_error("librevna_multi is handled by python_app.scripts.matrix_raw_producer"); + } + + if (config.model == "sn9000") { + throw std::runtime_error("sn9000 is handled by python_app.scripts.matrix_raw_producer"); } throw std::runtime_error("Unsupported radar.model for sweep_orchestrator: " + config.model); diff --git a/docs/operation_modes.md b/docs/operation_modes.md index dbb9b07..e4a3d50 100644 --- a/docs/operation_modes.md +++ b/docs/operation_modes.md @@ -9,6 +9,7 @@ Available models: librevna librevna_multi compact_m_k209 +sn9000 kamil_adc ``` @@ -48,7 +49,8 @@ The GUI process supervisor starts the correct producer automatically: - `librevna` -> `build/bin/sweep_orchestrator` - `compact_m_k209` -> `build/bin/sweep_orchestrator` -- `librevna_multi` -> `python_app.scripts.multi_device_raw_producer` +- `librevna_multi` -> `python_app.scripts.matrix_raw_producer` +- `sn9000` -> `python_app.scripts.matrix_raw_producer` - `kamil_adc` -> `python_app.scripts.kamil_adc_raw_producer` ## Pure Simulator @@ -140,7 +142,7 @@ Notes: run: ```bash -.venv/bin/python -m python_app.scripts.multi_device_raw_producer \ +.venv/bin/python -m python_app.scripts.matrix_raw_producer \ --config run_config_librevna_multi.example.json ``` diff --git a/docs/run_config.md b/docs/run_config.md index 3727f83..183250f 100644 --- a/docs/run_config.md +++ b/docs/run_config.md @@ -42,11 +42,11 @@ Fields: | Field | Meaning | | --- | --- | -| `model` | `librevna`, `librevna_multi`, `compact_m_k209`, or `kamil_adc`. | -| `serial` | LibreVNA serial. Empty means first device for single LibreVNA. For `librevna_multi`, this is the master serial. | -| `remote_host` | K209 remote server host. Used by `compact_m_k209`; ignored by LibreVNA modes. | -| `remote_port` | K209 remote server TCP port. Default is `50209`. | -| `driver_mode` | `native` for hardware, `mock` for supported synthetic LibreVNA modes. K209 and Kamil ADC require `native`. | +| `model` | `librevna`, `librevna_multi`, `compact_m_k209`, `sn9000`, or `kamil_adc`. | +| `serial` | LibreVNA serial. Empty means first device for single LibreVNA. For `librevna_multi`, this is the master serial. Unused by `compact_m_k209` and `sn9000`. | +| `remote_host` | SCPI server host. For `compact_m_k209` it is the K209 relay server host; for `sn9000` it is the SNVNA HiSLIP host. Ignored by LibreVNA modes. | +| `remote_port` | SCPI server TCP port. Default `50209` for `compact_m_k209` (relay), `4880` for `sn9000` (SNVNA HiSLIP). | +| `driver_mode` | `native` for hardware, `mock` for supported synthetic LibreVNA modes. K209, SN9000, and Kamil ADC require `native`. | | `mock_signal_hz` | Existing LibreVNA mock signal parameter used by C++ mock acquisition. | | `multi_device` | Extra settings for `librevna_multi`. | | `kamil_adc` | External collector process and TTY settings for `kamil_adc`. | @@ -266,7 +266,8 @@ Fields: {"input": 2, "output": 1} ``` -For `librevna_multi`, the model constraints force the canonical virtual matrix: +For `librevna_multi` and `sn9000`, the model constraints force the canonical +virtual matrix: ```text input: 0..3 @@ -410,6 +411,17 @@ Compact-M K209 via remote server: } ``` +SN9000 (PLANAR Иридиум) via SNVNA HiSLIP: + +```json +"radar": { + "model": "sn9000", + "remote_host": "192.168.1.10", + "remote_port": 4880, + "driver_mode": "native" +} +``` + Kamil ADC: ```json diff --git a/docs/sn9000_setup.md b/docs/sn9000_setup.md new file mode 100644 index 0000000..9a7407c --- /dev/null +++ b/docs/sn9000_setup.md @@ -0,0 +1,140 @@ +# SN9000 (PLANAR SNVNA / Иридиум) Setup + +This project controls the PLANAR SN9000 multi-port VNA through the SNVNA +companion application running on an external PC. The production path is: + +```text +SN9000 --USB 2.0--> SNVNA host PC --HiSLIP/VISA--> radar_system +``` + +For complete run-mode instructions see +[`docs/operation_modes.md`](operation_modes.md). For `run_config.json` field +reference see [`docs/run_config.md`](run_config.md). + +The SN9000 hardware has no built-in SCPI server; the SNVNA application on the +companion PC exposes the SCPI HiSLIP server (default port `4880`). This +project uses HiSLIP only, with the same `pyvisa` + IVI VISA stack already +required by K209 — there is no additional dependency. + +For maximum throughput the driver: + +- Uses HiSLIP, not raw TCP Socket. +- Keeps one persistent VISA session. +- Sends `FORM:DATA REAL32` and `FORM:BORD SWAP` (little-endian) once. +- Pre-configures 10 traces covering all S-parameters of the 2×4 matrix so + one trigger drives both stimulus ports. +- Sends the entire acquisition as one synchronized SCPI message: + `TRIG:SING;*OPC?;:SENS:DATA:CORR? S11;:SENS:DATA:CORR? S31;…;:SENS:DATA:CORR? S62`. + The K209 setup notes the same constraint: splitting `TRIG:SING` from the + data queries can return `-211,"Trigger system is not in the trigger wait state"`. + +Topology (manual p. 1457): + +| Trace | Output position | Stimulus port | Input position | Receiver port | +|-------|-----------------|---------------|----------------|----------------| +| S11 | 0 | 1 | (reflection) | 1 | +| S31 | 0 | 1 | 0 | 3 | +| S41 | 0 | 1 | 1 | 4 | +| S51 | 0 | 1 | 2 | 5 | +| S61 | 0 | 1 | 3 | 6 | +| S22 | 1 | 2 | (reflection) | 2 | +| S32 | 1 | 2 | 0 | 3 | +| S42 | 1 | 2 | 1 | 4 | +| S52 | 1 | 2 | 2 | 5 | +| S62 | 1 | 2 | 3 | 6 | + +## Required Components + +Install these on the machine that runs the SN9000 smoke test or acquisition +process: + +1. SNVNA companion application from Planar. + - The SN9000 hardware is connected to this host over USB 2.0. + +2. IVI VISA runtime and development files. + - Must support TCPIP HiSLIP resources. + - Suitable implementations include NI-VISA or Keysight IO Libraries Suite. + - If K209 already works on this host, no additional install is needed. + +3. Project Python environment. + - Use the repository virtual environment, not system Python. + - Install `requirements.txt` into `.venv`. + +## SNVNA HiSLIP Server + +Start SNVNA with the SN9000 connected over USB 2.0. Enable HiSLIP server on +port `4880`. From the SNVNA UI: + +```text +System -> Settings -> Remote control network settings -> HiSLIP server -> On +System -> Settings -> Remote control network settings -> HiSLIP port -> 4880 +``` + +Verify that the server is listening: + +```bash +ss -ltnp | grep 4880 +``` + +If SNVNA runs on a different machine from `radar_system`, set +`radar.remote_host` to that machine's IP address. + +The VISA resource string the driver assembles is: + +```text +TCPIP0::::hislip0,::INSTR +``` + +## `run_config.json` + +```json +"radar": { + "model": "sn9000", + "remote_host": "127.0.0.1", + "remote_port": 4880, + "driver_mode": "native" +} +``` + +The 2×4 virtual switch matrix is enforced automatically; do not edit +`switches.port1` / `switches.port2` or `run.combos` for SN9000 mode — the +config codec rewrites them on load. + +## Python Smoke Test + +Use the project virtual environment: + +```bash +.venv/Scripts/python.exe -m python_app.scripts.sn9000_smoke_test ^ + --host 127.0.0.1 --port 4880 ^ + --start-hz 1000000 --stop-hz 3000000000 ^ + --points 201 --ifbw-hz 10000 --power-dbm -10 ^ + --no-preset +``` + +Expected result: + +```text +SN9000 IDN: Planar, SN9000-N, ... +SN9000 collection OK: traces=8, points=201, first_hz=..., last_hz=..., mean_abs_s21=... +``` + +Use `--no-preset` for the first smoke test to avoid resetting the current +SNVNA session. Remove it when testing the full driver setup path. + +## SN9000 Limits + +The SNVNA SCPI surface exposes service capability queries identical to K209: + +```text +SERV:SWE:FREQ:MAX? Upper frequency bound in Hz. +SERV:SWE:FREQ:MIN? Lower frequency bound in Hz. +SERV:SWE:POIN? Maximum sweep point count. +SERV:SWE:POW:MAX? Upper power bound in dBm. +SERV:SWE:POW:MIN? Lower power bound in dBm. +``` + +The base SN9000 model covers `0.3 MHz .. 9 GHz`; power range is +`-45 .. +10 dBm` up to 6 GHz, and `-45 .. +2 dBm` from 6 GHz to 9 GHz +(manual p. 58). IF bandwidth selectable in the 1, 1.5, 2, 3, 5, 7 sequence +across decades from `1 Hz` to `300 kHz` (manual p. 58, 1261). 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 93d64c8..15b92f5 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 @@ -33,10 +33,10 @@ class AppWindowConfigProfileIOMixin: def _set_combo_selection_mode(self, mode: str) -> None: """Highlight current combo mode and enable only the relevant editors.""" - if self._is_multi_device_model_selected(): + if self._is_matrix_radar_model_selected(): self._run_combos_select_button.setChecked(True) self._single_combo_select_button.setChecked(False) - self._combos_text.setText(self._fixed_multi_combo_text()) + self._combos_text.setText(self._fixed_matrix_radar_combo_text()) self._combos_text.setEnabled(False) self._single_combo_output.setEnabled(True) self._single_combo_input.setEnabled(True) @@ -53,15 +53,15 @@ class AppWindowConfigProfileIOMixin: self._run_combos_select_button.setEnabled(True) self._single_combo_select_button.setEnabled(True) - def _is_multi_device_model_selected(self) -> bool: - """Return whether the loaded config targets LibreVNA multi-device acquisition.""" - return bool(self._defaults_config.is_multi_device) + def _is_matrix_radar_model_selected(self) -> bool: + """Return whether the loaded config targets a matrix-mode radar acquisition.""" + return bool(self._defaults_config.is_matrix_radar) - def _fixed_multi_combo_text(self) -> str: - """Return the canonical virtual combo matrix shown for multi-device mode.""" + def _fixed_matrix_radar_combo_text(self) -> str: + """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_multi_device_virtual_combos() + for combo in RunConfigModel.build_matrix_radar_virtual_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 b41fc6a..09ecac3 100644 --- a/python_app/gui/controllers/app_window_config/state_builders.py +++ b/python_app/gui/controllers/app_window_config/state_builders.py @@ -114,10 +114,10 @@ class AppWindowConfigStateBuildersMixin: @staticmethod def _format_combos_text_from_config(config: RunConfigModel) -> str: """Render configured combos for UI text editor, keeping full matrix as empty.""" - if config.is_multi_device: + if config.is_matrix_radar: return ",".join( f"{int(combo.input)}:{int(combo.output)}" - for combo in RunConfigModel.build_multi_device_virtual_combos() + for combo in RunConfigModel.build_matrix_radar_virtual_combos() ) combos = list(config.combos) full_combos = config.build_full_combos(config.input_switch.positions, config.output_switch.positions) @@ -285,14 +285,14 @@ class AppWindowConfigStateBuildersMixin: switches=GuiSwitchStateModel( combo_mode=( "text" - if self._is_multi_device_model_selected() + if self._is_matrix_radar_model_selected() else "single" if self._single_combo_select_button.isChecked() else "text" ), combos_text=( - self._fixed_multi_combo_text() - if self._is_multi_device_model_selected() + self._fixed_matrix_radar_combo_text() + if self._is_matrix_radar_model_selected() else self._combos_text.text().strip() ), single_input=self._single_combo_input.text().strip(), diff --git a/python_app/gui/controllers/app_window_plot/bscan_plot_mixin.py b/python_app/gui/controllers/app_window_plot/bscan_plot_mixin.py index 8d18316..64c0818 100644 --- a/python_app/gui/controllers/app_window_plot/bscan_plot_mixin.py +++ b/python_app/gui/controllers/app_window_plot/bscan_plot_mixin.py @@ -312,10 +312,10 @@ class AppWindowBscanPlotMixin: return display_key def _requested_bscan_display_key(self) -> tuple[int, int] | None: - """Return the multi-device B-scan display combo requested by the GUI.""" + """Return the matrix-radar B-scan display combo requested by the GUI.""" if not ( - hasattr(self, "_is_multi_device_model_selected") - and self._is_multi_device_model_selected() + hasattr(self, "_is_matrix_radar_model_selected") + and self._is_matrix_radar_model_selected() and hasattr(self, "_single_combo_input") and hasattr(self, "_single_combo_output") ): diff --git a/python_app/hardware_full/matrix_radar_service.py b/python_app/hardware_full/matrix_radar_service.py new file mode 100644 index 0000000..87a1c96 --- /dev/null +++ b/python_app/hardware_full/matrix_radar_service.py @@ -0,0 +1,67 @@ +"""Factory for matrix-mode (multi-port) radar acquisition services. + +Matrix radars acquire the full virtual switch matrix per call via +``acquire_collection`` instead of one combo per ``acquire`` like single-radar +services. The supported models are LibreVNA in synchronized multi-device mode +and the PLANAR SN9000 multi-port analyzer. + +Service implementations are imported lazily so a config that targets one +model does not pull in transport dependencies (libusb, pyvisa, etc.) needed +only by the other model. +""" + +from __future__ import annotations + +from typing import Protocol + +from python_app.models.dataset_model import SweepCollection +from python_app.models.run_config_model import RadarSweepModel, RunConfigModel + + +class MatrixRadarService(Protocol): + """Common API for radars that emit a full combo matrix per acquisition.""" + + def open(self) -> None: + """Open hardware connections.""" + + def close(self) -> None: + """Close hardware connections.""" + + def configure(self, sweep: RadarSweepModel) -> None: + """Apply sweep settings to the radar.""" + + def acquire_collection(self, collection_id: int = 1) -> SweepCollection: + """Acquire one complete virtual switch matrix in canonical combo order.""" + + def recover(self) -> None: + """Reconnect to the radar after a transient acquisition failure.""" + + +def create_matrix_radar_service(config: RunConfigModel) -> MatrixRadarService: + """Create the matrix radar service implementation for the active config.""" + if not config.is_matrix_radar: + raise RuntimeError("matrix radar service factory requires a matrix-mode radar.model") + + model = config.radar.model + if model == RunConfigModel.LIBREVNA_MULTI_MODEL: + from python_app.hardware_full.multi_device_service import MultiDeviceLibreVnaService + + return 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, + ) + + if model == RunConfigModel.SN9000_MODEL: + if config.radar.driver_mode != "native": + raise RuntimeError("SN9000 requires radar.driver_mode='native'") + from python_app.hardware_full.sn9000_service import Sn9000Service + + return Sn9000Service( + host=config.radar.remote_host, + port=config.radar.remote_port, + ) + + raise RuntimeError(f"Unsupported matrix radar model: {model}") diff --git a/python_app/hardware_full/single_radar_service.py b/python_app/hardware_full/single_radar_service.py index 681b439..4cea092 100644 --- a/python_app/hardware_full/single_radar_service.py +++ b/python_app/hardware_full/single_radar_service.py @@ -31,9 +31,12 @@ class SingleRadarService(Protocol): def create_single_radar_service(config: RunConfigModel) -> SingleRadarService: - """Create the Python service for a non-multi-device radar config.""" - if config.is_multi_device: - raise RuntimeError("single-radar service factory does not support librevna_multi") + """Create the Python service for a non-matrix-radar config.""" + if config.is_matrix_radar: + raise RuntimeError( + "single-radar service factory does not support matrix radars; " + "use create_matrix_radar_service instead" + ) model = config.radar.model or RunConfigModel.LIBREVNA_MODEL if model == RunConfigModel.LIBREVNA_MODEL: diff --git a/python_app/hardware_full/sn9000_service.py b/python_app/hardware_full/sn9000_service.py new file mode 100644 index 0000000..b11e135 --- /dev/null +++ b/python_app/hardware_full/sn9000_service.py @@ -0,0 +1,322 @@ +"""VISA HiSLIP driver for the PLANAR SN9000 (Иридиум series) multi-port VNA. + +The SN9000 hardware connects to a host PC over USB 2.0; the PC runs the SNVNA +application, which exposes the SCPI HiSLIP server. This service connects to +that server, configures a 2x4 virtual switch matrix (ports 1 and 2 stimulate, +ports 3..6 receive), and acquires the full matrix per call in a single +synchronized SCPI round trip. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import time +from typing import Any + +import numpy as np +import pyvisa + +from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData +from python_app.models.run_config_model import RadarSweepModel + +_OUTPUT_PORT_BY_INDEX: tuple[int, ...] = (1, 2) +_INPUT_PORT_BY_INDEX: tuple[int, ...] = (3, 4, 5, 6) +_REFLECTION_BY_OUTPUT: tuple[str, ...] = tuple( + f"S{port}{port}" for port in _OUTPUT_PORT_BY_INDEX +) +_S_PARAMETER_QUERY_ORDER: tuple[str, ...] = tuple( + name + for output_port in _OUTPUT_PORT_BY_INDEX + for name in ( + f"S{output_port}{output_port}", + *(f"S{receiver_port}{output_port}" for receiver_port in _INPUT_PORT_BY_INDEX), + ) +) + + +@dataclass(slots=True) +class Sn9000Service: + """Acquire the full 2x4 corrected sweep matrix from an SN9000 through VISA HiSLIP.""" + + host: str = "127.0.0.1" + port: int = 4880 + timeout_ms: int = 20_000 + preset_on_open: bool = True + visa_library: str = "@ivi" + _resource_manager: pyvisa.ResourceManager | None = field(init=False, default=None, repr=False) + _instrument: Any | None = field(init=False, default=None, repr=False) + _settings: RadarSweepModel | None = field(init=False, default=None, repr=False) + _frequency_hz: np.ndarray | None = field(init=False, default=None, repr=False) + + def __post_init__(self) -> None: + """Normalize and validate constructor values.""" + self.host = str(self.host).strip() + if not self.host: + raise ValueError("SN9000 host must not be empty") + self.port = int(self.port) + if self.port <= 0 or self.port > 65535: + raise ValueError("SN9000 port must be in 1..65535") + self.timeout_ms = int(self.timeout_ms) + if self.timeout_ms <= 0: + raise ValueError("SN9000 timeout_ms must be > 0") + self.visa_library = str(self.visa_library).strip() or "@ivi" + if self.visa_library == "@py" or self.visa_library.endswith("@py"): + raise ValueError("SN9000 requires an IVI/Vendor VISA backend, not pyvisa-py") + + @property + def resource(self) -> str: + """Return the assembled VISA HiSLIP resource string.""" + return f"TCPIP0::{self.host}::hislip0,{self.port}::INSTR" + + @property + def is_open(self) -> bool: + """Return whether the VISA session is open.""" + return self._instrument is not None + + def open(self) -> None: + """Open the VISA session and apply stored sweep settings when available.""" + if self._instrument is not None: + return + + try: + self._resource_manager = pyvisa.ResourceManager(self.visa_library) + self._instrument = self._resource_manager.open_resource(self.resource) + self._instrument.timeout = self.timeout_ms + self._instrument.write_termination = "\n" + self._instrument.read_termination = None + self._instrument.chunk_size = max(int(getattr(self._instrument, "chunk_size", 20_480)), 8 * 1024 * 1024) + self._instrument.write("*CLS") + + if self._settings is not None: + self._apply_configuration(self._settings) + except Exception: + self.close() + raise + + def close(self) -> None: + """Close VISA sessions.""" + if self._instrument is not None: + self._instrument.close() + self._instrument = None + if self._resource_manager is not None: + self._resource_manager.close() + self._resource_manager = None + + def __enter__(self) -> Sn9000Service: + """Open the service and return it.""" + self.open() + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + """Close VISA resources on context exit.""" + self.close() + + def recover(self) -> None: + """Reopen the VISA session after a transient acquisition failure.""" + self.close() + time.sleep(0.25) + self.open() + + def query_identity(self) -> str: + """Read the analyzer identity string.""" + instrument = self._require_instrument() + return str(instrument.query("*IDN?")).strip() + + def query_system_error(self) -> str: + """Read one entry from the analyzer SCPI error queue.""" + instrument = self._require_instrument() + return str(instrument.query("SYST:ERR?")).strip() + + def configure(self, sweep: RadarSweepModel) -> None: + """Store and apply sweep settings.""" + self._validate_sweep(sweep) + self._settings = sweep + self._frequency_hz = None + if self._instrument is None: + return + self._apply_configuration(sweep) + + def read_device_limits(self) -> dict[str, float | int]: + """Read analyzer limits through SCPI capability/service queries.""" + opened_here = self._instrument is None + try: + if opened_here: + self.open() + instrument = self._require_instrument() + return { + "min_frequency_hz": float(instrument.query("SERV:SWE:FREQ:MIN?")), + "max_frequency_hz": float(instrument.query("SERV:SWE:FREQ:MAX?")), + "max_points": int(float(instrument.query("SERV:SWE:POIN?"))), + "min_power_dbm": float(instrument.query("SERV:SWE:POW:MIN?")), + "max_power_dbm": float(instrument.query("SERV:SWE:POW:MAX?")), + } + finally: + if opened_here: + self.close() + + def frequency_axis(self) -> np.ndarray: + """Return the cached frequency axis from the most recent configuration.""" + if self._frequency_hz is None: + raise RuntimeError("SN9000 frequency axis is not configured") + return self._frequency_hz + + def acquire_collection(self, collection_id: int = 1) -> SweepCollection: + """Acquire one full virtual 2x4 matrix in canonical combo order.""" + if self._settings is None: + raise RuntimeError("SN9000 service is not configured") + if self._frequency_hz is None: + raise RuntimeError("SN9000 frequency axis is not configured") + points = int(self._settings.points) + capture_start_ns = time.monotonic_ns() + + s_parameters = self._query_sweep_s_parameters(points) + traces = self._assemble_traces(s_parameters) + + return SweepCollection( + collection_id=int(collection_id), + monotonic_ns=time.monotonic_ns(), + traces=traces, + capture_start_ns=capture_start_ns, + capture_end_ns=time.monotonic_ns(), + ) + + def _apply_configuration(self, sweep: RadarSweepModel) -> None: + instrument = self._require_instrument() + if self.preset_on_open: + instrument.write("SYST:PRES") + self._expect_opc("*OPC?", context="SN9000 preset") + + instrument.write(f"CALC:PAR:COUN {len(_S_PARAMETER_QUERY_ORDER)}") + for trace_index, parameter_name in enumerate(_S_PARAMETER_QUERY_ORDER, start=1): + instrument.write(f"CALC:PAR{trace_index}:DEF {parameter_name}") + + instrument.write("SENS:SWE:TYPE LIN") + instrument.write(f"SENS:FREQ:STAR {float(sweep.start_hz):.9f}") + instrument.write(f"SENS:FREQ:STOP {float(sweep.stop_hz):.9f}") + instrument.write(f"SENS:SWE:POIN {int(sweep.points)}") + instrument.write("SENS:SWE:POIN:TIME 0") + instrument.write(f"SENS:BAND {float(sweep.if_bandwidth_hz):.9f}") + instrument.write("SOUR:POW:PORT:COUP ON") + instrument.write(f"SOUR:POW {float(sweep.power_dbm):.3f}") + instrument.write("SENS:AVER OFF") + instrument.write("FORM:DATA REAL32") + instrument.write("FORM:BORD SWAP") + instrument.write("INIT:CONT ON") + instrument.write("TRIG:SOUR BUS") + self._expect_opc("*OPC?", context="SN9000 setup") + self._frequency_hz = self._query_float32_array("SENS:FREQ:DATA?", int(sweep.points)) + + def _query_sweep_s_parameters(self, points: int) -> dict[str, np.ndarray]: + instrument = self._require_instrument() + data_queries = ";".join(f":SENS:DATA:CORR? {name}" for name in _S_PARAMETER_QUERY_ORDER) + instrument.write(f"TRIG:SING;*OPC?;{data_queries}") + opc_token = self._read_ascii_token() + if opc_token != "1": + raise RuntimeError(f"SN9000 sweep returned unexpected *OPC? response: {opc_token!r}") + + complex_values: dict[str, np.ndarray] = {} + for parameter_name in _S_PARAMETER_QUERY_ORDER: + interleaved = self._read_float32_block(f"SENS:DATA:CORR? {parameter_name}", points * 2) + complex_values[parameter_name] = self._complex_from_interleaved(interleaved) + return complex_values + + def _assemble_traces(self, s_parameters: dict[str, np.ndarray]) -> list[TraceData]: + frequency_hz = self._require_frequency_axis() + traces: list[TraceData] = [] + for output_position, output_port in enumerate(_OUTPUT_PORT_BY_INDEX): + reflection = s_parameters[_REFLECTION_BY_OUTPUT[output_position]] + for input_position, receiver_port in enumerate(_INPUT_PORT_BY_INDEX): + transmission = s_parameters[f"S{receiver_port}{output_port}"] + traces.append( + TraceData( + combo=ComboKey(input=input_position, output=output_position), + frequency_hz=frequency_hz, + s11=reflection, + s21=transmission, + ) + ) + return traces + + def _expect_opc(self, command: str, *, context: str) -> None: + instrument = self._require_instrument() + response = str(instrument.query(command)).strip() + if response != "1": + raise RuntimeError(f"{context} returned unexpected *OPC? response: {response!r}") + + def _query_float32_array(self, command: str, expected_values: int) -> np.ndarray: + instrument = self._require_instrument() + instrument.write(command) + return self._read_float32_block(command, expected_values) + + def _read_ascii_token(self) -> str: + token = bytearray() + while True: + byte = self._read_response_bytes(1) + if byte in (b";", b"\n", b"\r"): + if token: + return token.decode("ascii").strip() + continue + token.extend(byte) + if len(token) > 64 * 1024: + raise RuntimeError("SN9000 ASCII response token is too long") + + def _read_float32_block(self, context: str, expected_values: int) -> np.ndarray: + marker = self._read_response_bytes(1) + while marker in (b";", b"\n", b"\r"): + marker = self._read_response_bytes(1) + if marker != b"#": + raise RuntimeError(f"SN9000 response for {context!r} does not start with IEEE block marker") + + width = self._read_response_bytes(1) + if width != b"8": + raise RuntimeError(f"SN9000 response for {context!r} uses unsupported IEEE block header width") + + payload_size = int(self._read_response_bytes(8).decode("ascii")) + expected_size = expected_values * np.dtype(np.float32).itemsize + if payload_size != expected_size: + raise RuntimeError( + f"SN9000 response for {context!r} returned {payload_size} payload bytes, " + f"expected {expected_size}" + ) + + payload = self._read_response_bytes(payload_size) + array = np.frombuffer(payload, dtype=" bytes: + instrument = self._require_instrument() + data = instrument.read_bytes(count, break_on_termchar=False) + if len(data) != count: + raise RuntimeError(f"SN9000 response ended after {len(data)} bytes, expected {count}") + return data + + def _require_instrument(self) -> Any: + if self._instrument is None: + raise RuntimeError("SN9000 VISA instrument is not open") + return self._instrument + + def _require_frequency_axis(self) -> np.ndarray: + if self._frequency_hz is None: + raise RuntimeError("SN9000 frequency axis is not configured") + return self._frequency_hz + + @staticmethod + def _validate_sweep(sweep: RadarSweepModel) -> None: + if int(sweep.points) < 2: + raise ValueError("SN9000 sweep points must be >= 2") + if float(sweep.stop_hz) < float(sweep.start_hz): + raise ValueError("SN9000 sweep stop_hz must be >= start_hz") + if float(sweep.if_bandwidth_hz) <= 0.0: + raise ValueError("SN9000 IF bandwidth must be > 0") + + @staticmethod + def _complex_from_interleaved(values: np.ndarray) -> np.ndarray: + if values.size % 2 != 0: + raise RuntimeError("SN9000 complex trace payload has odd scalar count") + reshaped = np.asarray(values, dtype=np.float32).reshape((-1, 2)) + return (reshaped[:, 0] + 1j * reshaped[:, 1]).astype(np.complex64) diff --git a/python_app/models/run_config_schema.py b/python_app/models/run_config_schema.py index b6e4f14..41467a4 100644 --- a/python_app/models/run_config_schema.py +++ b/python_app/models/run_config_schema.py @@ -270,6 +270,7 @@ class RunConfigModel: LIBREVNA_MULTI_MODEL = "librevna_multi" COMPACT_M_K209_MODEL = "compact_m_k209" KAMIL_ADC_MODEL = "kamil_adc" + SN9000_MODEL = "sn9000" MULTI_DEVICE_INPUT_POSITIONS = 4 MULTI_DEVICE_OUTPUT_POSITIONS = 2 @@ -290,11 +291,26 @@ class RunConfigModel: cls.MULTI_DEVICE_OUTPUT_POSITIONS, ) + @classmethod + def build_matrix_radar_virtual_combos(cls) -> list[ComboModel]: + """Build the canonical 2x4 virtual combo matrix shared by matrix-mode radars.""" + return cls.build_multi_device_virtual_combos() + @property def is_multi_device(self) -> bool: """Return whether this config targets synchronized multi-device acquisition.""" return self.radar.model == self.LIBREVNA_MULTI_MODEL + @property + def is_sn9000(self) -> bool: + """Return whether this config targets the SN9000 multi-port analyzer.""" + return self.radar.model == self.SN9000_MODEL + + @property + def is_matrix_radar(self) -> bool: + """Return whether this config acquires the full virtual switch matrix per sweep.""" + return self.is_multi_device or self.is_sn9000 + @property def is_kamil_adc(self) -> bool: """Return whether this config targets the external Kamil ADC acquisition path.""" @@ -348,10 +364,13 @@ class RunConfigModel: def apply_device_model_constraints(self) -> None: """Apply only required wire-format constraints for the selected device model.""" - if not self.is_multi_device: + if not self.is_matrix_radar: return + self._apply_matrix_virtual_switches() + self.combos = self.build_matrix_radar_virtual_combos() - self.radar.model = self.LIBREVNA_MULTI_MODEL + def _apply_matrix_virtual_switches(self) -> None: + """Pin the canonical 2x4 virtual switch matrix used by all matrix-mode radars.""" 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" @@ -365,11 +384,10 @@ class RunConfigModel: self.input_switch.radar_port = 2 self.input_switch.positions = self.MULTI_DEVICE_INPUT_POSITIONS self.input_switch.default_position = 0 - self.combos = self.build_multi_device_virtual_combos() def ensure_combos(self) -> None: """Populate combos with full matrix when no explicit run combos are set.""" - if self.is_multi_device: + if self.is_matrix_radar: self.apply_device_model_constraints() return if self.combos: diff --git a/python_app/orchestration/process_supervisor.py b/python_app/orchestration/process_supervisor.py index a72a97f..9b3a629 100644 --- a/python_app/orchestration/process_supervisor.py +++ b/python_app/orchestration/process_supervisor.py @@ -170,11 +170,11 @@ class ProcessSupervisor: def _acquisition_command(self, config_path: Path) -> list[str]: """Return acquisition producer command selected by radar.model.""" radar_model = self._read_radar_model(config_path) - if radar_model == "librevna_multi": + if radar_model in {"librevna_multi", "sn9000"}: return [ sys.executable, "-m", - "python_app.scripts.multi_device_raw_producer", + "python_app.scripts.matrix_raw_producer", "--config", str(config_path), ] diff --git a/python_app/scripts/matrix_raw_producer.py b/python_app/scripts/matrix_raw_producer.py new file mode 100644 index 0000000..795a736 --- /dev/null +++ b/python_app/scripts/matrix_raw_producer.py @@ -0,0 +1,92 @@ +"""Raw acquisition producer for matrix-mode radars (LibreVNA multi-device, SN9000).""" + +from __future__ import annotations + +import argparse +import logging +from pathlib import Path +import signal +import threading +import time + +from python_app.hardware_full.matrix_radar_service import create_matrix_radar_service +from python_app.models.run_config_model import RunConfigModel +from python_app.orchestration.shm import ShmRingWriter +from python_app.storage.npz.serialize import RAW_MAGIC, serialize_trace_collection + +logger = logging.getLogger(__name__) + + +def main() -> int: + """Run producer process until config or signal requests exit.""" + parser = argparse.ArgumentParser(description="Publish matrix-radar raw sweeps to SHM rings") + parser.add_argument("--config", required=True, type=Path, help="Path to run_config.json") + args = parser.parse_args() + + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") + stop_requested = threading.Event() + + def request_stop(_signum: int, _frame: object) -> None: + stop_requested.set() + + signal.signal(signal.SIGINT, request_stop) + signal.signal(signal.SIGTERM, request_stop) + + config = RunConfigModel.load_from_path(args.config) + config.apply_device_model_constraints() + if not config.is_matrix_radar: + raise RuntimeError( + "matrix_raw_producer requires a matrix-mode radar.model " + "(librevna_multi or sn9000)" + ) + + raw_writer = ShmRingWriter( + config.rings.raw.name, + config.rings.raw.capacity, + config.rings.raw.slot_size_bytes, + ) + raw_tap_writer = ShmRingWriter( + config.rings.raw_tap.name, + config.rings.raw_tap.capacity, + config.rings.raw_tap.slot_size_bytes, + ) + radar = create_matrix_radar_service(config) + + try: + radar.open() + radar.configure(config.radar.sweep) + collection_id = 1 + while not stop_requested.is_set(): + collection_start = time.monotonic() + collection = radar.acquire_collection(collection_id=collection_id) + + payload = serialize_trace_collection(collection, RAW_MAGIC) + if not raw_writer.push(payload): + raise RuntimeError( + f"Raw payload size {len(payload)} exceeds ring slot size {raw_writer.slot_size_bytes}" + ) + if not raw_tap_writer.push(payload): + raise RuntimeError( + f"Raw tap payload size {len(payload)} exceeds ring slot size {raw_tap_writer.slot_size_bytes}" + ) + if not config.runtime.continuous: + break + collection_duration_s = time.monotonic() - collection_start + if collection_id == 1 or collection_id % 20 == 0 or collection_duration_s > 2.0: + logger.info( + "matrix radar collection %d acquired in %.3f s", + collection_id, + collection_duration_s, + ) + collection_id += 1 + finally: + radar.close() + raw_tap_writer.close() + raw_writer.close() + + logger.info("matrix radar raw producer stopped") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/python_app/scripts/sn9000_smoke_test.py b/python_app/scripts/sn9000_smoke_test.py new file mode 100644 index 0000000..892dfaa --- /dev/null +++ b/python_app/scripts/sn9000_smoke_test.py @@ -0,0 +1,124 @@ +"""Standalone smoke test for the SN9000 multi-port VISA HiSLIP acquisition path.""" + +from __future__ import annotations + +import argparse + +import numpy as np + +from python_app.hardware_full.sn9000_service import Sn9000Service +from python_app.models.dataset_model import SweepCollection +from python_app.models.run_config_model import RadarSweepModel, RunConfigModel + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Acquire one SN9000 collection through VISA HiSLIP") + parser.add_argument( + "--host", + default="127.0.0.1", + help="SNVNA HiSLIP server host (default: 127.0.0.1)", + ) + parser.add_argument( + "--port", + type=int, + default=4880, + help="SNVNA HiSLIP server TCP port (default: 4880)", + ) + parser.add_argument("--start-hz", type=float, default=1_000_000.0) + parser.add_argument("--stop-hz", type=float, default=3_000_000_000.0) + parser.add_argument("--points", type=int, default=201) + parser.add_argument("--ifbw-hz", type=float, default=10_000.0) + parser.add_argument("--power-dbm", type=float, default=-10.0) + parser.add_argument("--timeout-ms", type=int, default=20_000) + parser.add_argument("--no-preset", action="store_true") + parser.add_argument( + "--visa-library", + default="@ivi", + help="PyVISA IVI backend specification, e.g. @ivi or /usr/lib/x86_64-linux-gnu/libvisa.so", + ) + return parser.parse_args() + + +def _validate_collection(collection: SweepCollection, expected_points: int) -> None: + expected_traces = ( + RunConfigModel.MULTI_DEVICE_INPUT_POSITIONS * RunConfigModel.MULTI_DEVICE_OUTPUT_POSITIONS + ) + if len(collection.traces) != expected_traces: + raise RuntimeError( + f"SN9000 collection has {len(collection.traces)} traces, expected {expected_traces}" + ) + + reference_frequency = collection.traces[0].frequency_hz + if reference_frequency.shape != (expected_points,): + raise RuntimeError(f"Unexpected frequency shape: {reference_frequency.shape}") + if not np.all(np.isfinite(reference_frequency)): + raise RuntimeError("Frequency axis contains non-finite values") + if np.any(np.diff(reference_frequency) < 0.0): + raise RuntimeError("Frequency axis is not monotonic") + + expected_combos = RunConfigModel.build_matrix_radar_virtual_combos() + for trace, expected_combo in zip(collection.traces, expected_combos, strict=True): + if ( + int(trace.combo.input) != int(expected_combo.input) + or int(trace.combo.output) != int(expected_combo.output) + ): + raise RuntimeError( + f"Trace combo mismatch: got ({trace.combo.input},{trace.combo.output}), " + f"expected ({expected_combo.input},{expected_combo.output})" + ) + if trace.s11.shape != (expected_points,) or trace.s21.shape != (expected_points,): + raise RuntimeError( + f"Trace ({trace.combo.input},{trace.combo.output}) has shapes " + f"s11={trace.s11.shape}, s21={trace.s21.shape}" + ) + if not np.all(np.isfinite(trace.s11.real)) or not np.all(np.isfinite(trace.s11.imag)): + raise RuntimeError( + f"Trace ({trace.combo.input},{trace.combo.output}) S11 contains non-finite values" + ) + if not np.all(np.isfinite(trace.s21.real)) or not np.all(np.isfinite(trace.s21.imag)): + raise RuntimeError( + f"Trace ({trace.combo.input},{trace.combo.output}) S21 contains non-finite values" + ) + + +def main() -> int: + args = _parse_args() + sweep = RadarSweepModel( + start_hz=args.start_hz, + stop_hz=args.stop_hz, + points=args.points, + if_bandwidth_hz=args.ifbw_hz, + power_dbm=args.power_dbm, + ) + + service = Sn9000Service( + host=args.host, + port=args.port, + timeout_ms=args.timeout_ms, + preset_on_open=not args.no_preset, + visa_library=args.visa_library, + ) + try: + service.open() + print(f"SN9000 IDN: {service.query_identity()}") + service.configure(sweep) + collection = service.acquire_collection(collection_id=1) + _validate_collection(collection, args.points) + system_error = service.query_system_error() + if not system_error.startswith("0,"): + raise RuntimeError(f"SN9000 SCPI error after sweep: {system_error}") + mean_abs_s21 = np.mean([float(np.mean(np.abs(trace.s21))) for trace in collection.traces]) + print( + "SN9000 collection OK: " + f"traces={len(collection.traces)}, points={args.points}, " + f"first_hz={collection.traces[0].frequency_hz[0]:.3f}, " + f"last_hz={collection.traces[0].frequency_hz[-1]:.3f}, " + f"mean_abs_s21={mean_abs_s21:.6g}" + ) + finally: + service.close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/python_app/workflows/multi_radar_capture_workflow.py b/python_app/workflows/multi_radar_capture_workflow.py index 9bf29ae..1a38d36 100644 --- a/python_app/workflows/multi_radar_capture_workflow.py +++ b/python_app/workflows/multi_radar_capture_workflow.py @@ -8,7 +8,7 @@ import time import numpy as np -from python_app.hardware_full.multi_device_service import MultiDeviceLibreVnaService +from python_app.hardware_full.matrix_radar_service import MatrixRadarService, create_matrix_radar_service from python_app.hardware_full.single_radar_service import create_single_radar_service from python_app.hardware_full.switch_service import SwitchService from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData @@ -74,11 +74,12 @@ class MultiRadarSequentialCaptureSession: self._set_name = set_name self._radar_variants = list(radar_variants) self._median_sweep_count = int(median_sweep_count) + self._is_matrix_radar = base_config.is_matrix_radar self._is_multi_device = base_config.is_multi_device self._manual_multi_device_capture = self._is_multi_device and kind in MULTI_DEVICE_MANUAL_CAPTURE_KINDS self._combos = ( - RunConfigModel.build_multi_device_virtual_combos() - if self._is_multi_device + 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, @@ -95,14 +96,8 @@ class MultiRadarSequentialCaptureSession: self._next_index = 0 self._opened = False - if self._is_multi_device: - self._radar = MultiDeviceLibreVnaService( - master_serial=base_config.radar.serial, - slave_serials=list(base_config.radar.multi_device.slave_serials), - force_external_reference=base_config.radar.multi_device.force_external_reference, - recovery_attempts=base_config.radar.multi_device.recovery_attempts, - backend_mode=base_config.radar.driver_mode, - ) + if self._is_matrix_radar: + self._radar: MatrixRadarService = create_matrix_radar_service(base_config) self._input_switch = None self._output_switch = None else: @@ -174,7 +169,7 @@ class MultiRadarSequentialCaptureSession: set_name=self._set_name, captured_count=( self._next_index - if self._is_multi_device and not self._manual_multi_device_capture + if self._is_matrix_radar and not self._manual_multi_device_capture else len(self._captured_batches) ), total_count=len(self._combos), @@ -197,7 +192,7 @@ class MultiRadarSequentialCaptureSession: display_traces: list[TraceData] = [] variant_labels: list[str] = [] - if self._is_multi_device: + if self._is_matrix_radar: for variant in self._radar_variants: self._radar.configure(variant.config.radar.sweep) if self._base_config.runtime.settling_ms > 0: @@ -207,7 +202,7 @@ class MultiRadarSequentialCaptureSession: collection = self._radar.acquire_collection(collection_id=1) if not collection.traces: raise RuntimeError( - f"Multi-device variant {variant.display_name} returned no traces" + f"Matrix radar variant {variant.display_name} returned no traces" ) collections.append(collection) if self._manual_multi_device_capture: @@ -256,7 +251,7 @@ class MultiRadarSequentialCaptureSession: variant_labels=tuple(variant_labels), ) self._captured_batches.append(batch) - if self._is_multi_device and not self._manual_multi_device_capture: + if self._is_matrix_radar and not self._manual_multi_device_capture: self._next_index = len(self._combos) else: self._next_index += 1 @@ -269,12 +264,12 @@ class MultiRadarSequentialCaptureSession: if not self._captured_batches or self._next_index <= 0: raise RuntimeError("No captured combo is available to undo") - if self._is_multi_device and not self._manual_multi_device_capture: + if self._is_matrix_radar and not self._manual_multi_device_capture: removed_batch = self._captured_batches[-1] for variant in self._radar_variants: traces = self._traces_by_radar_key[variant.radar_key] if len(traces) < len(self._combos): - raise RuntimeError("Capture session state is inconsistent; missing multi-device traces") + raise RuntimeError("Capture session state is inconsistent; missing matrix radar traces") del traces[-len(self._combos) :] self._next_index = 0 self._captured_batches.pop() diff --git a/python_app/workflows/sequential_capture_workflow.py b/python_app/workflows/sequential_capture_workflow.py index d69e2af..d328ecd 100644 --- a/python_app/workflows/sequential_capture_workflow.py +++ b/python_app/workflows/sequential_capture_workflow.py @@ -8,7 +8,7 @@ import time import numpy as np -from python_app.hardware_full.multi_device_service import MultiDeviceLibreVnaService +from python_app.hardware_full.matrix_radar_service import MatrixRadarService, create_matrix_radar_service from python_app.hardware_full.single_radar_service import create_single_radar_service from python_app.hardware_full.switch_service import SwitchService from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData @@ -57,11 +57,12 @@ class SequentialCaptureSession: self._kind = kind self._set_name = set_name self._median_sweep_count = int(median_sweep_count) + self._is_matrix_radar = config.is_matrix_radar self._is_multi_device = config.is_multi_device self._manual_multi_device_capture = self._is_multi_device and kind in MULTI_DEVICE_MANUAL_CAPTURE_KINDS self._combos = ( - RunConfigModel.build_multi_device_virtual_combos() - if self._is_multi_device + RunConfigModel.build_matrix_radar_virtual_combos() + if self._is_matrix_radar else RunConfigModel.build_full_combos(config.input_switch.positions, config.output_switch.positions) ) if not self._combos: @@ -71,14 +72,8 @@ class SequentialCaptureSession: self._next_index = 0 self._opened = False - if self._is_multi_device: - self._radar = 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, - ) + if self._is_matrix_radar: + self._radar: MatrixRadarService = create_matrix_radar_service(config) self._input_switch = None self._output_switch = None else: @@ -164,12 +159,12 @@ class SequentialCaptureSession: if combo is None: raise RuntimeError("Capture session is already complete") - if self._is_multi_device: + if self._is_matrix_radar: collections: list[SweepCollection] = [] for _ in range(self._median_sweep_count): collection = self._radar.acquire_collection(collection_id=1) if not collection.traces: - raise RuntimeError("Multi-device capture returned no traces") + raise RuntimeError("Matrix radar capture returned no traces") collections.append(collection) if self._manual_multi_device_capture: per_sweep_traces = [select_trace_for_combo(collection, combo) for collection in collections] @@ -213,9 +208,9 @@ class SequentialCaptureSession: if not self._traces or self._next_index <= 0: raise RuntimeError("No captured combo is available to undo") - if self._is_multi_device and not self._manual_multi_device_capture: + if self._is_matrix_radar and not self._manual_multi_device_capture: if len(self._traces) != len(self._combos): - raise RuntimeError("Capture session state is inconsistent; multi-device trace matrix is incomplete") + raise RuntimeError("Capture session state is inconsistent; matrix radar trace matrix is incomplete") removed_trace = self._traces[-1] self._traces.clear() self._next_index = 0 diff --git a/Руководство_по_эксплуатации_SNVNA_часть_2_ver_26.1.pdf b/Руководство_по_эксплуатации_SNVNA_часть_2_ver_26.1.pdf new file mode 100644 index 0000000..9b32032 Binary files /dev/null and b/Руководство_по_эксплуатации_SNVNA_часть_2_ver_26.1.pdf differ