web UI added and refactoring done

This commit is contained in:
Ayzen
2026-06-06 00:06:30 +03:00
parent 3c30a12d4a
commit af6005d68f
65 changed files with 3630 additions and 4720 deletions
-308
View File
@@ -1,308 +0,0 @@
# Compact-M K209 / S2VNA Setup
This project controls the Compact-M K209 through the S2VNA SCPI server.
The production path is:
```text
K209 --USB-C--> S2VNA --HiSLIP/VISA--> radar_system
```
For complete run-mode instructions, including what runs on the x86_64 S2VNA
computer and what runs on Raspberry Pi, see
[`docs/operation_modes.md`](operation_modes.md). For `run_config.json` fields,
see [`docs/run_config.md`](run_config.md).
There is no direct USB driver for K209 in this project. Do not use mock
transports, socket fallbacks, or `pyvisa-py` for the K209 path. The required
transport dependency is an IVI/Vendor VISA implementation that provides both
`visa.h` and `libvisa.so`.
For maximum throughput the driver uses:
- HiSLIP, not TCP Socket.
- A persistent VISA session.
- Binary `FORM:DATA REAL32`.
- Little-endian `FORM:BORD SWAP`.
- One-time sweep configuration outside the acquisition loop.
- Cached frequency axis after configuration, so repeated acquisition reads only
the complex traces.
- Point delay forced to `0` and sweep averaging forced `OFF`.
- The acquisition loop sends one synchronized SCPI message:
`TRIG:SING;*OPC?;:SENS:DATA:CORR? S11;:SENS:DATA:CORR? S21`.
This keeps the trigger state valid while avoiding separate round trips for
`*OPC?`, `S11`, and `S21`.
Do not remove the inline `*OPC?` from the hot path. On the tested K209/S2VNA
setup, `TRIG:SING` followed immediately by data queries can return data but
queues SCPI error `-211,"Trigger system is not in the trigger wait state"`.
## Required Components
Install these on the machine that runs the K209 smoke tests or acquisition
process:
1. S2VNA from Planar.
- On Linux the S2VNA manual describes the AppImage package
`S2VNA_X.X.X_x86_64.AppImage`.
- The K209 is connected to this S2VNA instance over USB-C.
2. IVI VISA runtime and development files.
- Must provide `visa.h`.
- Must provide `libvisa.so`.
- Must support TCPIP HiSLIP resources.
- Suitable implementations include NI-VISA or Keysight IO Libraries Suite.
3. Project Python environment.
- Use the repository virtual environment, not system Python.
- Install `requirements.txt` into `.venv`.
4. Native build dependencies.
- C++20 compiler.
- `make`.
- `libusb-1.0` development files for the existing LibreVNA build.
## Ubuntu x86_64
Install base packages:
```bash
sudo apt update
sudo apt install build-essential make pkg-config python3-venv python3-pip libusb-1.0-0-dev
```
Create or update the project virtual environment:
```bash
cd /home/europa/Documents/radar_system
python3 -m venv .venv
.venv/bin/python -m pip install --upgrade pip
.venv/bin/python -m pip install -r requirements.txt
```
Install an IVI VISA implementation. For NI-VISA, install the NI Linux package
repository from NI, then install the `ni-visa` package with the system package
manager. For Keysight, install Keysight IO Libraries Suite for Linux with VISA
support enabled.
Do not use Ubuntu's `libvisa-dev` / `libvisa0` packages for the K209 production
path. Those packages come from `librevisa` and are not the vendor IVI VISA stack
used for high-speed HiSLIP operation.
After installation, verify that the system exposes the required C and runtime
files:
```bash
ldconfig -p | grep libvisa
find /usr /opt -name visa.h -o -name libvisa.so 2>/dev/null
.venv/bin/pyvisa-info
```
`pyvisa-info` must show the `ivi` backend with a found binary library.
If `visa.h` or `libvisa.so` is installed outside the default compiler/linker
paths, pass the paths explicitly:
```bash
make build/bin/k209_smoke_test \
VISA_CXXFLAGS='-I/path/to/visa/include' \
VISA_LDFLAGS='-pthread -lrt -L/path/to/visa/lib -lvisa'
```
## S2VNA HiSLIP Server
Start S2VNA with the K209 connected over USB-C. Enable HiSLIP server on port
`4880`. This can be done from the S2VNA UI:
```text
System -> Settings -> Remote control network settings -> HiSLIP server -> On
System -> Settings -> Remote control network settings -> HiSLIP port -> 4880
```
The S2VNA command line can also enable the server:
```bash
./S2VNA_X.X.X_x86_64.AppImage /HislipServer:on /HislipPort:4880
```
For unattended runs, S2VNA also supports hiding the UI:
```bash
./S2VNA_X.X.X_x86_64.AppImage /HislipServer:on /HislipPort:4880 /visible:off
```
Verify that the server is listening:
```bash
ss -ltnp | grep 4880
```
The local VISA resource is:
```text
TCPIP0::127.0.0.1::hislip0,4880::INSTR
```
If S2VNA runs on another machine, replace `127.0.0.1` with that machine's IP
address.
## Remote Raspberry Pi Mode
For Raspberry Pi runs, keep S2VNA and NI-VISA on the x86_64 computer connected
to the K209, and run only the project pipeline/GPIO on the Raspberry Pi.
On the x86_64 computer with S2VNA running:
```bash
cd /path/to/radar_system
.venv/bin/python -m python_app.scripts.k209_remote_server --host 0.0.0.0 --port 50209
```
On the Raspberry Pi, set the K209 config to the server address:
```json
"radar": {
"model": "compact_m_k209",
"remote_host": "192.168.1.10",
"remote_port": 50209,
"driver_mode": "native"
}
```
The Raspberry Pi does not need S2VNA or NI-VISA for this mode.
## Python Smoke Test
Use the project virtual environment:
```bash
cd /home/europa/Documents/radar_system
.venv/bin/python -m python_app.scripts.k209_smoke_test \
--resource 'TCPIP0::127.0.0.1::hislip0,4880::INSTR' \
--visa-library '@ivi' \
--start-hz 10000000 \
--stop-hz 100000000 \
--points 11 \
--ifbw-hz 10000 \
--power-dbm -20 \
--no-preset
```
Expected result:
```text
K209 IDN: Planar, K209, ...
K209 sweep OK: points=11, first_hz=10000000.000, last_hz=100000000.000, ...
```
Use `--no-preset` for the first smoke test to avoid resetting the current S2VNA
session. Remove it when testing the full driver setup path.
## C++ Smoke Test
Build the C++ K209 smoke binary:
```bash
cd /home/europa/Documents/radar_system
make build/bin/k209_smoke_test
```
Run it against the local S2VNA HiSLIP server:
```bash
build/bin/k209_smoke_test \
--resource 'TCPIP0::127.0.0.1::hislip0,4880::INSTR' \
--start-hz 10000000 \
--stop-hz 100000000 \
--points 11 \
--ifbw-hz 10000 \
--power-dbm -20 \
--no-preset
```
If the build fails with `fatal error: visa.h: No such file or directory`, the
IVI VISA development headers are not installed or are not visible to the
compiler. If linking fails with `cannot find -lvisa`, the IVI VISA runtime or
linker path is not installed correctly.
## Python Sweep Benchmark
Use this script to measure hot-loop sweep throughput for a selected sweep
configuration. It keeps one VISA session open, configures the sweep once, caches
the frequency axis once, then times repeated synchronized trigger/read cycles
for binary `S11` and `S21` arrays.
Edit the configuration constants at the top of
`python_app/scripts/k209_sweep_benchmark.py`, then run:
```bash
cd /home/europa/Documents/radar_system
.venv/bin/python -m python_app.scripts.k209_sweep_benchmark
```
The reported `points_per_s` is:
```text
timed_sweeps * points / total_timed_seconds
```
Set `INCLUDE_RESULT_CONVERSION = True` only when you want to include Python
`SweepResult` construction overhead. Keep it `False` when measuring the
device/transport hot path.
## K209 Limits
The connected K209 reports these limits through SCPI service/capability
queries:
```text
frequency_hz: 9000 .. 9000000000
ifbw_hz: 1 .. 300000
power_dbm: -55 .. +5
points: 2 .. 500001
```
The S2VNA manual specifies the IFBW range with 1/3-decade spacing.
`SENS:BAND` accepts values in the 1, 1.5, 2, 3, 5, 7 sequence across decades
and clamps out-of-range values to the nearest limit.
The manual describes metrology/dynamic-range frequency subranges such as
`9 kHz..300 kHz`, `300 kHz..2 MHz`, and `2 MHz..9 GHz`, but it does not expose a
SCPI command for manually selecting an internal RF band. S2VNA handles internal
range switching. Benchmark the exact frequency window used by the application
when sweep speed matters.
The S2VNA manual also describes a SCPI FIFO buffer mode for very high-rate
external-trigger sequences. It is not the right default for this driver stage:
the manual limits it to specific analyzer families, external/repeated trigger
workflows, one open channel, disabled display updates, and at most 3000 points
per sweep. The current K209 path therefore uses synchronized HiSLIP binary
sweeps instead of FIFO.
## Raspberry Pi OS
Raspberry Pi 5 uses ARM64/AArch64 when running 64-bit Raspberry Pi OS. The S2VNA
Linux package described in the S2VNA manual is `x86_64`, and common vendor VISA
packages are also primarily published for x86_64 Linux.
Do not assume local K209 acquisition on Raspberry Pi works until both of these
are available for ARM64:
1. S2VNA build that can run on Raspberry Pi OS ARM64 and control the K209 over
USB-C.
2. IVI VISA implementation for ARM64 that provides `visa.h`, `libvisa.so`, and
TCPIP HiSLIP support.
If those ARM64 dependencies are not available, run S2VNA and the acquisition
server on an Ubuntu x86_64 machine and use the remote K209 mode documented
above. In that mode, Raspberry Pi runs the project pipeline and GPIO switch
drivers, while the x86_64 machine runs S2VNA and the K209 remote server.
## Expected Hardware Test Result
With S2VNA listening on `4880` and IVI/Vendor VISA installed correctly, the
Python and C++ smoke tests should report:
```text
K209 IDN: Planar, K209, ...
K209 sweep OK: points=11, first_hz=10000000.000, last_hz=100000000.000
```
-295
View File
@@ -1,295 +0,0 @@
# Operation Modes
The active radar backend is selected manually in JSON by `radar.model`.
The GUI does not expose a model selector.
Available models:
```text
librevna
librevna_multi
compact_m_k209
sn9000
kamil_adc
```
Example configs in the repository root:
```text
run_config_librevna.example.json
run_config_librevna_multi.example.json
run_config_compact_m_k209.example.json
run_config_compact_m_k209_local_mock_switches.example.json
run_config_kamil_adc.example.json
run_config_simulator.example.json
```
## Common Commands
Build native binaries:
```bash
cd /path/to/radar_system
make
```
Run the GUI:
```bash
.venv/bin/python -m python_app.gui.main
```
Run a single acquisition producer manually:
```bash
build/bin/sweep_orchestrator --config run_config.json
```
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.matrix_raw_producer`
- `sn9000` -> `python_app.scripts.matrix_raw_producer`
- `kamil_adc` -> `python_app.scripts.kamil_adc_raw_producer`
## Pure Simulator
Use `run_config_simulator.example.json` to run the full GUI pipeline without
radar hardware or GPIO. It uses the single-LibreVNA mock producer, mock
switches, and the synthetic `smoke_cal` / `smoke_ref` preprocessing sets stored
under `python_app/data`.
Typical local check:
```bash
cd /path/to/radar_system
make
.venv/bin/python -m python_app.gui.main
```
Then load `run_config_simulator.example.json` in the GUI and press Start.
## Single LibreVNA
Use this mode when one LibreVNA is connected directly over USB to the machine
running the project.
Config:
```json
"radar": {
"model": "librevna",
"serial": "",
"driver_mode": "native"
}
```
Notes:
- Empty `serial` means use the first compatible LibreVNA found.
- Set `serial` when multiple LibreVNAs are connected.
- `driver_mode: "native"` uses the direct USB LibreVNA driver.
- `driver_mode: "mock"` generates synthetic radar data for UI/development.
- Switch GPIO is controlled by the same machine unless switch `driver_mode` is
set to `mock`.
Typical local check without GPIO:
```bash
cp run_config_librevna.example.json /tmp/librevna_mock_switches.json
# edit both switches to driver_mode="mock" if needed
build/bin/sweep_orchestrator --config /tmp/librevna_mock_switches.json
```
## LibreVNA Multi-Device
Use this mode for one master LibreVNA and two slave LibreVNAs. This mode does
not use physical RF switch GPIO in the acquisition producer. It exposes a fixed
virtual matrix:
```text
inputs: 0..3
outputs: 0..1
combos: 8
```
Config:
```json
"radar": {
"model": "librevna_multi",
"serial": "MASTER_SERIAL",
"driver_mode": "native",
"multi_device": {
"slave_serials": [
"SLAVE_SERIAL_1",
"SLAVE_SERIAL_2"
],
"force_external_reference": true,
"recovery_attempts": 3
}
}
```
Notes:
- Exactly two slave serials are required.
- `force_external_reference` configures the synchronized reference workflow.
- `recovery_attempts` controls reopen/retry attempts after native acquisition
errors.
- The Python producer is selected automatically by the GUI. Manual raw-producer
run:
```bash
.venv/bin/python -m python_app.scripts.matrix_raw_producer \
--config run_config_librevna_multi.example.json
```
## Compact-M K209 On The Same Computer
Use this for local development on the x86_64 computer that runs S2VNA and has
the K209 connected over USB-C. GPIO can be disabled with mock switches.
1. Start S2VNA and enable HiSLIP on port `4880`.
2. Start the local project K209 server:
```bash
.venv/bin/python -m python_app.scripts.k209_remote_server \
--host 127.0.0.1 \
--port 50209
```
3. In another terminal, smoke-test the server:
```bash
.venv/bin/python -m python_app.scripts.k209_remote_smoke_test \
--host 127.0.0.1 \
--port 50209
```
4. Run one acquisition with mock switches:
```bash
build/bin/sweep_orchestrator \
--config run_config_compact_m_k209_local_mock_switches.example.json
```
This mode is useful on a laptop because it avoids GPIO dependencies.
## Compact-M K209 With Raspberry Pi GPIO
Use this for the real K209 + Raspberry Pi setup:
```text
K209 --USB-C--> x86_64 computer running S2VNA
x86_64 computer --Ethernet--> Raspberry Pi 5
Raspberry Pi 5 --GPIO--> RF switches
```
On the x86_64 computer:
```bash
cd /path/to/radar_system
.venv/bin/python -m python_app.scripts.k209_remote_server \
--host 0.0.0.0 \
--port 50209
```
On the Raspberry Pi, set `radar.remote_host` to the Ethernet IP address of the
x86_64 computer:
```json
"radar": {
"model": "compact_m_k209",
"remote_host": "192.168.1.10",
"remote_port": 50209,
"driver_mode": "native"
}
```
Then run the GUI or producer on the Raspberry Pi:
```bash
.venv/bin/python -m python_app.gui.main
```
For a command-line connection check from the Raspberry Pi:
```bash
.venv/bin/python -m python_app.scripts.k209_remote_smoke_test \
--host 192.168.1.10 \
--port 50209
```
The Raspberry Pi does not need S2VNA or NI-VISA in this remote mode.
## Kamil ADC With Laser Control
Use this mode for the ADC collector from `/home/europa/Documents/kamil_adc`
and the laser board configured through `laser_control`. The external
`kamil_adc` project is not modified by `radar_system`; the producer launches
the configured executable and reads its TTY stream.
Config:
```json
"radar": {
"model": "kamil_adc",
"serial": "kamil_adc",
"driver_mode": "native",
"kamil_adc": {
"project_dir": "/home/europa/Documents/kamil_adc",
"executable_path": "/home/europa/Documents/kamil_adc/kamil_adc_capture",
"tty_path": "/tmp/ttyADC_data",
"args": [
"profile:phase",
"clock:internal",
"internal_ref_hz:2000000",
"mode:diff",
"channels:2",
"ch1:2",
"ch2:3",
"do1_toggle_per_frame",
"do1_pair_subtract_avg"
]
},
"laser_control": {
"enabled": true,
"port": "/dev/ttyUSB0",
"mode": "variation"
}
}
```
Notes:
- `executable_path` is mandatory and must name the real Raspberry Pi binary.
- The producer appends `tty:<tty_path>` automatically; do not put `tty:` in
`radar.kamil_adc.args`.
- The producer derives the sweep point count from the Kamil ADC TTY stream.
`radar.sweep.start_hz` and `stop_hz` define the synthetic frequency axis;
`radar.sweep.points` is not a Kamil ADC setting.
- The laser-control driver is vendored under `python_app.hardware_full.laser_control`.
- `laser_control` and `kamil_adc` are treated as one hardware configuration.
Changing either section requires restarting acquisition so the lasers are
configured before the ADC collector starts.
- The TTY frame `0x000A step data1 data2` is imported as `S21 = data1 + j*data2`.
`S11` is filled with explicit zeros.
Manual raw-producer run:
```bash
.venv/bin/python -m python_app.scripts.kamil_adc_raw_producer \
--config run_config_kamil_adc.example.json
```
## K209 Remote Performance
The remote K209 path keeps one persistent TCP connection open. Configuration
sends sweep settings once and receives the frequency axis once. Each sweep then
sends one command byte and receives only binary `S11` and `S21` `float32`
arrays.
Use wired Ethernet. Wi-Fi works for tests but adds jitter.
-356
View File
@@ -1,356 +0,0 @@
# radar_system reliability audit
Source: multi-agent audit (98 agents). 86 findings, 72 confirmed, 57 after dedup.
## Top risks
- Headless appliance has NO self-healing: a crashed/exited C++ child (sweep_orchestrator, data_preprocessor, data_processor) is logged once and dropped, never respawned, and because the Python GUI parent stays alive systemd Restart=on-failure never fires. Combined with headless start-failures being swallowed (exit code stays 0), the box goes dark and stays dark until a human power-cycles. This is the single most important gap to close (PS-001 / RS-02 / RAD-002).
- Multiple consumer run-loops crash the whole daemon on ONE bad/edge-case collection: data_preprocessor and data_processor have no per-iteration try/catch, and pop()/deserialize throw on torn or oversized payloads (incl. an unbounded reserve() OOM from a wire-supplied u32 count). One malformed frame permanently stops all downstream processing. Wrap per-collection bodies in try/catch + drop, make pop() resync instead of throw, and bounds-check declared counts.
- The lock-free SHM ring has real data-corruption races: producer publishes the new sequence BEFORE copying the payload and there is no post-copy re-validation (C++ pop and the Python ShmRingReader), so a lapping producer hands consumers torn payloads; full-ring overflow also silently overwrites unread sweeps and still returns success. Add a seqlock-style publish/verify protocol and surface drops.
- Device-absent-at-boot is mishandled per-path: the C++ sweep_orchestrator and the kamil_adc producer exit on first open failure with no wait-for-device retry, while backend_mode='auto' silently and PERMANENTLY latches to synthetic mock data — so the appliance records fake radar instead of waiting. Make all producers wait-for-device with bounded backoff and forbid auto->mock latching in hardware deployments.
- Disk/fd/thread exhaustion over days of uptime: child stdout/stderr logs grow with no rotation (can fill the SD card and corrupt everything), partial-open in MultiDeviceVnaController leaks a libusb context + RX thread on every forever-retry, and locator client sessions are only reaped on new accept() so flapping clients leak fds+threads. Cap/rotate logs, free partial opens, and reap sessions from publish().
- Stale /dev/shm and orphaned children defeat restart: no production code ever unlinks rings, so a geometry change wedges the pipeline in a boot loop (C++ throws 'geometry mismatch') while Python silently truncates and diverges; a kill -9'd GUI orphans C++ children that keep holding the radar/port/rings and the next start spawns a conflicting second set. Clean rings on headless start and reap pre-existing pipeline processes via pidfile/process-group.
## Issues (ranked)
### #1 [HIGH] Crashed C++ pipeline child is never restarted and the failure is invisible; systemd Restart never fires because the GUI parent stays alive
- **subsystem:** py_orchestration / cross_cutting
- **location:** python_app/orchestration/process_supervisor.py:289-317; python_app/gui/controllers/app_window_pipeline_mixin.py:289
- **impact:** collect_exit_reports() is the only place a child death is observed; on exit it logs one ERROR, sets status='error', and pops the handle from self._processes. There is no watchdog and no respawn anywhere. If sweep_orchestrator/data_preprocessor/data_processor crashes (device hiccup, OOM, segfault) acquisition/processing never resume. systemd Restart=on-failure is on the parent only, and the parent stays healthy, so it never fires. On a headless Pi the appliance silently produces no data until the next reboot. Merges PS-001 and the cross_cutting child-crash finding.
- **fix:** In headless mode treat an unexpected child exit as recoverable: from _poll_rings respawn the crashed stage with bounded retry/backoff (track restart counts per name); after exhausting retries either expose a hard 'pipeline degraded' state or os._exit(non-zero) after logging to stderr so systemd Restart=on-failure performs a clean full recovery. Add a heartbeat that detects 'expected running but all acquisition children dead'.
### #2 [CRITICAL] Headless auto-start failures are swallowed (exit code stays 0), leaving an idle zombie daemon that never auto-restarts
- **subsystem:** deploy_daemon
- **location:** python_app/gui/app_window.py:503 (and :490); python_app/gui/controllers/app_window_pipeline_mixin.py:151-155; app_window.py:289-292
- **impact:** In headless mode the auto-start chain catches Exception and calls _show_exception/_show_error, both of which return immediately when RADAR_SYSTEM_HEADLESS=1. The Qt loop keeps running and the process exit code stays 0. So a missing device at boot, a busy SHM ring, or a producer that fails to spawn leaves the daemon alive doing nothing; systemd sees a healthy Type=simple process and Restart=on-failure NEVER fires. The appliance silently produces no data with no self-healing.
- **fix:** In headless mode propagate fatal auto-start/apply failures into a non-zero process exit (QApplication.exit(1) / os._exit(1) after logging to stderr) so systemd restarts the unit. Add a watchdog: if the pipeline is not producing data within N seconds of headless auto-start, exit non-zero so Restart=on-failure self-heals.
### #3 [CRITICAL] Per-collection exception in the preprocessor run loop crashes the daemon with no auto-respawn
- **subsystem:** cpp_preprocess
- **location:** data_acq_and_processing/preprocessing/data_preprocessor/src/data_preprocessor.cpp:136-149
- **impact:** run() calls try_pop_raw_collection -> deserialize_raw_collection, preprocess_collection, publish_preprocessed_collection with no try/catch around the per-collection body. Any of these throws on normal-but-imperfect input (torn/stale ring slot magic/length errors, S21/S11 axis or point-count mismatch, slot-too-small, bad_alloc). The throw unwinds to main()'s catch (main.cpp:90) -> exit 1, and the supervisor does not respawn it (see rank 1). One malformed or transiently-mismatched collection permanently stops all preprocessing and every downstream result.
- **fix:** Wrap the per-iteration body (pop+deserialize, preprocess, publish) in try/catch inside run(); on std::exception log (with collection_id when available) and continue, treating one bad collection as a recoverable drop. Keep only truly unrecoverable conditions (ring not open) fatal. Optionally add a consecutive-failure counter that exits only past a threshold.
### #4 [MEDIUM] Unbounded reserve() on a wire-supplied u32 count during raw deserialize causes OOM crash
- **subsystem:** cpp_preprocess
- **location:** data_acq_and_processing/common_cpp/ipc/src/shared_types.cpp:136-138 (and :186)
- **impact:** read_trace_collection/read_trace_block do reserve(trace_count) and reserve(point_count) using raw uint32 counts read straight from the wire BEFORE any bytes-available check. A torn/corrupt ring slot or buggy producer can present a count near 2^32; reserve(4e9) of vector<Complex32> requests ~32GB and throws length_error/bad_alloc or trips the OOM killer on a 1-4GB Pi, killing the daemon (compounds rank 3). The payload_size<=slot_size_bytes check bounds the buffer but not the declared element count.
- **fix:** Before reserving, cap counts against reader.remaining_bytes(): require point_count*bytes_per_point (>=12B/point: 4 freq + 8 complex) <= remaining_bytes() and trace_count <= remaining_bytes()/min_trace_bytes; throw a descriptive runtime_error if exceeded (then caught by rank-3's loop guard) rather than reserving blindly.
### #5 [HIGH] push() torn-write race: reader copies a slot the producer is mid-overwriting; no post-copy sequence re-check (C++ pop and Python reader)
- **subsystem:** ipc_shm / py_orchestration
- **location:** data_acq_and_processing/common_cpp/ipc/src/shm_ring.cpp:303 (pop check l.325); python_app/orchestration/shm/ring_reader.py:46-65
- **impact:** push() writes payload_size, sets slot->sequence = write_seq+1, THEN memcpy's the payload, and only afterward publishes write_seq. A reader sitting on the same physical slot index can observe the NEW sequence (passing the sequence==read_seq+1 check) yet copy a mix of old+new payload bytes. The single fence between memcpy and write_seq.store does not protect a reader already inside the slot, and neither the C++ pop nor the Python ShmRingReader re-validates the sequence after copying. On a Pi where the C++ producer outruns the 50ms GUI poll, a lapping producer yields torn payloads that crash/garble deserialize/decode. Merges SHM-001 and the Python ring-reader race.
- **fix:** Seqlock-style publish: write payload+size FIRST, then publish slot->sequence with a release store; readers re-read the slot sequence AFTER copying (acquire) and discard+resync if it changed or if the writer advanced past read_seq+capacity. Equivalently use a per-slot odd/even generation counter. Also sanity-bound payload_size <= slot_size_bytes in the Python reader before slicing.
### #6 [HIGH] backend_mode='auto' silently and permanently latches to synthetic mock data when the device is absent
- **subsystem:** py_hardware
- **location:** python_app/hardware_full/multi_device_service.py:79-83 (open), :69/:109 (latch)
- **impact:** When MultiDeviceLibreVnaService.open() fails in 'auto' mode it sets _using_mock_backend=True and swallows the error; the matrix factory passes backend_mode=config.radar.driver_mode, so driver_mode='auto' makes _open_radar_with_retry succeed immediately with synthetic data and never enter the wait-for-device loop. The flag latches permanently (open/recover short-circuit), so even after the real VNA is plugged in the service emits fabricated S-parameters forever. A headless box that boots before the VNA is connected silently records/serves completely fake radar with no operator-visible error.
- **fix:** Do not let 'auto' fall back to mock for a hardware producer meant to wait for the device. Either require driver_mode in {native,mock} for matrix producers (reject auto), or on auto-fallback emit a loud throttled WARNING and re-attempt native on every open() without latching, or treat native open failure as retryable so _open_radar_with_retry keeps waiting.
### #7 [HIGH] Native LibreVNA sweep uses a single 1500ms deadline for the entire multi-point sweep, guaranteeing timeouts and reconnect churn on large sweeps
- **subsystem:** cpp_acquisition
- **location:** data_acq_and_processing/sweep_orchestrator/device_drivers/radar/librevna/librevna_minimal_driver_lifecycle.cpp:285 (used at :290)
- **impact:** acquire_native computes deadline = now + 1500ms once before the receive loop and reuses it across all sweep.points. Real dwell is ~points/IFBW seconds (e.g. 1001 points @1kHz IFBW ~= 1s + USB latency); at low IFBW/high points the sweep exceeds 1500ms. Once the shared deadline passes mid-sweep, wait_for_packet throws 'Timeout' (retryable), so acquire_sweep tears down and reconnects (lifecycle.cpp:153-160), retries the same too-short window up to 3 times, then rethrows -> no collection published and process exits 1. Native acquisition is effectively broken for any sweep longer than 1.5s, manifesting as reconnect churn then a crash.
- **fix:** Derive the deadline from configured sweep size (base + points/IFBW + margin) or extend it as progress is made (advance on each new datapoint, with an overall hard cap and a per-gap stall timeout). Never share one fixed wall-clock budget across an unbounded number of points.
### #8 [HIGH] Transient device read/decode errors crash sweep_orchestrator with exit 1 and it is never restarted
- **subsystem:** cpp_acquisition
- **location:** data_acq_and_processing/sweep_orchestrator/src/sweep_orchestrator.cpp:127 (main catch main.cpp:165-168)
- **impact:** run() lets any exception propagate to main -> exit 1. Many recoverable-in-spirit faults are fatal and not in the retryable list: a single corrupted USB frame ('Invalid LibreVNA packet CRC', transport.cpp:333); a cable replug yielding a retryable bulk error followed by non-retryable 'No compatible LibreVNA USB device found' on reconnect (transport.cpp:122); a K209 socket timeout treated as a hard read failure. The supervisor never respawns sweep_orchestrator (see rank 1), so any transient device fault permanently stops acquisition on the headless box.
- **fix:** Add a bounded backoff-based supervised retry around the acquisition loop that, on recoverable device errors (timeouts, NACK, transient USB/socket, transient device-not-found after replug), closes/reopens drivers and continues in continuous mode rather than exiting. Reserve exit-1 for genuinely fatal/config errors; distinguish exit codes and/or enable supervisor respawn for the orchestrator.
### #9 [MEDIUM] C++ sweep_orchestrator open_all() has no wait-for-device retry; absent device at boot kills the daemon
- **subsystem:** py_hardware / cpp_acquisition
- **location:** data_acq_and_processing/sweep_orchestrator/src/sweep_orchestrator.cpp:111-114 (open path lifecycle.cpp:102-114)
- **impact:** run() calls lifecycle_guard.open_all() exactly once; LibreVnaMinimalDriver::open() calls open_native() once and throws on failure with no retry, unwinding to main -> exit 1. For the 'librevna' and 'compact_m_k209' C++ paths, a device absent at boot (the normal Pi cold-boot race) terminates acquisition immediately. The native acquire path has only a small bounded in-loop reconnect (no unbounded wait), so a device unplugged longer than kNativeAcquireMaxAttempts also exits. This is the same robustness asymmetry the Python matrix producer was fixed to avoid.
- **fix:** Add a wait-for-device retry around open_all() mirroring matrix_raw_producer._open_radar_with_retry: loop open_all() with capped exponential backoff while !should_stop(stop_requested), logging throttled failures, proceeding only once open succeeds. Extend native acquire reconnect to keep retrying (interruptible by stop_requested) in continuous mode instead of giving up after kNativeAcquireMaxAttempts.
### #10 [HIGH] kamil_adc producer has no open/reconnect retry and silently exits 0 on a partial sweep
- **subsystem:** py_hardware / cross_cutting
- **location:** python_app/scripts/kamil_adc_raw_producer.py:59-90 (acquire :79, partial-sweep break :89-90)
- **impact:** Unlike matrix_raw_producer, this opens radar/switches once with no retry; KamilAdcService.open() failure (collector not ready, TTY not created within startup_timeout_s, USB CDC-ACM not yet enumerated) propagates and the process exits. In the loop, radar.acquire() raising a TTY-closed/process-exited RuntimeError on a USB unplug or collector death is not caught, so one transient hiccup terminates the producer with no reconnect. Worse, len(traces)!=len(combos) breaks the loop and returns 0 even when stop was NOT requested, silently stopping continuous acquisition with no log. As a supervised child, systemd cannot restart it. Merges KAMIL-NO-WAIT and the cross_cutting kamil finding.
- **fix:** Mirror the matrix producer: wrap open()+acquire/switch in a reconnect-forever loop with capped backoff, interruptible by stop_requested (relaunch collector+reader on failure). Replace the unconditional break on incomplete traces with a check that exits only when stop_requested is set, otherwise reconnect+log.
### #11 [HIGH] MultiDeviceVnaController partial-open leaks the master USB handle + RX thread on every failed open, unbounded under retry-forever
- **subsystem:** py_hardware
- **location:** python_app/hardware_full/librevna_multi_device_driver/controller.py:48-57 (close :67-75)
- **impact:** self._all_devices is assigned only after ALL connection opens succeed. If the master opens but a slave is absent (common boot case: one of three USB VNAs not yet enumerated), the except calls close(), which iterates the still-empty _all_devices and frees nothing. The opened master connection (USBContext + claimed handle + running 'librevna-usb-rx' daemon thread) leaks. matrix_raw_producer retries FOREVER with 1-10s backoff, so every retry leaks one context+handle+thread (~6/min) until RLIMIT_NOFILE/pthread limits crash the producer, defeating the wait-forever design.
- **fix:** Track opened devices incrementally so close() can free a partial open: append each LibreVnaUsbBulkConnection to self._all_devices (and set _master_device) as it is constructed, or in the except explicitly close the master and any constructed slaves before re-raising. Verify with lsof/thread count that a repeated open-failure loop holds fd/thread count flat.
### #12 [MEDIUM] push() silently overwrites unread data on overflow yet returns success; drops are invisible to producers and consumers
- **subsystem:** ipc_shm
- **location:** data_acq_and_processing/common_cpp/ipc/src/shm_ring.cpp:298-310 (callers sweep_orchestrator.cpp:86, data_preprocessor.cpp:128, data_processor.cpp:29)
- **impact:** When the ring is full, push() advances read_seq, increments dropped, overwrites the oldest unread slot, and still returns true. Callers treat only false (slot-too-small) as an error, so whenever the consumer is slower than the producer (heavy processing or a stalled GUI tap) unread collections are silently discarded with no log and no backpressure. dropped_count() exists but is never read anywhere in the tree, so loss is invisible on the headless daemon and detection-critical sweeps can vanish.
- **fix:** Surface drops: periodically log dropped_count() deltas, or change push() to return {Queued, Overwrote, TooLarge} so callers WARN on Overwrote. For the primary raw->preprocessed->results path consider a blocking/backpressure push variant so detection data is never silently dropped.
### #13 [MEDIUM] Stale /dev/shm rings are reused on restart; geometry change wedges the pipeline (C++ throws) while Python silently truncates and diverges
- **subsystem:** cross_cutting / ipc_shm
- **location:** data_acq_and_processing/sweep_orchestrator/src/main.cpp:139; shm_ring.cpp:199; python_app/orchestration/shm/ring_writer.py:35-40; deploy/install-daemon.sh:54
- **impact:** No production code unlinks rings (unlink_ring/cleanup_known_shm only run under start.sh --clean-shm, which the unit's 'start.sh --headless --skip-build' does not pass). After a SIGKILL/OOM/crash the ring files persist with their last write_seq/read_seq and the restarted side ATTACHES. (1) If capacity/slot_size_bytes change between runs, the C++ side throws 'geometry mismatch' and the producer/processor dies every start -- an unrecoverable boot loop. (2) The Python writer instead silently truncates+reinitializes a size-mismatched stale ring, so a C++ reader mapped to the old size reads garbage. Stale seq counters also cause first-read mis-sequencing. Merges RS-03 and SHM-003.
- **fix:** Make restart self-healing: have the ring owner (orchestrator) unlink_ring() each ring name at startup before open_or_create, OR add --clean-shm to the unit ExecStart / a systemd ExecStartPre that clears radar_* shm objects. On geometry mismatch, unlink+recreate instead of throwing. Document a single owner per ring responsible for create+unlink, and align Python/C++ mismatch behavior.
### #14 [HIGH] Acceptor thread permanently exits on EMFILE/ENFILE; locator reports running but never accepts again
- **subsystem:** cpp_processing_locator
- **location:** data_acq_and_processing/processing/locator/src/tcp_server.cpp:431
- **impact:** In acceptor_loop any accept() error other than EINTR breaks the loop and the acceptor thread exits for good. Transient/recoverable errors (ECONNABORTED, EMFILE/ENFILE on fd-limit, ENOBUFS/ENOMEM) all permanently stop accepting. running_ stays true, is_running() keeps returning true, and data_processor keeps publish()ing into a server that can never get a new client. After a GUI/client restart it can never reconnect, with no log and no exit, until the whole daemon restarts. Compounds with the session fd leak (rank 17) which itself triggers EMFILE here.
- **fix:** Distinguish fatal vs transient accept() errors: on EINTR/ECONNABORTED continue; on EMFILE/ENFILE/ENOBUFS/ENOMEM log a warning, sleep ~100ms, and continue so the acceptor recovers once fds free up; only break when running_ is false or the fd is genuinely closed (EBADF/EINVAL). Optionally keep one reserved fd to accept-and-close under EMFILE.
### #15 [MEDIUM] Uncaught exception in the data_processor live loop crashes the headless daemon
- **subsystem:** cpp_processing_locator
- **location:** data_acq_and_processing/processing/data_processor/src/data_processor.cpp:111 (publish throw :30, main catch main.cpp:87)
- **impact:** run() calls deserialize_preprocessed_collection, process_collection, and publish_result_collection (which throws when a serialized result exceeds the results ring slot) with no per-iteration try/catch. Any throw propagates to main -> exit 1, taking down the whole processing+locator stage. A single oversized/edge-case result (e.g. a large bscan replay table) or one corrupt preprocessed frame is a hard outage rather than a dropped frame, and the supervisor does not respawn it (rank 1).
- **fix:** Wrap the per-iteration body (deserialize, process, publish_result_collection, publish_locator) in try/catch that logs and continues to the next ring item. Reserve fatal exit for truly unrecoverable conditions (ring detached). For publish, log-and-drop oversized results instead of throwing.
### #16 [MEDIUM] Child stdout/stderr log files grow without rotation; long-lived daemon can exhaust the SD card and wedge the system
- **subsystem:** py_orchestration / recent_changes / cross_cutting
- **location:** python_app/orchestration/process_supervisor.py:158-164
- **impact:** _spawn opens runtime/logs/{name}.out.log and .err.log in 'wb' (truncate only at spawn) and hands the fds to each child. There is zero rotation/size cap anywhere in the repo. The always-on data_processor and a continuously-logging producer (per-sweep logging, repeated reconnect warnings while a device is absent, locator per-malformed-packet warnings) run for days/weeks between reboots and grow .out/.err without bound. A full rootfs on a Pi corrupts SQLite/NPZ writes and SHM/config writes and can wedge the whole system -- including the very logs needed to diagnose it. Merges PS-002, RS-06, and the recent_changes data_processor log finding.
- **fix:** Do not redirect children to plain truncating files for a long-lived daemon: pipe output through a size-bounded RotatingFileHandler-style writer, run children under systemd-journald, or periodically rotate/cap (size + count). At minimum cap each file and rotate the always-on data_processor log on a size limit; extend throttling to all hot-path warnings.
### #17 [MEDIUM] Non-draining/dead locator client leaks fd + 2 threads forever; sessions are only reaped on new accept()
- **subsystem:** cpp_processing_locator
- **location:** data_acq_and_processing/processing/locator/src/tcp_server.cpp:200 (write_all :38-59; reap only at :439)
- **impact:** The latest-wins change keeps a full-queue client instead of request_stop() on overflow. With a blocking writer (write_all loops on send() with no SO_SNDTIMEO) and reaping only inside acceptor_loop, a client whose TCP window goes to zero (peer alive but not reading) blocks the writer thread forever; the reader stays blocked in recv() (peer never closes) so exited_ is never set and the session is never reaped -- leaking one fd + two threads per stuck client. Separately, normally-finished sessions also linger in clients_ until the next accept(), so with a fixed/flapping client set zombies accumulate and every publish() wastes work iterating them; this eventually triggers EMFILE -> rank 14. Merges LOC-001 and the recent_changes reap finding.
- **fix:** Set SO_SNDTIMEO on accepted sockets and treat send timeout as fatal -> request_stop(), so a stuck peer is torn down. Set exited_ when BOTH loops finish so a writer-only death is reapable. Call reap_finished_clients() from publish()/broadcast_packet() (try-lock, join outside the mutex) or a periodic timer so clients_ is bounded regardless of new connections. Tune TCP keepalive (KEEPIDLE/INTVL/CNT).
### #18 [HIGH] NaN/Infinity float fields round-trip into run_config.json and abort every C++ consumer at boot
- **subsystem:** py_config_models
- **location:** python_app/models/run_config_codec.py:113-122 (write path config_writer.py:46)
- **impact:** All float fields are coerced with bare float(); Python json.loads accepts NaN/Infinity and float('nan')/('inf') also arise from stray strings. validate_gpr_model uses float(rel_perm) <= 0.0, always False for NaN, so a NaN permittivity passes. ConfigWriter.write() calls json.dumps with default allow_nan=True, emitting literal NaN/Infinity into run_config.json; the C++ nlohmann parser (run_config.cpp:431, default flags) throws parse_error. The moment a profile with any non-finite numeric is saved, every spawned C++ process fails to load config and exits at startup -- the appliance silently never acquires while the JSON looks valid to an operator.
- **fix:** Reject non-finite numbers at decode and encode time: add a _read_float helper that does float(...) then raises ValueError if not math.isfinite, and use it for every float() in run_config_codec.py. Independently pass allow_nan=False to json.dumps in config_writer.py:46, live_processing_config.py:142, and profile_io_mixin.py:31 so a stray NaN fails loudly in Python.
### #19 [MEDIUM] Abrupt GUI SIGKILL orphans C++ children holding the radar/rings/port; next start spawns a conflicting second pipeline
- **subsystem:** cross_cutting
- **location:** python_app/orchestration/process_supervisor.py:77-83
- **impact:** Clean shutdown relies on closeEvent -> _stop_all_processes. If the GUI is killed abruptly (kill -9, OOM-killer, crash skipping closeEvent), the Popen children reparent to init and keep running, still holding the USB radar handle, locator TCP port, and SHM rings. is_running()/is_processor_running() consult only the in-memory _processes dict (empty in a fresh process), so the new instance does not detect orphans and spawns a second full pipeline; two processes then contend for the same device and rings. The start.sh flock and 'systemctl stop' only cover the systemd-managed case; a kill -9'd interactive launch or any non-cgroup kill leaves orphans uncovered.
- **fix:** Detect/reap pre-existing pipeline processes at startup independent of in-memory state: write child PIDs to a runtime pidfile and kill stale ones on start, or scan for known binary names, or under systemd use KillMode=control-group and launch children in a dedicated process group killed on supervisor start. Combine with rank-13 ring cleanup.
### #20 [HIGH] Headless boot pip install hangs/fails forever on an offline appliance, causing a crash-restart loop
- **subsystem:** deploy_daemon
- **location:** /home/europa/Documents/radar_system/start.sh:354 (failure exit :167-169)
- **impact:** main() calls ensure_python_dependencies() even in --headless mode. If the import probe fails for any reason (partially-upgraded wheel, .pyc/.so mismatch after an OS update, corrupted .venv, a new dep in requirements.txt), the daemon runs pip install. On an offline appliance pip cannot reach PyPI, blocks on DNS/connect retries (delaying the unit), then exits non-zero -> with Restart=on-failure/RestartSec=3 this becomes a crash-restart loop that never starts the radar. The headless guard at :347-353 only skips sudo/system steps, not the more likely network block.
- **fix:** In headless mode treat missing dependencies as a hard, fast failure: if the import probe fails, log a clear error and exit non-zero immediately, or gate the pip-install branch behind ((HEADLESS == 0)). Provisioning should only happen during the documented interactive launch. Optionally set PIP_NO_INDEX defensively so any accidental install fails fast instead of hanging.
### #21 [HIGH] Daemon runs --skip-build with no validation that build/bin binaries exist and are current
- **subsystem:** deploy_daemon
- **location:** /home/europa/Documents/radar_system/deploy/install-daemon.sh:54 (skips start.sh:360-362)
- **impact:** ExecStart passes --skip-build, so the boot daemon never builds. The default librevna producer is the native build/bin/sweep_orchestrator. If that binary is missing, stale (built against a changed C++/SHM layout), or wiped by git clean/partial update, the daemon either fails to spawn the producer (silenced per rank 1/2) or runs a producer whose SHM ring format mismatches the reader -> silent no-data or corrupt data. There is no pre-flight check that required binaries exist and are newer than sources.
- **fix:** Add a fast headless pre-flight that verifies the required build/bin binaries exist and are executable (no full rebuild) and aborts with a non-zero exit if missing or older than their sources, so Restart/operator notice fires. Alternatively run make -q and fail fast on a stale tree rather than trusting --skip-build.
### #22 [HIGH] systemd unit has no boot ordering or device-readiness gate, racing USB/local-fs at boot
- **subsystem:** deploy_daemon
- **location:** /home/europa/Documents/radar_system/deploy/install-daemon.sh:46
- **impact:** The generated unit has an empty [Unit] section (no After=/Wants=/Requires=) and Type=simple. WantedBy=multi-user.target only sets the install target, not startup ordering against device/filesystem readiness. On a Pi the USB radar enumerates asynchronously after udev settles and the .venv/project may live on a not-yet-ready mount, so the daemon can start before the device node exists and hit 'device not found' (then silently idle per rank 2 or churn per rank 7). Type=simple also marks the service 'started' the instant exec begins, so readiness cannot be relied upon.
- **fix:** Add ordering: After=local-fs.target systemd-udev-settle.service and Wants=systemd-udev-settle.service (or a device-specific BindsTo=/After=dev-...device via a udev SYSTEMD_WANTS tag); if the project mount is non-root add RequiresMountsFor=${PROJECT_ROOT}. Consider Type=notify with sd_notify(READY=1) once the pipeline is actually producing.
### #23 [MEDIUM] Headless daemon writes all logs/errors only to an offscreen Qt widget; nothing reaches journald
- **subsystem:** cross_cutting
- **location:** python_app/gui/app_window.py:365 (widget app_window_ui_mixin.py:181; unit deploy/install-daemon.sh:46)
- **impact:** In --headless mode every GUI-side message (startup errors, pipeline-start failures, reader-poll exceptions, child-crash exit reports, 'Status: error') is rendered via _append_log_entry into a QTextEdit on the offscreen platform. Nothing is written to stdout/stderr/journald (no logging/StreamHandler in the GUI process; the unit sets no StandardOutput/SyslogIdentifier). The widget is capped at 1200 in-memory blocks, so older errors scroll away and are lost on exit. 'journalctl -u radar.service' shows no GUI diagnostics, making a headless box undebuggable when acquisition silently stops.
- **fix:** In headless mode also route _append_log_entry (at least WARN/ERROR) to Python logging with a StreamHandler to stderr (captured by journald) and/or a rotating file under runtime/logs. Set SyslogIdentifier and StandardError=journal in the unit. Keep the widget for GUI mode.
### #24 [LOW] SIGTERM/SIGINT handler runs full Qt teardown inline from C signal context; re-entrant and reentrancy-unsafe
- **subsystem:** py_gui_lifecycle / cross_cutting
- **location:** python_app/gui/main.py:42 (closeEvent app_window.py:512)
- **impact:** _request_shutdown directly calls window.close() -> closeEvent (which terminates C++ children with multi-second waits and closes mmaps) and app.quit() from signal context. Python delivers handlers between bytecodes on the main thread, so a second SIGTERM (systemd escalation or a double Ctrl-C) arriving during the blocking teardown re-enters _request_shutdown -> closeEvent recursively on half-torn-down state (readers None, supervisor map mutated mid-iteration), corrupting teardown ordering or raising inside the handler. There is no closeEvent re-entry guard and no signal de-arming. This is the daemon's normal shutdown path. Merges RS-001 and the cross_cutting signal-safety finding.
- **fix:** Make the handler async-signal-safe: only set a flag / write a self-pipe (signal.set_wakeup_fd + QSocketNotifier) or QTimer.singleShot(0, window.close) to schedule teardown on the next event-loop iteration, and immediately reset handlers to SIG_IGN/SIG_DFL so a repeat signal cannot re-enter. Add a self._closing guard at the top of closeEvent that returns early if teardown is in progress.
### #25 [LOW] pop() throws on oversized payload_size and reader trusts payload_size before bounds-checking the mapping
- **subsystem:** ipc_shm
- **location:** data_acq_and_processing/common_cpp/ipc/src/shm_ring.cpp:331-338 (Python ring_reader.py:56,63)
- **impact:** pop() throws runtime_error when slot->payload_size > slot_size_bytes -- reachable from a torn write (rank 5) or stale/corrupt ring (rank 13) -- and the throw propagates up the preprocessor/processor run loops, killing the daemon (compounds rank 3/15). The Python reader does NOT validate payload_size at all before slicing, so a torn/corrupt size runs off the slot into adjacent slots/header and decode_* mis-parses. The size is also read non-atomically relative to the producer's write of it (rank 5), so even in normal wrap the size can belong to a different generation than the copied bytes. Merges SHM-006 and SHM-007.
- **fix:** Make pop() treat an over-size payload as a corrupt slot it skips: log, advance read_seq past it (resync), and return false instead of throwing. Validate payload_size <= slot_size_bytes in the Python reader and reject/resync otherwise. Combine with rank-5's post-copy sequence re-validation so a size/payload pair is accepted only if the slot sequence is unchanged across the read.
### #26 [LOW] Blocking device I/O makes SIGTERM/SIGINT shutdown hang up to the full I/O timeout (~20s for K209)
- **subsystem:** cpp_acquisition
- **location:** data_acq_and_processing/sweep_orchestrator/src/sweep_orchestrator.cpp:117 (and :149)
- **impact:** The signal handler only sets g_stop_requested and the run loop checks it between sweeps/combos. Every per-combo step blocks in non-interruptible device I/O: native VNA sweep up to ~1500ms in libusb_bulk_transfer, DeviceInfo wait up to 2s, remote K209 ::recv up to 20000ms. On a headless Pi a SIGTERM during a stalled read is ignored for the full timeout, and a wedged device that keeps timing-out-and-retrying can effectively never honor stop, forcing the supervisor's force-kill. Clean shutdown / switch-to-safe-state is not guaranteed.
- **fix:** Make the stop flag observable inside blocking waits: pass stop_requested into the driver acquire path (or a self-pipe/eventfd woken by the handler), check it inside wait_for_packet/wait_for_ack/pump_usb and recv_exact/send_all loops, and keep per-call USB/socket timeouts short and re-loop so SIGTERM is honored within a few hundred ms.
### #27 [MEDIUM] Oversized serialized collection makes push() return false and is escalated to a fatal crash; tap-ring failure aborts the primary path
- **subsystem:** cpp_acquisition / cpp_preprocess
- **location:** data_acq_and_processing/sweep_orchestrator/src/sweep_orchestrator.cpp:84-91 (also data_preprocessor.cpp:126-134)
- **impact:** push() returns false only when payload > slot_size_bytes; overflow is handled internally by overwrite-oldest. publish_collection/publish_preprocessed_collection throw on false -> exit 1. slot_size_bytes is fixed at create time but serialized size scales with run_combos x sweep.points x per-point bytes, so growing combo/point count past the slot makes EVERY collection too large and the first publish crashes the daemon at startup with no recovery. Worse, a too-small raw_tap/preprocessed_tap slot crashes the whole producer even though the primary ring already accepted the data -- a debug/GUI tap takes down the real data path. Merges SO-005 and PREP-003.
- **fix:** Validate worst-case serialized size against slot_size_bytes at open/startup and fail fast with a clear config error there. At runtime, log-and-drop (increment an oversize counter) on a too-large payload instead of throwing, and make tap pushes strictly best-effort so a tap failure can never abort the primary path.
### #28 [LOW] ConfigWriter.write performs a non-atomic write_text of run_config.json consumed by spawning C++ children
- **subsystem:** py_config_models
- **location:** python_app/orchestration/config_writer.py:43-47
- **impact:** write() does output_path.write_text(json.dumps(...)) directly (no temp+rename), unlike sibling writers that use temp+replace. The supervisor reads this file (_read_radar_model :222) and every C++ child reads it via --config at startup. An interrupted write (power loss mid-write, or a child reading during a restart rewrite) yields a truncated/empty file -> json.loads raises and start() aborts opaquely, or a child fails to parse. On crash mid-write the on-disk file is left corrupt and persists across reboot, so the boot daemon fails to start the pipeline every boot until manually repaired.
- **fix:** Write atomically: dump to output_path.with_suffix('.json.tmp'), flush+os.fsync, then os.replace() onto the destination (matching ProcessingLiveConfigWriter). Optionally fsync the parent dir for power-loss durability.
### #29 [MEDIUM] Respawn opens child log files in truncate mode, destroying the prior child's crash log before it is reported
- **subsystem:** py_orchestration
- **location:** python_app/orchestration/process_supervisor.py:163-164
- **impact:** _spawn always opens stdout/stderr with open(path,'wb') (truncate) and early-returns only if the existing handle is still alive. When a process has crashed but its exit has not yet been collected (collect_exit_reports removes it, but start() can run before the next 50ms poll, e.g. single-capture restart or operator re-Start), the next _spawn reopens 'wb' and erases the crashed child's stdout/stderr -- the diagnostic evidence of why it died is gone before anyone reads it, undermining the exit-report mechanism. Same loss across a parent restart for the previous boot's final crash log.
- **fix:** Before truncating, if a stale (exited, uncollected) entry exists for this name, fold its tail into an exit report first or roll the existing log to {name}.out.log.prev/.err.log.prev. Alternatively open in append mode with a session delimiter (paired with rotation from rank 16) so the crash log survives respawn.
### #30 [MEDIUM] Singular OSL calibration points silently substitute degenerate coefficients; a bad calibration loads and is used
- **subsystem:** cpp_preprocess
- **location:** data_acq_and_processing/preprocessing/calibration_master/src/channel_bundle_support.cpp:221-226
- **impact:** solve_osl_coefficients initializes source_match=0, reflection_tracking=1 and only overwrites them when norm(open_delta-short_delta) > 1e-12. When open/short standards are nearly equal (a degenerate capture, common with a flaky USB VNA on a Pi) the point keeps the degenerate coefficients, making S11 correction at that frequency reduce to measured-minus-directivity with no real correction. There is no count, log, or threshold on fallbacks, and 1e-12 on a float magnitude-squared rarely trips for ill-conditioned-but-nonzero denominators. A largely-degenerate calibration loads successfully and produces systematically wrong S11 with no operator-visible indication.
- **fix:** Track the fraction of fallback points per combo; throw at load time if it exceeds a small threshold so a bad bundle is rejected at startup instead of silently used. Use a relative (not just absolute) conditioning check on the denominator and log which combos/frequencies were degenerate.
### #31 [MEDIUM] libusb retry path does full libusb_exit/init churn per recovery; a recoverable USB glitch becomes a fatal device-not-found
- **subsystem:** cpp_acquisition
- **location:** data_acq_and_processing/sweep_orchestrator/device_drivers/radar/librevna/librevna_minimal_driver_lifecycle.cpp:157-159 (transport.cpp:111,171)
- **impact:** On a retryable native error, acquire_sweep calls close_native() (libusb_exit) then open_native() (libusb_init), destroying and recreating the entire libusb context and re-enumerating all USB devices per transient stall. On a Pi this re-enumeration is slow and racy right after a replug: the kernel may not have re-bound the device, so find_matching_device_handle returns null and open_native throws the non-retryable 'No compatible LibreVNA USB device found', turning a recoverable glitch into a fatal exit (compounds rank 8). Repeated init/exit cycling also stresses libusb on a long-running daemon.
- **fix:** Keep the libusb_context alive across retries; only release/reclaim the interface and reopen the handle, not the whole context. On reconnect retry device discovery with a short bounded backoff (a few hundred ms, a few attempts) to absorb re-enumeration latency, and classify 'device not found immediately after a transient error' as retryable.
### #32 [MEDIUM] multi_device recover() uses blocking time.sleep and ignores stop_requested, delaying SIGTERM shutdown by seconds per failed acquisition
- **subsystem:** py_hardware
- **location:** python_app/hardware_full/multi_device_service.py:114-139 (controller :169)
- **impact:** recover() sleeps through _REOPEN_BACKOFF_SECONDS (0.25+0.5+1.0=1.75s) with no stop hook, and _acquire_native_collection_with_recovery calls recover() up to recovery_attempts+1 (default 4) per acquire_collection(). One failed acquisition can block ~4 x (1.75s + open/close) before stop_requested is re-checked. On device removal + SIGTERM the producer can take tens of seconds (with USB re-enumeration) to exit, risking systemd TimeoutStopSec SIGKILL and an unclean shutdown; the signal handler only sets a threading.Event these C-level/sleep sections never observe.
- **fix:** Thread the stop Event into recover() and _acquire_native_collection_with_recovery; use stop_event.wait(delay) instead of time.sleep and bail out of both the backoff and recovery-attempt loops the moment stop is set. Cap total recovery wall-time per acquire_collection() so shutdown stays well under TimeoutStopSec.
### #33 [LOW] stop() force-kills on a shared 2s deadline, drops handles without exit reports, and may orphan device-I/O grandchildren
- **subsystem:** py_orchestration
- **location:** python_app/orchestration/process_supervisor.py:230-261
- **impact:** _stop_processes terminates all named processes against a single shared 2.0s deadline, kills stragglers, then _drop_exited() removes handles with NO ProcessExitReport. (1) The librevna_multi/sn9000/kamil producers are launched as 'python -m python_app.scripts...'; SIGTERM/SIGKILL to that python parent does not necessarily kill device-I/O grandchildren/threads, so a hung device thread can be orphaned holding the VNA/USB device and make the NEXT start() fail to acquire it. (2) Any abnormal exit during stop (e.g. processor segfault on teardown) is silently swallowed, so recurring shutdown crashes are invisible.
- **fix:** Use start_new_session=True (process group) on Popen for the python producer commands and os.killpg on stop so grandchildren die. Give each process its own kill deadline rather than a shared 2s budget. Before _drop_exited, capture exit codes and log abnormal stop-time exits (or route through collect_exit_reports).
### #34 [LOW] Latest socket vlc speed never expires; a dropped client's last speed is used indefinitely as live motion
- **subsystem:** recent_changes / cpp_processing_locator
- **location:** data_acq_and_processing/processing/locator/src/tcp_server.cpp:280 (read :414-420)
- **impact:** reader_loop stores any finite inbound vlc into a shared atomic that latest_socket_speed() returns forever until the next value or process restart; there is no timestamp/TTL and the value is not reset when the contributing client disconnects. If the speed feed (odometer/positioning) drops or freezes, the GPR pipeline keeps consuming the last speed as if live, silently producing migration/positioning results from stale motion with no indication the feed died.
- **fix:** Store (value, monotonic timestamp) and have latest_socket_speed() return nullopt once older than a configured staleness window so the processor falls back to manual speed or flags missing data. Optionally reset the slot to NaN when the last contributing client disconnects.
### #35 [LOW] GPIO control-button watcher leaks line/chip/pipe fds and an orphaned thread on partial start failure, wedging the button until reboot
- **subsystem:** py_config_models / recent_changes
- **location:** python_app/gui/control_button.py:87-94 (mixin app_window_control_button_mixin.py:55-60)
- **impact:** start() opens the GPIO line, then os.pipe(), then starts the daemon thread, with no rollback. If os.pipe() (fd exhaustion) or Thread.start() fails after _line.open() succeeded, start() raises with the GPIO chip+line fds (and possibly pipe fds) still open; the mixin's except only logs and _control_button_watcher stays None, so _stop_control_button_watcher can never release them. The kernel line stays claimed (consumer='radar_control_button'), so a later retry/restart hits EBUSY and the button silently never works again until reboot. The _run loop also leaks fds on any select/read error. Merges the two GPIO-watcher leak reports.
- **fix:** Wrap start()'s body in try/except that calls _line.close() and _close_stop_pipe() before re-raising, and close/release in a finally in _run (or have the failed handler trigger stop). Guard start() against double-start. Alternatively assign self._control_button_watcher before start() (or in finally) and call _stop_control_button_watcher() in the except path.
### #36 [LOW] Non-positive/out-of-range ring capacity, slot_size, and sweep points pass Python validation and crash C++ at boot
- **subsystem:** py_config_models
- **location:** python_app/models/run_config_validation.py:49-50; run_config_codec.py:118
- **impact:** load_ring_payload coerces capacity/slot_size_bytes with bare int() and no range check, and radar.sweep.points is int()-coerced with no check. A profile with capacity 0/-1, negative slot_size, or points<=0 is accepted and written to run_config.json. The C++ side throws ('Ring capacity must be > 0', 'Value out of uint32 range', 'radar.sweep.points must be > 0') and every pipeline process aborts at startup -- a recurring boot-time crash with no acquisition until the config is hand-edited. Python int(100.5)=100 also accepts a fractional points value that C++ number_to_u32 rejects, so a profile that loads in the GUI still fails in C++. Merges CFG ring and points validation findings.
- **fix:** In Python enforce capacity > 0, slot_size_bytes > 0 (with a uint32 upper bound and an overflow-safe cap on capacity*slot_size), and radar.sweep.points > 0; reject fractional points (require integral input) and validate stop_hz >= start_hz, mirroring the C++ contracts so failures surface in the GUI/save path.
### #37 [LOW] Explicit JSON null in numeric/bool config fields is silently coerced or hard-fails instead of using the default
- **subsystem:** py_config_models
- **location:** python_app/models/run_config_codec.py:32 (and the int()/float()/bool() call sites)
- **impact:** _read_str guards strings against null (payload.get returns None for explicit null; str(None)='None'), but numeric/bool fields still use int()/float()/bool() directly. With explicit null, int(None)/float(None) raise TypeError (bypassing the intended default fallback) and bool(None)=False silently overrides a True default -- e.g. multi_device.force_external_reference (default True) and control_button.active_low (default True). A profile with 'force_external_reference': null quietly disables the external reference and 'active_low': null flips the button edge polarity.
- **fix:** Generalize null-as-missing handling: add _read_int/_read_float/_read_bool helpers mirroring _read_str that treat None as 'use default', and apply them wherever int()/float()/bool() wrap payload.get(). This prevents the TypeError on null numerics and stops null from silently flipping a True default to False.
### #38 [LOW] Empty S11 calibration/reference paths silently disable correction with no operator warning
- **subsystem:** cpp_preprocess
- **location:** data_acq_and_processing/preprocessing/calibration_master/src/channel_bundle_support.cpp:303-305 (and :386-388)
- **impact:** S11CalibrationBundle::load returns early (correction disabled) when all open/short/load paths are empty, and S11ReferenceBundle::load returns early when path is empty; apply() then passes S11 through uncorrected and validate_combos skips validation when not enabled. A config typo resolving an S11 path to empty (or a missing key defaulting to '') silently disables one-port S11 correction: the box boots, runs headless, and emits uncorrected S11 with no error or warning until measurement quality is questioned much later.
- **fix:** Distinguish intentionally-disabled from misconfigured: require an explicit s11.calibration.enabled flag to disable (no-op when flagged), but when a path is expected and resolves empty/missing, throw at load so startup fails loudly. At minimum log a clear WARNING to stderr (visible in the per-process log).
### #39 [LOW] _poll_rings exception handler dedups by (type,str), permanently silencing distinct recurring reader failures
- **subsystem:** py_gui_lifecycle
- **location:** python_app/gui/controllers/app_window_pipeline_mixin.py:319-324
- **impact:** When _poll_rings raises, it logs once per unique (type, message) and suppresses every identical exception thereafter. A persistent reader fault (SHM ring detached after a producer crash, repeated 'Result ring reader is not initialised') is logged once then silently swallowed every 50ms forever; the status label is set to error only via collect_exit_reports, not here, so the operator sees no continuing signal that polling/rendering is dead -- the screen simply stops updating. There is also no recovery attempt (readers are never reset/reconnected).
- **fix:** Keep dedup for log spam but still set the status label to error on a repeated reader error, periodically re-log (every N seconds or count), and trigger a reader-reconnect or pipeline-stop path so a wedged reader is surfaced and recovered rather than failing silently.
### #40 [MEDIUM] Blocking hardware capture and time.sleep drain loops run on the GUI/event-loop thread, freezing the headless daemon and starving signal delivery
- **subsystem:** py_gui_lifecycle
- **location:** python_app/gui/controllers/app_window_control_button_mixin.py:73 (drains app_window_pipeline_mixin.py:417, snapshot_mixin.py:236)
- **impact:** _on_control_button_pressed -> _capture_tmp_reference runs entirely on the Qt main thread: it calls _stop_run() (with time.sleep drain loops) and capture_reference_set() which opens the device and acquires median_sweep_count sweeps synchronously. During this the event loop is blocked, so the 50ms _poll_rings stops draining SHM rings (rings fill/overwrite), the headless keepalive timer that delivers Unix signals stops firing, and queued button signals stall. The bounded drain loops (~0.6s stop, ~0.35s clear, ~1.2s snapshot) compound this on the closeEvent path, widening the signal-reentrancy window (rank 24). A physical button press produces a multi-second total UI/daemon stall and delays SIGTERM. Merges the capture-on-GUI-thread and drain-loop findings.
- **fix:** Run capture off the GUI thread (QThread/worker, results marshaled via queued signal) and guard re-entrant presses with a busy flag. Convert the bounded drain loops to event-loop-friendly waits (QEventLoop+QTimer or a worker) so signals and the keepalive timer keep firing, or aggressively cap/avoid blocking drains on the closeEvent path.
### #41 [LOW] RF switches are not driven to a safe/default state on shutdown or crash; a transient ioctl failure is fatal
- **subsystem:** cpp_acquisition
- **location:** data_acq_and_processing/sweep_orchestrator/device_drivers/switches/h7992_minimal_driver.cpp:136 (hmc349a :119; hot-loop switch_to sweep_orchestrator.cpp:156-157)
- **impact:** open() drives switches to default_position, but close_native() only releases the GPIO fds and never returns the lines to the safe position, so on exit (clean SIGTERM, crash, or exit-1) the RF front-end is left in an undefined electrical state between runs. Worse, switch_to() in the hot loop is unguarded: a single GPIO_V2_LINE_SET_VALUES ioctl failure throws, is in no retry path, and kills the whole daemon, leaving the matrix switches in whatever state they were last commanded.
- **fix:** In close_native() command lines to default_position before closing fds so the RF path is left known-safe; ensure the DriverLifecycleGuard destructor also drives switches safe. Wrap per-combo switch_to() in the same recoverable-error handling as device reads so a transient ioctl failure retries/reconnects instead of crashing.
### #42 [LOW] USBTransport.disconnect() closes the libusb handle/context even when the RX-thread join times out (use-after-free hazard)
- **subsystem:** py_hardware
- **location:** python_app/hardware_full/librevna_driver/transport/usb.py:155-174
- **impact:** disconnect() sets the stop event, joins the RX thread with a 1.0s timeout, then unconditionally releaseInterface()/close()es the handle and closes the context even if the join TIMED OUT and the thread is still inside a blocking bulkRead. Closing the USBContext/handle out from under a live RX thread is a use-after-free / libusb-state-corruption hazard that on a Pi can hang or crash during reconnect; under retry-forever, any RX thread that fails to exit within 1s raises the odds of an orphaned daemon thread referencing a closed context.
- **fix:** After join(timeout=1.0) check rx_thread.is_alive(); if still alive, log a hard fault and either retry the join with a longer bound or skip closing the handle/context (deliberate leak is safer than closing under a live thread). Better: ensure the 100ms bulkRead + stop_event check guarantees exit, and assert the join succeeded before closing.
### #43 [LOW] GpioOutputLines/GpioLineEventWatcher close() can raise from os.close and leave the second fd open
- **subsystem:** py_hardware
- **location:** python_app/hardware_full/switch_drivers/gpio_uapi.py:182-185 (watcher :318-321)
- **impact:** close() calls _close_line_fd() then _close_chip_fd() sequentially with no exception isolation. If os.close(line_fd) raises (EINTR, or EIO/ENODEV when a USB GPIO expander is yanked on a Pi), the exception propagates and _close_chip_fd() never runs, leaking the chip fd; the chip fd is also never reset to -1, so a later reopen overwrites/leaks it. Over many switch open/close cycles in a long-running daemon this slowly exhausts fds.
- **fix:** Make close() best-effort and idempotent: wrap each os.close in try/finally (or contextlib.suppress(OSError)) so both _close_line_fd and _close_chip_fd always run and always reset their fd to -1 even when close() errors. Apply to both classes.
### #44 [LOW] matrix producer's radar.close() in finally can block SIGTERM-driven shutdown on a hung device
- **subsystem:** recent_changes / py_hardware
- **location:** python_app/scripts/matrix_raw_producer.py:154-157
- **impact:** On SIGTERM the loop breaks and finally calls radar.close() under suppress(Exception). For SN9000 (VISA/TCP) or LibreVNA (libusb), close() can issue a blocking transport teardown that hangs when the device is unresponsive -- exactly the failure this producer tolerates -- and suppress() does not bound time. With the supervisor's ~2s pre-SIGKILL budget, a hung close() means force-kill; and if acquire_collection() is mid-blocking-read when the signal arrives, the Python handler cannot interrupt the C-level call, so stop_requested is observed only after it returns, delaying clean exit up to the device timeout and risking SIGKILL mid-sweep (partial device state).
- **fix:** Bound device teardown: run radar.close() with a watchdog/timeout (timer thread or hard deadline) so a hung transport cannot delay exit, and ensure the driver's blocking acquire uses a finite transport timeout so stop_requested is checked at bounded intervals.
### #45 [LOW] KamilAdcService.open()/_wait_for_tty busy-polls and ignores the stop Event, delaying shutdown during the boot startup window
- **subsystem:** py_hardware
- **location:** python_app/hardware_full/kamil_adc_service.py:419-430
- **impact:** _wait_for_tty polls with time.sleep(0.05) up to startup_timeout_s with no reference to the producer's stop_requested Event. If the collector is slow to create the TTY (or never does) at boot and the operator sends SIGTERM during this window, the producer cannot interrupt the wait and must block until startup_timeout_s elapses before unwinding. Combined with the lack of an open() retry loop (rank 10), startup is the least responsive phase to a stop request, adding to worst-case TimeoutStopSec pressure.
- **fix:** Accept an optional stop Event in open()/_wait_for_tty and break the poll loop promptly when set (event.wait(0.05) instead of time.sleep). Have kamil_adc_raw_producer pass its stop_requested Event through so shutdown is immediate in all phases.
### #46 [LOW] Crashed-child exit reporting reads both 16KB log tails on the GUI thread every 50ms; stop() blocks the GUI for seconds
- **subsystem:** py_orchestration
- **location:** python_app/orchestration/process_supervisor.py:240-253 (tails :299-300)
- **impact:** collect_exit_reports (called every 50ms from the GUI QTimer) does a 16KB seek+read on each exited child's stdout AND stderr (SD-card I/O from the UI loop). More significantly, _stop_processes blocks the calling thread up to ~2s (terminate deadline) + up to 1s per force-killed process; stop_all on three stuck children freezes the GUI ~3-5s. On the headless box the GUI is the supervising loop, so during a stop the 50ms ring poll stalls, exit reports are not collected, and any added watchdog is starved.
- **fix:** Move process termination/wait off the GUI thread (worker thread or QProcess async finished signals) or cap the total stop budget. Read log tails lazily only when actually building an ERROR report, not on every 50ms poll for every exited process.
### #47 [LOW] load_channel_traces silently collapses duplicate combos via insert_or_assign, hiding bundle corruption
- **subsystem:** cpp_preprocess
- **location:** data_acq_and_processing/preprocessing/calibration_master/src/channel_bundle_support.cpp:142
- **impact:** load_channel_traces builds traces_by_combo with insert_or_assign for every trace. If a calibration/reference bundle contains two traces for the same ComboKey (a generation bug or a partially-overwritten/corrupted bundle), the second silently overwrites the first. The operator believes a calibration is loaded for that combo when it is actually an arbitrary last-wins duplicate, potentially the wrong standard; this passes all combo-coverage validation and is undetectable at runtime.
- **fix:** Use insert() and check the bool result; on a duplicate ComboKey throw a descriptive runtime_error ('duplicate combo X in <bundle_label> bundle') so a malformed bundle is rejected at load time.
### #48 [LOW] Stale-but-running locator keeps emitting sts=1 with a fresh timestamp; clients cannot tell processing has stalled
- **subsystem:** cpp_processing_locator
- **location:** data_acq_and_processing/processing/locator/src/payload_builder.cpp:184
- **impact:** build_payload_json hardcodes sts=1 and a fresh wall-clock tim on every packet. The snapshot-on-connect sends the last cached packet to new clients, and publish() is only driven by data_processor frames. If the processor loop stalls or exits, the locator keeps the last cached packet, and any newly connecting client receives a packet that always claims sts=1 with stale observations -- there is no liveness/heartbeat or staleness indication, so a downstream consumer cannot distinguish live data from a frozen pipeline.
- **fix:** Carry a real status/age signal: stamp packets with the source frame time so consumers can detect staleness, or emit a heartbeat with sts reflecting whether a fresh result was produced within a recent window. At minimum do not re-send a stale cached snapshot to a new client without marking it stale.
### #49 [MEDIUM] Tap/overflow rings have two concurrent plain-store writers to read_seq -> lost-update race
- **subsystem:** ipc_shm
- **location:** data_acq_and_processing/common_cpp/ipc/src/shm_ring.cpp:299 (Python ring_reader.py:59,64,95)
- **impact:** The ring is single-producer/single-consumer, but on the tap rings (raw_tap, preprocessed_tap) BOTH sides write read_seq concurrently: the C++ producer advances read_seq on overflow (plain store read_seq+1) while the GUI ShmRingReader advances read_seq on every pop and drop_all. These plain stores clobber each other: the producer can rewind read_seq from a faster consumer's higher value back to R+1, so already-consumed slots are re-read (duplicate payloads) or the over-full check is computed against a rewound read_seq, corrupting full/empty accounting. Manifests as duplicated/garbled GUI frames and unbounded apparent backlog.
- **fix:** Make read_seq advancement a CAS loop on both the producer overflow path and all consumers, or redesign so the producer never touches read_seq for overflow (advance only write_seq with separate dropped accounting; consumers detect lapping via the per-slot sequence check). At minimum the producer's overflow store must be a compare_exchange so it never moves read_seq backward.
### #50 [LOW] open_existing() never validates capacity/slot_size against mapped size; slot_header() can compute out-of-bounds offsets (SIGSEGV)
- **subsystem:** ipc_shm
- **location:** data_acq_and_processing/common_cpp/ipc/src/shm_ring.cpp:216 (slot math :349-353)
- **impact:** open_existing() only checks st_size >= sizeof(Header), magic, and version; it trusts header->capacity and slot_size_bytes verbatim. slot_header()/slot_payload() then compute sizeof(Header)+index*slot_stride and read slot_size_bytes past it. A stale/truncated/corrupt file (a crash during ftruncate left a short file, or a different-geometry ring with a matching magic) makes the computed slot address point outside the mmap -> SIGSEGV or reading adjacent memory. push()/pop() dereference slot fields with no bounds check -- a hard crash on a headless Pi.
- **fix:** In open_existing() compute expected_size = sizeof(Header) + (sizeof(SlotHeader)+slot_size_bytes)*capacity from header fields and require st_size >= expected_size (and capacity>0, slot_size_bytes>0, no multiplication overflow) before returning, else throw. Apply the same expected-size check in open_or_create()'s EEXIST branch (it currently only compares equality, not actual file size).
### #51 [MEDIUM] stop_headless_service: a sudo failure aborts the entire GUI launch under set -euo pipefail
- **subsystem:** deploy_daemon
- **location:** /home/europa/Documents/radar_system/start.sh:327
- **impact:** 'sudo systemctl stop ${SERVICE_NAME}' runs as a bare command under set -euo pipefail. If the sudoers rule is absent/mismatched (SERVICE_USER differs from the login user, install under a different account, or systemctl path moved so the NOPASSWD absolute-path match fails), sudo prompts for a password in a possibly TTY-less context or returns non-zero. Unguarded, a non-zero return makes set -e abort start.sh entirely -- the operator cannot launch the GUI at all, and the still-running daemon keeps owning the radar/SHM/locator port.
- **fix:** Make the stop best-effort and non-fatal: 'sudo -n systemctl stop "${SERVICE_NAME}" || echo WARN...' then verify with systemctl is-active and only hard-fail if still active. Use sudo -n to avoid hanging for a password on a TTY-less invocation. In sudoers allow both /usr/bin/systemctl and /bin/systemctl (or a unit-scoped path) so a relocated binary still matches.
### #52 [LOW] Locator reader recv()/writer have no SO_RCVTIMEO/SO_SNDTIMEO; a stalled client pins the reader thread and the vlc-update path
- **subsystem:** cpp_processing_locator
- **location:** data_acq_and_processing/processing/locator/src/tcp_server.cpp:63
- **impact:** read_exact loops on recv() with no timeout. A client that sends a valid 8-byte header advertising a payload then sends nothing leaves reader_loop blocked in recv() indefinitely. The reader is the only path that updates latest_socket_speed_, so a stalled/slow first client can prevent fresh vlc speed updates, and the session is not reaped (exited_ unset) until external teardown. A buggy/hostile LAN client can hold a reader thread per connection. Shutdown still works (request_stop -> shutdown unblocks recv), so this is a steady-state hang.
- **fix:** Set SO_RCVTIMEO on accepted sockets and treat EAGAIN/EWOULDBLOCK in read_exact as a check-stop-flag-and-retry (or slow-client disconnect after N timeouts). Re-check stop_requested_ between recv() calls so a stalled reader notices teardown even before shutdown().
### #53 [LOW] Malformed JSON types (array/object where a scalar is expected) raise TypeError, defeating ValueError-only config error handling
- **subsystem:** py_config_models
- **location:** python_app/models/run_config_validation.py:23
- **impact:** Every coercion in load_switch_payload/load_control_button_payload/load_ring_payload and the radar/laser/locator sections uses bare int()/float()/str() on the raw JSON value. A wrong-type field (radar_port:[1,2], pin:{}, capacity:[..]) raises TypeError, not ValueError. The codec and gui_profile_codec emit clean ValueError for bad shape, so callers/tests that catch ValueError as the canonical bad-config signal let TypeError escape uncaught. The GUI load path catches broad Exception so it survives, but any non-GUI consumer doing 'except ValueError' crashes instead of reporting a config error.
- **fix:** Route all scalar reads through typed helpers (like gui_profile_codec's _optional_int/_optional_float/_optional_string with isinstance checks that raise ValueError), or wrap the int()/float() calls so a non-scalar value raises ValueError with the field name, making the malformed-input contract uniform.
### #54 [LOW] install-daemon.sh aborts if SUDO_USER is unset (root login / sudo -i / cloud-init), blocking first-boot provisioning
- **subsystem:** deploy_daemon
- **location:** /home/europa/Documents/radar_system/deploy/install-daemon.sh:18
- **impact:** SERVICE_USER = ${SUDO_USER:-root}; installing from a real root shell, serial console, sudo -i, or cloud-init (SUDO_USER unset) makes SERVICE_USER=root and the script exits demanding a normal login user. On a fresh Pi image, first-boot provisioning is frequently done as root with no SUDO_USER, so the documented one-shot install fails and the daemon is never installed. There is also no validation that SERVICE_USER exists, is in plugdev, or can read the .venv/project tree, so a mismatched user yields a daemon that cannot execute its own venv.
- **fix:** Accept an explicit RADAR_SERVICE_USER arg/env and fall back to the owner of PROJECT_ROOT (stat -c %U) rather than failing when SUDO_USER is empty. Validate the chosen user exists (id), is in plugdev, and owns/can read the .venv and project tree, failing with an actionable message otherwise.
### #55 [LOW] Socket-supplied vlc speed never triggers reprocessing of the current result and is read non-atomically w.r.t. live config
- **subsystem:** cpp_processing_locator
- **location:** data_acq_and_processing/processing/data_processor/src/data_processor.cpp:166 (reprocess gate :71)
- **impact:** resolve_effective_live_config() overlays latest_socket_speed() onto gpr_speed_m_s every tick, but the reprocess/replay branch is gated solely on the file revision counter, which changes only when the live-config FILE changes. A new vlc value over the socket therefore does not reprocess the current result -- it only affects the next preprocessed frame popped from the ring. If the radar is paused (no new frames), a speed update from the client has no visible effect until motion resumes. Matches an inline comment so may be intended, but the on-wire speed control silently does nothing while idle.
- **fix:** If live speed should affect the current/last result, track a dirty flag when latest_socket_speed() changes value and OR it into the reprocess condition (respecting reprocess_current_result). If the current behavior is intended, document it explicitly so it is not mistaken for a bug during field debugging.
### #56 [LOW] Watcher pressed/failed slots can execute after the watcher is stopped because signals are never disconnected on teardown
- **subsystem:** py_gui_lifecycle
- **location:** python_app/gui/controllers/app_window_control_button_mixin.py:80
- **impact:** _stop_control_button_watcher calls watcher.stop() and sets the field None but never disconnects watcher.pressed/failed. A queued cross-thread pressed emitted just before stop() can still be delivered after stop() returns and after the watcher is dereferenced. closeEvent stops the watcher first but then continues multi-second teardown while the loop is not spinning, so in resume-after-close or non-close stop paths a queued press can re-trigger _capture_tmp_reference against an already torn-down pipeline (supervisor stopped, readers None).
- **fix:** In _stop_control_button_watcher, disconnect watcher.pressed/failed from their slots before/after stop() and consider watcher.deleteLater(). Re-check self._supervisor/self._closing state at the top of _on_control_button_pressed.
### #57 [LOW] parse_combos_from_text raises uncaught/opaque ValueError on non-numeric combo tokens with no count cap
- **subsystem:** py_config_models
- **location:** python_app/models/run_config_validation.py:97
- **impact:** parse_combos_from_text splits on ',' and ':' then int()s each side with no guard. A token like 'a:0', '0:', or '0:x' raises ValueError('invalid literal for int') exposing the raw failure rather than a combo-context message, and an empty side raises without saying which field is wrong. Reached from the GUI switches text box (caught broadly, so no crash) but the operator gets an opaque Python error. There is also no cap on parsed combos, so a pathological pasted string allocates one ComboModel per token, unbounded before downstream Cartesian expansion.
- **fix:** Wrap the int() conversions in try/except ValueError and re-raise with the offending pair/side (e.g. 'Invalid combo {pair!r}: input/output must be integers'), reject empty sides explicitly, and cap the combo count to a sane maximum to bound resource use.
-479
View File
@@ -1,479 +0,0 @@
# Run Config Reference
`run_config.json` is the stable runtime configuration consumed by the GUI,
Python helpers, and C++ pipeline binaries. The active file is normally
`run_config.json`; root-level `*.example.json` files are templates.
JSON does not support comments. Keep notes in docs, not inside config files.
## Top-Level Sections
```json
{
"radar": {},
"switches": {},
"run": {},
"preprocess": {},
"gpr": {},
"rings": {}
}
```
## `radar`
Selects the radar model and sweep settings.
```json
"radar": {
"model": "compact_m_k209",
"serial": "",
"remote_host": "127.0.0.1",
"remote_port": 50209,
"driver_mode": "native",
"mock_signal_hz": 5000000.0,
"multi_device": {},
"kamil_adc": {},
"laser_control": {},
"sweep": {}
}
```
Fields:
| Field | Meaning |
| --- | --- |
| `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`. |
| `laser_control` | Laser board settings applied before `kamil_adc` collection starts. |
| `sweep` | Frequency, point count, IFBW, and power settings. |
### `radar.sweep`
```json
"sweep": {
"start_hz": 1000000.0,
"stop_hz": 6000000000.0,
"points": 201,
"if_bandwidth_hz": 50000.0,
"stimulus_power_dbm": -10.0
}
```
Fields:
| Field | Meaning |
| --- | --- |
| `start_hz` | Sweep start frequency in Hz. |
| `stop_hz` | Sweep stop frequency in Hz. Must be `>= start_hz`. |
| `points` | Number of frequency points. |
| `if_bandwidth_hz` | IF bandwidth in Hz. |
| `stimulus_power_dbm` | Output power in dBm. |
K209 limits reported by the tested device:
```text
frequency_hz: 9000 .. 9000000000
ifbw_hz: 1 .. 300000
power_dbm: -55 .. +5
points: 2 .. 500001
```
### `radar.multi_device`
Used only when `radar.model == "librevna_multi"`.
```json
"multi_device": {
"slave_serials": [
"SLAVE_SERIAL_1",
"SLAVE_SERIAL_2"
],
"force_external_reference": true,
"recovery_attempts": 3
}
```
Fields:
| Field | Meaning |
| --- | --- |
| `slave_serials` | Exactly two slave LibreVNA serials. |
| `force_external_reference` | Configure the synchronized external reference path. |
| `recovery_attempts` | Reopen/retry attempts after native multi-device acquisition errors. |
### `radar.kamil_adc`
Used only when `radar.model == "kamil_adc"`.
```json
"kamil_adc": {
"project_dir": "/home/europa/Documents/kamil_adc",
"executable_path": "/home/europa/Documents/kamil_adc/kamil_adc_capture",
"tty_path": "/tmp/ttyADC_data",
"args": [
"profile:phase",
"clock:internal",
"internal_ref_hz:2000000",
"mode:diff",
"channels:2",
"ch1:2",
"ch2:3",
"do1_toggle_per_frame",
"do1_pair_subtract_avg"
],
"env": {},
"startup_timeout_s": 5.0,
"sweep_timeout_s": 5.0,
"stop_timeout_s": 2.0
}
```
Fields:
| Field | Meaning |
| --- | --- |
| `project_dir` | Working directory for the external ADC collector. Required. |
| `executable_path` | Full path to the Raspberry Pi executable. Required; no filename is assumed. |
| `tty_path` | TTY stream path, for example `/tmp/ttyADC_data`. The producer appends `tty:<tty_path>`. |
| `args` | Explicit collector arguments, excluding any `tty:` argument. |
| `env` | Extra environment variables for the collector process. |
| `startup_timeout_s` | Time allowed for the collector to create a fresh TTY path. |
| `sweep_timeout_s` | Time allowed to receive one full sweep packet. |
| `stop_timeout_s` | Graceful stop timeout before killing the collector process. |
The TTY frame format is strict: packet start is `0x000A 0xFFFF 0xFFFF 0xFFFF`,
then each sweep point is `0x000A step data1 data2`. Steps must arrive as
`1..N`; `N` is derived from the stream when the next packet start arrives.
`radar.sweep.points` is not used by the Kamil ADC producer. `S21` is
`data1 + j*data2`; `S11` is stored as explicit zeros.
### `radar.laser_control`
Used with `kamil_adc` when the laser board must be configured before ADC
collection starts. `laser_control` and `kamil_adc` are one hardware
configuration unit: changing either section requires restarting acquisition.
```json
"laser_control": {
"enabled": true,
"port": "/dev/ttyUSB0",
"mode": "variation",
"pi_coeff1_p": 2560,
"pi_coeff1_i": 128,
"pi_coeff2_p": 2560,
"pi_coeff2_i": 128,
"manual": {
"temp1": 25.0,
"temp2": 25.0,
"current1": 30.0,
"current2": 30.0
},
"variation": {
"variation_type": "CHANGE_CURRENT_LD1",
"static_temp1": 28.0,
"static_temp2": 28.9,
"static_current1": 33.0,
"static_current2": 35.0,
"min_value": 33.0,
"max_value": 60.0,
"step": 0.05,
"time_step": 50,
"delay_time": 10
}
}
```
`mode="manual"` uses `manual`. `mode="variation"` uses `variation`.
`variation_type` is the enum name from `laser_control`, for example
`CHANGE_CURRENT_LD1` or `CHANGE_TEMPERATURE_LD2`.
## `switches`
Two RF switch sections are used:
```json
"switches": {
"port1": {},
"port2": {}
}
```
By convention in the C++ pipeline:
```text
port1 -> output switch
port2 -> input switch
```
Switch fields:
| Field | Meaning |
| --- | --- |
| `name` | Human-readable switch name. |
| `driver_mode` | `native` for GPIO, `mock` to avoid GPIO access. |
| `driver` | `h7992` or `hmc349a`. |
| `radar_port` | Physical radar port mapping, must be unique and either `1` or `2`. |
| `positions` | Number of switch positions. |
| `default_position` | Position selected on open. Zero-based. |
| `gpio_chip` | Linux GPIO chip path, usually `/dev/gpiochip0`. |
| `pin_a` | First GPIO control pin. |
| `pin_b` | Second GPIO control pin for `h7992`. |
| `invert_logic` | Logic inversion for supported switch drivers. |
Use mock switches on a laptop without GPIO:
```json
"driver_mode": "mock"
```
## `control_button`
Optional physical GPIO push-button that triggers a runtime action on press.
The watcher runs in both GUI and headless modes (it is attached to the main
window, which both launch paths build). On press it reuses the existing
"Capture Tmp Reference" flow: stop the pipeline, capture a fresh tmp S21
reference with the current sweep settings, then restart the pipeline if it had
been running.
```json
"control_button": {
"enabled": true,
"gpio_chip": "/dev/gpiochip0",
"pin": 26,
"active_low": true,
"bias": "",
"debounce_ms": 50,
"action": "capture_tmp_reference"
}
```
| Field | Meaning |
| --- | --- |
| `enabled` | Master switch. When `false` (default) no GPIO line is opened, so non-Pi hosts are unaffected. |
| `gpio_chip` | Linux GPIO chip path, usually `/dev/gpiochip0`. |
| `pin` | BCM line offset of the button. `26` is physical pin 37, with GND on physical pin 39. |
| `active_low` | `true` for a button wired to GND with the internal pull-up: the line idles high and a press is detected on the falling edge. `false` mirrors this for a button wired to 3V3 with a pull-down (rising edge). |
| `bias` | Internal bias override: `pull_up`, `pull_down`, or `disabled`. Empty (default) derives the bias from `active_low`. |
| `debounce_ms` | Hardware debounce period applied by the kernel, in milliseconds. |
| `action` | Action to run on press. Currently only `capture_tmp_reference`. |
Occupied BCM lines (native switches) are `17`, `22`, `23`, `27`; pick a free
line such as `16`, `20`, `21`, or `26` for the button. A failure to open the
line (missing chip, line already in use) is logged as a warning and never
aborts startup.
## `run`
Runtime behavior and combo selection.
```json
"run": {
"settling_ms": 0,
"idle_sleep_ms": 2,
"continuous": true,
"processing_live_config_path": "python_app/runtime/processing_live.json",
"locator_server": {},
"combos": [
{"input": 0, "output": 0}
]
}
```
Fields:
| Field | Meaning |
| --- | --- |
| `settling_ms` | Delay after switching before measuring. |
| `idle_sleep_ms` | Sleep between continuous collections. |
| `continuous` | `true` loops until stopped; `false` captures one collection and exits. |
| `processing_live_config_path` | Runtime path used by processing live settings. |
| `locator_server` | Embedded TCP server settings for publishing locator results. |
| `combos` | Zero-based switch combinations to acquire. |
`combos` entries use input/output switch positions:
```json
{"input": 2, "output": 1}
```
For `librevna_multi` and `sn9000`, the model constraints force the canonical
virtual matrix:
```text
input: 0..3
output: 0..1
```
## `run.locator_server`
Settings for the embedded locator result TCP server.
| Field | Meaning |
| --- | --- |
| `device_id` | Device identifier in locator payloads. |
| `protocol_version` | Locator payload protocol version. |
| `host` | Bind host, commonly `0.0.0.0`. |
| `port` | TCP port, commonly `8888`. |
| `max_payload_bytes` | Maximum result payload size. |
| `client_queue_size` | Per-client queue size. |
| `logger_name` | Logger name used by the service. |
## `preprocess`
Names or bundle paths for calibration/reference assets used by preprocessing.
```json
"preprocess": {
"s21": {
"calibration": {"set_name": "", "bundle_path": ""},
"reference": {"set_name": "", "bundle_path": ""}
},
"s11": {
"calibration": {
"open": {"set_name": "", "bundle_path": ""},
"short": {"set_name": "", "bundle_path": ""},
"load": {"set_name": "", "bundle_path": ""}
},
"reference": {"set_name": "", "bundle_path": ""}
},
"notch": {
"enabled": true,
"bands_hz": [],
"taper_width_hz": 40000000.0,
"taper_type": "cosine"
}
}
```
`set_name` selects a stored set for the active radar key. `bundle_path` can
point to an exported bundle. Empty values mean no asset is selected.
`notch.bands_hz` is a list of `[low_hz, high_hz]` ranges. `taper_type` is
`cosine` or `hard`.
## `gpr`
GPR geometry and processing configuration.
```json
"gpr": {
"relative_permittivity": 1.0,
"tx_geometry": [
{"output_pos": 0, "x_m": 0.905}
],
"rx_geometry": [
{"input_pos": 0, "x_m": -0.18}
]
}
```
Fields:
| Field | Meaning |
| --- | --- |
| `relative_permittivity` | Medium relative permittivity used for propagation speed. |
| `tx_geometry` | Transmitter positions keyed by output switch position. |
| `rx_geometry` | Receiver positions keyed by input switch position. |
Geometry positions must match configured switch positions. For example, an
`output_pos` of `1` requires the output switch to have at least 2 positions.
## `rings`
Shared-memory ring endpoints used by native processes.
```json
"rings": {
"raw": {"name": "/radar_raw", "capacity": 50, "slot_size_bytes": 2097152},
"raw_tap": {"name": "/radar_raw_tap", "capacity": 50, "slot_size_bytes": 2097152},
"preprocessed": {"name": "/radar_preprocessed", "capacity": 50, "slot_size_bytes": 2097152},
"preprocessed_tap": {"name": "/radar_preprocessed_tap", "capacity": 50, "slot_size_bytes": 2097152},
"results": {"name": "/radar_results", "capacity": 50, "slot_size_bytes": 2097152}
}
```
Fields:
| Field | Meaning |
| --- | --- |
| `name` | POSIX shared-memory object name. |
| `capacity` | Number of slots. |
| `slot_size_bytes` | Maximum serialized payload size per slot. |
Use unique ring names for parallel tests to avoid collisions with a running GUI
session.
## Minimal Model Examples
Single LibreVNA:
```json
"radar": {
"model": "librevna",
"serial": "",
"driver_mode": "native"
}
```
Multi-device LibreVNA:
```json
"radar": {
"model": "librevna_multi",
"serial": "MASTER_SERIAL",
"driver_mode": "native",
"multi_device": {
"slave_serials": ["SLAVE_1", "SLAVE_2"],
"force_external_reference": true,
"recovery_attempts": 3
}
}
```
Compact-M K209 via remote server:
```json
"radar": {
"model": "compact_m_k209",
"remote_host": "192.168.1.10",
"remote_port": 50209,
"driver_mode": "native"
}
```
SN9000 (PLANAR Иридиум) via SNVNA HiSLIP:
```json
"radar": {
"model": "sn9000",
"remote_host": "192.168.1.10",
"remote_port": 4880,
"driver_mode": "native"
}
```
Kamil ADC:
```json
"radar": {
"model": "kamil_adc",
"serial": "kamil_adc",
"driver_mode": "native",
"kamil_adc": {
"project_dir": "/home/europa/Documents/kamil_adc",
"executable_path": "/home/europa/Documents/kamil_adc/kamil_adc_capture",
"tty_path": "/tmp/ttyADC_data"
},
"laser_control": {
"enabled": true,
"port": "/dev/ttyUSB0",
"mode": "variation"
}
}
```
-140
View File
@@ -1,140 +0,0 @@
# 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).