sn9000 support
This commit is contained in:
@@ -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)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,7 +73,11 @@ void install_signal_handlers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (config.model == kLibreVnaMultiModel) {
|
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);
|
throw std::runtime_error("Unsupported radar.model for sweep_orchestrator: " + config.model);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ Available models:
|
|||||||
librevna
|
librevna
|
||||||
librevna_multi
|
librevna_multi
|
||||||
compact_m_k209
|
compact_m_k209
|
||||||
|
sn9000
|
||||||
kamil_adc
|
kamil_adc
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -48,7 +49,8 @@ The GUI process supervisor starts the correct producer automatically:
|
|||||||
|
|
||||||
- `librevna` -> `build/bin/sweep_orchestrator`
|
- `librevna` -> `build/bin/sweep_orchestrator`
|
||||||
- `compact_m_k209` -> `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`
|
- `kamil_adc` -> `python_app.scripts.kamil_adc_raw_producer`
|
||||||
|
|
||||||
## Pure Simulator
|
## Pure Simulator
|
||||||
@@ -140,7 +142,7 @@ Notes:
|
|||||||
run:
|
run:
|
||||||
|
|
||||||
```bash
|
```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
|
--config run_config_librevna_multi.example.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+18
-6
@@ -42,11 +42,11 @@ Fields:
|
|||||||
|
|
||||||
| Field | Meaning |
|
| Field | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | `librevna`, `librevna_multi`, `compact_m_k209`, or `kamil_adc`. |
|
| `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. |
|
| `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` | K209 remote server host. Used by `compact_m_k209`; ignored by LibreVNA modes. |
|
| `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` | K209 remote server TCP port. Default is `50209`. |
|
| `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 and Kamil ADC require `native`. |
|
| `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. |
|
| `mock_signal_hz` | Existing LibreVNA mock signal parameter used by C++ mock acquisition. |
|
||||||
| `multi_device` | Extra settings for `librevna_multi`. |
|
| `multi_device` | Extra settings for `librevna_multi`. |
|
||||||
| `kamil_adc` | External collector process and TTY settings for `kamil_adc`. |
|
| `kamil_adc` | External collector process and TTY settings for `kamil_adc`. |
|
||||||
@@ -266,7 +266,8 @@ Fields:
|
|||||||
{"input": 2, "output": 1}
|
{"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
|
```text
|
||||||
input: 0..3
|
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:
|
Kamil ADC:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -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::<radar.remote_host>::hislip0,<radar.remote_port>::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).
|
||||||
@@ -33,10 +33,10 @@ class AppWindowConfigProfileIOMixin:
|
|||||||
|
|
||||||
def _set_combo_selection_mode(self, mode: str) -> None:
|
def _set_combo_selection_mode(self, mode: str) -> None:
|
||||||
"""Highlight current combo mode and enable only the relevant editors."""
|
"""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._run_combos_select_button.setChecked(True)
|
||||||
self._single_combo_select_button.setChecked(False)
|
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._combos_text.setEnabled(False)
|
||||||
self._single_combo_output.setEnabled(True)
|
self._single_combo_output.setEnabled(True)
|
||||||
self._single_combo_input.setEnabled(True)
|
self._single_combo_input.setEnabled(True)
|
||||||
@@ -53,15 +53,15 @@ class AppWindowConfigProfileIOMixin:
|
|||||||
self._run_combos_select_button.setEnabled(True)
|
self._run_combos_select_button.setEnabled(True)
|
||||||
self._single_combo_select_button.setEnabled(True)
|
self._single_combo_select_button.setEnabled(True)
|
||||||
|
|
||||||
def _is_multi_device_model_selected(self) -> bool:
|
def _is_matrix_radar_model_selected(self) -> bool:
|
||||||
"""Return whether the loaded config targets LibreVNA multi-device acquisition."""
|
"""Return whether the loaded config targets a matrix-mode radar acquisition."""
|
||||||
return bool(self._defaults_config.is_multi_device)
|
return bool(self._defaults_config.is_matrix_radar)
|
||||||
|
|
||||||
def _fixed_multi_combo_text(self) -> str:
|
def _fixed_matrix_radar_combo_text(self) -> str:
|
||||||
"""Return the canonical virtual combo matrix shown for multi-device mode."""
|
"""Return the canonical virtual combo matrix shown for matrix-mode radars."""
|
||||||
return ",".join(
|
return ",".join(
|
||||||
f"{int(combo.input)}:{int(combo.output)}"
|
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:
|
def _sync_pass_through_y_controls(self) -> None:
|
||||||
|
|||||||
@@ -114,10 +114,10 @@ class AppWindowConfigStateBuildersMixin:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_combos_text_from_config(config: RunConfigModel) -> str:
|
def _format_combos_text_from_config(config: RunConfigModel) -> str:
|
||||||
"""Render configured combos for UI text editor, keeping full matrix as empty."""
|
"""Render configured combos for UI text editor, keeping full matrix as empty."""
|
||||||
if config.is_multi_device:
|
if config.is_matrix_radar:
|
||||||
return ",".join(
|
return ",".join(
|
||||||
f"{int(combo.input)}:{int(combo.output)}"
|
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)
|
combos = list(config.combos)
|
||||||
full_combos = config.build_full_combos(config.input_switch.positions, config.output_switch.positions)
|
full_combos = config.build_full_combos(config.input_switch.positions, config.output_switch.positions)
|
||||||
@@ -285,14 +285,14 @@ class AppWindowConfigStateBuildersMixin:
|
|||||||
switches=GuiSwitchStateModel(
|
switches=GuiSwitchStateModel(
|
||||||
combo_mode=(
|
combo_mode=(
|
||||||
"text"
|
"text"
|
||||||
if self._is_multi_device_model_selected()
|
if self._is_matrix_radar_model_selected()
|
||||||
else "single"
|
else "single"
|
||||||
if self._single_combo_select_button.isChecked()
|
if self._single_combo_select_button.isChecked()
|
||||||
else "text"
|
else "text"
|
||||||
),
|
),
|
||||||
combos_text=(
|
combos_text=(
|
||||||
self._fixed_multi_combo_text()
|
self._fixed_matrix_radar_combo_text()
|
||||||
if self._is_multi_device_model_selected()
|
if self._is_matrix_radar_model_selected()
|
||||||
else self._combos_text.text().strip()
|
else self._combos_text.text().strip()
|
||||||
),
|
),
|
||||||
single_input=self._single_combo_input.text().strip(),
|
single_input=self._single_combo_input.text().strip(),
|
||||||
|
|||||||
@@ -312,10 +312,10 @@ class AppWindowBscanPlotMixin:
|
|||||||
return display_key
|
return display_key
|
||||||
|
|
||||||
def _requested_bscan_display_key(self) -> tuple[int, int] | None:
|
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 (
|
if not (
|
||||||
hasattr(self, "_is_multi_device_model_selected")
|
hasattr(self, "_is_matrix_radar_model_selected")
|
||||||
and self._is_multi_device_model_selected()
|
and self._is_matrix_radar_model_selected()
|
||||||
and hasattr(self, "_single_combo_input")
|
and hasattr(self, "_single_combo_input")
|
||||||
and hasattr(self, "_single_combo_output")
|
and hasattr(self, "_single_combo_output")
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -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}")
|
||||||
@@ -31,9 +31,12 @@ class SingleRadarService(Protocol):
|
|||||||
|
|
||||||
|
|
||||||
def create_single_radar_service(config: RunConfigModel) -> SingleRadarService:
|
def create_single_radar_service(config: RunConfigModel) -> SingleRadarService:
|
||||||
"""Create the Python service for a non-multi-device radar config."""
|
"""Create the Python service for a non-matrix-radar config."""
|
||||||
if config.is_multi_device:
|
if config.is_matrix_radar:
|
||||||
raise RuntimeError("single-radar service factory does not support librevna_multi")
|
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
|
model = config.radar.model or RunConfigModel.LIBREVNA_MODEL
|
||||||
if model == RunConfigModel.LIBREVNA_MODEL:
|
if model == RunConfigModel.LIBREVNA_MODEL:
|
||||||
|
|||||||
@@ -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="<f4")
|
||||||
|
if array.size != expected_values:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"SN9000 response for {context!r} returned {array.size} float32 values, "
|
||||||
|
f"expected {expected_values}"
|
||||||
|
)
|
||||||
|
return array
|
||||||
|
|
||||||
|
def _read_response_bytes(self, count: int) -> 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)
|
||||||
@@ -270,6 +270,7 @@ class RunConfigModel:
|
|||||||
LIBREVNA_MULTI_MODEL = "librevna_multi"
|
LIBREVNA_MULTI_MODEL = "librevna_multi"
|
||||||
COMPACT_M_K209_MODEL = "compact_m_k209"
|
COMPACT_M_K209_MODEL = "compact_m_k209"
|
||||||
KAMIL_ADC_MODEL = "kamil_adc"
|
KAMIL_ADC_MODEL = "kamil_adc"
|
||||||
|
SN9000_MODEL = "sn9000"
|
||||||
MULTI_DEVICE_INPUT_POSITIONS = 4
|
MULTI_DEVICE_INPUT_POSITIONS = 4
|
||||||
MULTI_DEVICE_OUTPUT_POSITIONS = 2
|
MULTI_DEVICE_OUTPUT_POSITIONS = 2
|
||||||
|
|
||||||
@@ -290,11 +291,26 @@ class RunConfigModel:
|
|||||||
cls.MULTI_DEVICE_OUTPUT_POSITIONS,
|
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
|
@property
|
||||||
def is_multi_device(self) -> bool:
|
def is_multi_device(self) -> bool:
|
||||||
"""Return whether this config targets synchronized multi-device acquisition."""
|
"""Return whether this config targets synchronized multi-device acquisition."""
|
||||||
return self.radar.model == self.LIBREVNA_MULTI_MODEL
|
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
|
@property
|
||||||
def is_kamil_adc(self) -> bool:
|
def is_kamil_adc(self) -> bool:
|
||||||
"""Return whether this config targets the external Kamil ADC acquisition path."""
|
"""Return whether this config targets the external Kamil ADC acquisition path."""
|
||||||
@@ -348,10 +364,13 @@ class RunConfigModel:
|
|||||||
|
|
||||||
def apply_device_model_constraints(self) -> None:
|
def apply_device_model_constraints(self) -> None:
|
||||||
"""Apply only required wire-format constraints for the selected device model."""
|
"""Apply only required wire-format constraints for the selected device model."""
|
||||||
if not self.is_multi_device:
|
if not self.is_matrix_radar:
|
||||||
return
|
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.name = self.output_switch.name or "virtual_output"
|
||||||
self.output_switch.driver_mode = "mock"
|
self.output_switch.driver_mode = "mock"
|
||||||
self.output_switch.driver = self.output_switch.driver or "h7992"
|
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.radar_port = 2
|
||||||
self.input_switch.positions = self.MULTI_DEVICE_INPUT_POSITIONS
|
self.input_switch.positions = self.MULTI_DEVICE_INPUT_POSITIONS
|
||||||
self.input_switch.default_position = 0
|
self.input_switch.default_position = 0
|
||||||
self.combos = self.build_multi_device_virtual_combos()
|
|
||||||
|
|
||||||
def ensure_combos(self) -> None:
|
def ensure_combos(self) -> None:
|
||||||
"""Populate combos with full matrix when no explicit run combos are set."""
|
"""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()
|
self.apply_device_model_constraints()
|
||||||
return
|
return
|
||||||
if self.combos:
|
if self.combos:
|
||||||
|
|||||||
@@ -170,11 +170,11 @@ class ProcessSupervisor:
|
|||||||
def _acquisition_command(self, config_path: Path) -> list[str]:
|
def _acquisition_command(self, config_path: Path) -> list[str]:
|
||||||
"""Return acquisition producer command selected by radar.model."""
|
"""Return acquisition producer command selected by radar.model."""
|
||||||
radar_model = self._read_radar_model(config_path)
|
radar_model = self._read_radar_model(config_path)
|
||||||
if radar_model == "librevna_multi":
|
if radar_model in {"librevna_multi", "sn9000"}:
|
||||||
return [
|
return [
|
||||||
sys.executable,
|
sys.executable,
|
||||||
"-m",
|
"-m",
|
||||||
"python_app.scripts.multi_device_raw_producer",
|
"python_app.scripts.matrix_raw_producer",
|
||||||
"--config",
|
"--config",
|
||||||
str(config_path),
|
str(config_path),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -8,7 +8,7 @@ import time
|
|||||||
|
|
||||||
import numpy as np
|
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.single_radar_service import create_single_radar_service
|
||||||
from python_app.hardware_full.switch_service import SwitchService
|
from python_app.hardware_full.switch_service import SwitchService
|
||||||
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
|
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
|
||||||
@@ -74,11 +74,12 @@ class MultiRadarSequentialCaptureSession:
|
|||||||
self._set_name = set_name
|
self._set_name = set_name
|
||||||
self._radar_variants = list(radar_variants)
|
self._radar_variants = list(radar_variants)
|
||||||
self._median_sweep_count = int(median_sweep_count)
|
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._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._manual_multi_device_capture = self._is_multi_device and kind in MULTI_DEVICE_MANUAL_CAPTURE_KINDS
|
||||||
self._combos = (
|
self._combos = (
|
||||||
RunConfigModel.build_multi_device_virtual_combos()
|
RunConfigModel.build_matrix_radar_virtual_combos()
|
||||||
if self._is_multi_device
|
if self._is_matrix_radar
|
||||||
else RunConfigModel.build_full_combos(
|
else RunConfigModel.build_full_combos(
|
||||||
base_config.input_switch.positions,
|
base_config.input_switch.positions,
|
||||||
base_config.output_switch.positions,
|
base_config.output_switch.positions,
|
||||||
@@ -95,14 +96,8 @@ class MultiRadarSequentialCaptureSession:
|
|||||||
self._next_index = 0
|
self._next_index = 0
|
||||||
self._opened = False
|
self._opened = False
|
||||||
|
|
||||||
if self._is_multi_device:
|
if self._is_matrix_radar:
|
||||||
self._radar = MultiDeviceLibreVnaService(
|
self._radar: MatrixRadarService = create_matrix_radar_service(base_config)
|
||||||
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,
|
|
||||||
)
|
|
||||||
self._input_switch = None
|
self._input_switch = None
|
||||||
self._output_switch = None
|
self._output_switch = None
|
||||||
else:
|
else:
|
||||||
@@ -174,7 +169,7 @@ class MultiRadarSequentialCaptureSession:
|
|||||||
set_name=self._set_name,
|
set_name=self._set_name,
|
||||||
captured_count=(
|
captured_count=(
|
||||||
self._next_index
|
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)
|
else len(self._captured_batches)
|
||||||
),
|
),
|
||||||
total_count=len(self._combos),
|
total_count=len(self._combos),
|
||||||
@@ -197,7 +192,7 @@ class MultiRadarSequentialCaptureSession:
|
|||||||
display_traces: list[TraceData] = []
|
display_traces: list[TraceData] = []
|
||||||
variant_labels: list[str] = []
|
variant_labels: list[str] = []
|
||||||
|
|
||||||
if self._is_multi_device:
|
if self._is_matrix_radar:
|
||||||
for variant in self._radar_variants:
|
for variant in self._radar_variants:
|
||||||
self._radar.configure(variant.config.radar.sweep)
|
self._radar.configure(variant.config.radar.sweep)
|
||||||
if self._base_config.runtime.settling_ms > 0:
|
if self._base_config.runtime.settling_ms > 0:
|
||||||
@@ -207,7 +202,7 @@ class MultiRadarSequentialCaptureSession:
|
|||||||
collection = self._radar.acquire_collection(collection_id=1)
|
collection = self._radar.acquire_collection(collection_id=1)
|
||||||
if not collection.traces:
|
if not collection.traces:
|
||||||
raise RuntimeError(
|
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)
|
collections.append(collection)
|
||||||
if self._manual_multi_device_capture:
|
if self._manual_multi_device_capture:
|
||||||
@@ -256,7 +251,7 @@ class MultiRadarSequentialCaptureSession:
|
|||||||
variant_labels=tuple(variant_labels),
|
variant_labels=tuple(variant_labels),
|
||||||
)
|
)
|
||||||
self._captured_batches.append(batch)
|
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)
|
self._next_index = len(self._combos)
|
||||||
else:
|
else:
|
||||||
self._next_index += 1
|
self._next_index += 1
|
||||||
@@ -269,12 +264,12 @@ class MultiRadarSequentialCaptureSession:
|
|||||||
if not self._captured_batches or self._next_index <= 0:
|
if not self._captured_batches or self._next_index <= 0:
|
||||||
raise RuntimeError("No captured combo is available to undo")
|
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]
|
removed_batch = self._captured_batches[-1]
|
||||||
for variant in self._radar_variants:
|
for variant in self._radar_variants:
|
||||||
traces = self._traces_by_radar_key[variant.radar_key]
|
traces = self._traces_by_radar_key[variant.radar_key]
|
||||||
if len(traces) < len(self._combos):
|
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) :]
|
del traces[-len(self._combos) :]
|
||||||
self._next_index = 0
|
self._next_index = 0
|
||||||
self._captured_batches.pop()
|
self._captured_batches.pop()
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import time
|
|||||||
|
|
||||||
import numpy as np
|
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.single_radar_service import create_single_radar_service
|
||||||
from python_app.hardware_full.switch_service import SwitchService
|
from python_app.hardware_full.switch_service import SwitchService
|
||||||
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
|
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
|
||||||
@@ -57,11 +57,12 @@ class SequentialCaptureSession:
|
|||||||
self._kind = kind
|
self._kind = kind
|
||||||
self._set_name = set_name
|
self._set_name = set_name
|
||||||
self._median_sweep_count = int(median_sweep_count)
|
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._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._manual_multi_device_capture = self._is_multi_device and kind in MULTI_DEVICE_MANUAL_CAPTURE_KINDS
|
||||||
self._combos = (
|
self._combos = (
|
||||||
RunConfigModel.build_multi_device_virtual_combos()
|
RunConfigModel.build_matrix_radar_virtual_combos()
|
||||||
if self._is_multi_device
|
if self._is_matrix_radar
|
||||||
else RunConfigModel.build_full_combos(config.input_switch.positions, config.output_switch.positions)
|
else RunConfigModel.build_full_combos(config.input_switch.positions, config.output_switch.positions)
|
||||||
)
|
)
|
||||||
if not self._combos:
|
if not self._combos:
|
||||||
@@ -71,14 +72,8 @@ class SequentialCaptureSession:
|
|||||||
self._next_index = 0
|
self._next_index = 0
|
||||||
self._opened = False
|
self._opened = False
|
||||||
|
|
||||||
if self._is_multi_device:
|
if self._is_matrix_radar:
|
||||||
self._radar = MultiDeviceLibreVnaService(
|
self._radar: MatrixRadarService = create_matrix_radar_service(config)
|
||||||
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,
|
|
||||||
)
|
|
||||||
self._input_switch = None
|
self._input_switch = None
|
||||||
self._output_switch = None
|
self._output_switch = None
|
||||||
else:
|
else:
|
||||||
@@ -164,12 +159,12 @@ class SequentialCaptureSession:
|
|||||||
if combo is None:
|
if combo is None:
|
||||||
raise RuntimeError("Capture session is already complete")
|
raise RuntimeError("Capture session is already complete")
|
||||||
|
|
||||||
if self._is_multi_device:
|
if self._is_matrix_radar:
|
||||||
collections: list[SweepCollection] = []
|
collections: list[SweepCollection] = []
|
||||||
for _ in range(self._median_sweep_count):
|
for _ in range(self._median_sweep_count):
|
||||||
collection = self._radar.acquire_collection(collection_id=1)
|
collection = self._radar.acquire_collection(collection_id=1)
|
||||||
if not collection.traces:
|
if not collection.traces:
|
||||||
raise RuntimeError("Multi-device capture returned no traces")
|
raise RuntimeError("Matrix radar capture returned no traces")
|
||||||
collections.append(collection)
|
collections.append(collection)
|
||||||
if self._manual_multi_device_capture:
|
if self._manual_multi_device_capture:
|
||||||
per_sweep_traces = [select_trace_for_combo(collection, combo) for collection in collections]
|
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:
|
if not self._traces or self._next_index <= 0:
|
||||||
raise RuntimeError("No captured combo is available to undo")
|
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):
|
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]
|
removed_trace = self._traces[-1]
|
||||||
self._traces.clear()
|
self._traces.clear()
|
||||||
self._next_index = 0
|
self._next_index = 0
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user