Rename kamil_adc to rph_radar and fix its data path
This commit is contained in:
@@ -63,64 +63,60 @@ PROCESSOR_SOURCES := \
|
||||
data_acq_and_processing/processing/data_processor/src/main.cpp \
|
||||
$(LOCATOR_SOURCES)
|
||||
|
||||
# Kamil ADC collector: standalone L-Card E-502 acquisition binary. It loads the
|
||||
# RadioPhotonic radar (RPH) collector: application-owned L-Card E-502 acquisition binary. It loads the
|
||||
# proprietary libx502api/libe502api at runtime via dlopen, so it builds anywhere
|
||||
# from the vendored headers alone and only needs those libraries present at run
|
||||
# time. Built on demand (not part of `all`) for radar.model == kamil_adc.
|
||||
KAMIL_COLLECTOR_DIR := data_acq_and_processing/kamil_adc_collector
|
||||
# The collector now also drives the RF switches itself (switch-aware mode), so it
|
||||
# uses the common H7992 driver and run_config loader, which
|
||||
# pull in the common config/ipc/locator headers and the vendored nlohmann/json.
|
||||
KAMIL_COLLECTOR_INCLUDES := \
|
||||
-I$(KAMIL_COLLECTOR_DIR)/include \
|
||||
-I$(KAMIL_COLLECTOR_DIR)/vendor/lcard \
|
||||
# time. Built on demand (not part of `all`) for radar.model == rph_radar.
|
||||
RPH_COLLECTOR_DIR := data_acq_and_processing/rph_radar_collector
|
||||
# The collector drives H7992 switches from the validated JSON supplied by Python.
|
||||
RPH_COLLECTOR_INCLUDES := \
|
||||
-I$(RPH_COLLECTOR_DIR)/include \
|
||||
-I$(RPH_COLLECTOR_DIR)/vendor/lcard \
|
||||
-Idata_acq_and_processing/common_cpp/ipc/include \
|
||||
-Idata_acq_and_processing/common_cpp/config/include \
|
||||
-Idata_acq_and_processing/common_cpp/hardware/include \
|
||||
-Idata_acq_and_processing/processing/locator/include \
|
||||
-Idata_acq_and_processing/third_party
|
||||
KAMIL_COLLECTOR_LDFLAGS := -pthread -ldl -lutil
|
||||
# Collector sources, including the shared drivers/config loader it reuses. These
|
||||
# are compiled into a private build/kamil/ tree (see rule below) so they never
|
||||
RPH_COLLECTOR_LDFLAGS := -pthread -ldl -lutil
|
||||
# Collector sources, including the shared H7992 driver. These
|
||||
# are compiled into a private build/rph/ tree (see rule below) so they never
|
||||
# collide with pipeline objects, which use different include paths.
|
||||
KAMIL_COLLECTOR_SOURCES := \
|
||||
$(KAMIL_COLLECTOR_DIR)/src/main.cpp \
|
||||
$(KAMIL_COLLECTOR_DIR)/src/tty_protocol_writer.cpp \
|
||||
$(KAMIL_COLLECTOR_DIR)/src/capture_file_writer.cpp \
|
||||
data_acq_and_processing/common_cpp/config/src/run_config.cpp \
|
||||
data_acq_and_processing/common_cpp/ipc/src/shared_types.cpp \
|
||||
RPH_COLLECTOR_SOURCES := \
|
||||
$(RPH_COLLECTOR_DIR)/src/main.cpp \
|
||||
$(RPH_COLLECTOR_DIR)/src/tty_protocol_writer.cpp \
|
||||
data_acq_and_processing/common_cpp/hardware/src/h7992_minimal_driver.cpp
|
||||
|
||||
PREPROCESSOR_OBJS := $(addprefix $(BUILD_DIR)/,$(COMMON_SOURCES:.cpp=.o) $(PREPROC_SOURCES:.cpp=.o))
|
||||
DATA_PROCESSOR_OBJS := $(addprefix $(BUILD_DIR)/,$(COMMON_SOURCES:.cpp=.o) $(PROCESSOR_SOURCES:.cpp=.o))
|
||||
KAMIL_BUILD_DIR := $(BUILD_DIR)/kamil
|
||||
KAMIL_COLLECTOR_OBJS := $(addprefix $(KAMIL_BUILD_DIR)/,$(KAMIL_COLLECTOR_SOURCES:.cpp=.o))
|
||||
DEPFILES := $(sort $(PREPROCESSOR_OBJS:.o=.d) $(DATA_PROCESSOR_OBJS:.o=.d) $(KAMIL_COLLECTOR_OBJS:.o=.d))
|
||||
RPH_BUILD_DIR := $(BUILD_DIR)/rph
|
||||
RPH_COLLECTOR_OBJS := $(addprefix $(RPH_BUILD_DIR)/,$(RPH_COLLECTOR_SOURCES:.cpp=.o))
|
||||
DEPFILES := $(sort $(PREPROCESSOR_OBJS:.o=.d) $(DATA_PROCESSOR_OBJS:.o=.d) $(RPH_COLLECTOR_OBJS:.o=.d))
|
||||
|
||||
NATIVE_TEST_SOURCES := $(COMMON_SOURCES) $(LOCATOR_SOURCES) \
|
||||
data_acq_and_processing/tests/native_regressions.cpp \
|
||||
data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp \
|
||||
$(filter-out %/main.cpp,$(PREPROC_SOURCES)) \
|
||||
$(KAMIL_COLLECTOR_DIR)/src/capture_file_writer.cpp \
|
||||
$(KAMIL_COLLECTOR_DIR)/src/tty_protocol_writer.cpp
|
||||
$(RPH_COLLECTOR_DIR)/src/tty_protocol_writer.cpp \
|
||||
data_acq_and_processing/common_cpp/hardware/src/h7992_minimal_driver.cpp
|
||||
NATIVE_BUILD_DIR := $(BUILD_DIR)/native_tests
|
||||
NATIVE_TEST_OBJS := $(addprefix $(NATIVE_BUILD_DIR)/,$(NATIVE_TEST_SOURCES:.cpp=.o))
|
||||
NATIVE_CXXFLAGS := $(CXXFLAGS) -O1 -g $(if $(NATIVE_SANITIZERS),-fsanitize=$(NATIVE_SANITIZERS) -fno-omit-frame-pointer)
|
||||
NATIVE_FLAGS_STAMP := $(NATIVE_BUILD_DIR)/compiler.flags
|
||||
$(shell mkdir -p $(NATIVE_BUILD_DIR) && echo '$(NATIVE_CXXFLAGS)' | cmp -s - $(NATIVE_FLAGS_STAMP) || echo '$(NATIVE_CXXFLAGS)' > $(NATIVE_FLAGS_STAMP))
|
||||
DEPFILES += $(NATIVE_TEST_OBJS:.o=.d)
|
||||
DEPFILES += $(NATIVE_TEST_OBJS:.o=.d) $(RPH_BUILD_DIR)/data_acq_and_processing/tests/collector_regressions.d
|
||||
|
||||
TARGETS := \
|
||||
$(BIN_DIR)/data_preprocessor \
|
||||
$(BIN_DIR)/data_processor
|
||||
|
||||
.PHONY: all clean kamil_adc_collector check-native check-sar3d
|
||||
.PHONY: all clean rph_radar_collector check-native check-sar3d
|
||||
|
||||
all: $(TARGETS)
|
||||
|
||||
kamil_adc_collector: $(BIN_DIR)/kamil_adc_collector
|
||||
rph_radar_collector: $(BIN_DIR)/rph_radar_collector
|
||||
|
||||
check-native: $(NATIVE_BUILD_DIR)/native_regressions
|
||||
check-native: $(NATIVE_BUILD_DIR)/native_regressions $(BIN_DIR)/collector_regressions $(NATIVE_BUILD_DIR)/rph_sdk/libx502api.so
|
||||
LD_LIBRARY_PATH=$(abspath $(NATIVE_BUILD_DIR)/rph_sdk) $(BIN_DIR)/collector_regressions
|
||||
$(NATIVE_BUILD_DIR)/native_regressions run_config_examples/run_config_simulator.example.json
|
||||
|
||||
SAR3D_TEST_SOURCES := data_acq_and_processing/tests/sar3d_fixture.cpp \
|
||||
@@ -146,7 +142,7 @@ $(NATIVE_BUILD_DIR)/native_regressions: $(NATIVE_TEST_OBJS)
|
||||
|
||||
$(NATIVE_BUILD_DIR)/%.o: %.cpp $(NATIVE_FLAGS_STAMP)
|
||||
@mkdir -p $(dir $@)
|
||||
$(CXX) $(NATIVE_CXXFLAGS) $(INCLUDES) -I$(KAMIL_COLLECTOR_DIR)/include -c $< -o $@
|
||||
$(CXX) $(NATIVE_CXXFLAGS) $(INCLUDES) -Idata_acq_and_processing/common_cpp/hardware/include -I$(RPH_COLLECTOR_DIR)/include -c $< -o $@
|
||||
|
||||
$(BIN_DIR)/data_preprocessor: $(PREPROCESSOR_OBJS)
|
||||
@mkdir -p $(BIN_DIR)
|
||||
@@ -161,13 +157,24 @@ $(BUILD_DIR)/data_acq_and_processing/processing/processors/src/sar3d_gpu.o: CXXF
|
||||
$(BUILD_DIR)/data_acq_and_processing/processing/processors/src/sar3d_gpu.o: $(SAR3D_CUDA_STAMP)
|
||||
|
||||
# Keep source-relative paths so moving a source also replaces its dependencies.
|
||||
$(KAMIL_BUILD_DIR)/%.o: %.cpp
|
||||
$(RPH_BUILD_DIR)/%.o: %.cpp
|
||||
@mkdir -p $(dir $@)
|
||||
$(CXX) $(CXXFLAGS) $(KAMIL_COLLECTOR_INCLUDES) -c $< -o $@
|
||||
$(CXX) $(CXXFLAGS) $(RPH_COLLECTOR_INCLUDES) -c $< -o $@
|
||||
|
||||
$(BIN_DIR)/kamil_adc_collector: $(KAMIL_COLLECTOR_OBJS)
|
||||
$(RPH_BUILD_DIR)/data_acq_and_processing/tests/collector_regressions.o: data_acq_and_processing/tests/collector_regressions.cpp
|
||||
|
||||
$(NATIVE_BUILD_DIR)/rph_sdk/libx502api.so: data_acq_and_processing/tests/rph_sdk_fixture.cpp
|
||||
@mkdir -p $(@D)
|
||||
$(CXX) -std=c++20 -shared -fPIC -I$(RPH_COLLECTOR_DIR)/vendor/lcard $< -o $@
|
||||
ln -sf libx502api.so $(@D)/libe502api.so
|
||||
|
||||
$(BIN_DIR)/collector_regressions: $(RPH_BUILD_DIR)/data_acq_and_processing/tests/collector_regressions.o $(filter-out %/main.o,$(RPH_COLLECTOR_OBJS))
|
||||
@mkdir -p $(BIN_DIR)
|
||||
$(CXX) $(KAMIL_COLLECTOR_OBJS) -o $@ $(KAMIL_COLLECTOR_LDFLAGS)
|
||||
$(CXX) $^ -o $@ $(RPH_COLLECTOR_LDFLAGS)
|
||||
|
||||
$(BIN_DIR)/rph_radar_collector: $(RPH_COLLECTOR_OBJS)
|
||||
@mkdir -p $(BIN_DIR)
|
||||
$(CXX) $(RPH_COLLECTOR_OBJS) -o $@ $(RPH_COLLECTOR_LDFLAGS)
|
||||
|
||||
$(BUILD_DIR)/%.o: %.cpp
|
||||
@mkdir -p $(dir $@)
|
||||
|
||||
@@ -19,7 +19,7 @@ skips overwritten frames and continues from the retained tail:
|
||||
|
||||
1. **Acquisition** — Python producers configure the radar, acquire frequency sweeps,
|
||||
and publish raw S21 traces. Both single and multi-device LibreVNA use
|
||||
`matrix_raw_producer`; Kamil ADC uses `kamil_adc_raw_producer` with its C++ collector.
|
||||
`matrix_raw_producer`; RadioPhotonic radar (RPH) uses `rph_radar_raw_producer` with its C++ collector.
|
||||
2. **Preprocessing** — `data_preprocessor` applies through calibration,
|
||||
reference subtraction, optional frequency-domain notch filtering, and an
|
||||
optional object-based MIMO correction of S21.
|
||||
@@ -45,8 +45,11 @@ controls, processing settings and a copy of the live plot through an embedded we
|
||||
|
||||
- **librevna** — single LibreVNA.
|
||||
- **librevna_multi** — synchronized multi-device LibreVNA.
|
||||
- **kamil_adc** — [Kamil ADC collector](data_acq_and_processing/kamil_adc_collector/README.md),
|
||||
with optional laser control and [temperature monitoring](python_app/hardware_full/laser_control/monitoring/README.md).
|
||||
- **rph_radar** — [RadioPhotonic radar (RPH) collector](data_acq_and_processing/rph_radar_collector/README.md),
|
||||
with manual laser setpoints or LD1 current variation via **Apply Radar**.
|
||||
The application owns the collector and exchanges complete sweeps over a PTY.
|
||||
Switching to a different antenna pair starts a fresh ADC session after settling;
|
||||
queued samples and an initial partial sweep are discarded before using that pair.
|
||||
- **mock / simulator** — driver-less mode for development without hardware.
|
||||
|
||||
## Processing modes
|
||||
@@ -86,7 +89,7 @@ so an older in-flight result cannot overwrite new geometry.
|
||||
|
||||
| Path | Contents |
|
||||
| --- | --- |
|
||||
| `data_acq_and_processing/` | C++ pipeline: `preprocessing/`, `processing/` (including `locator/`), `kamil_adc_collector/`, shared `common_cpp/` (config, IPC rings, H7992 driver), and vendored `third_party/` (Eigen, nlohmann/json). |
|
||||
| `data_acq_and_processing/` | C++ pipeline: `preprocessing/`, `processing/` (including `locator/`), `rph_radar_collector/`, shared `common_cpp/` (config, IPC rings, H7992 driver), and vendored `third_party/` (Eigen, nlohmann/json). |
|
||||
| `python_app/` | Python application: `gui/`, `orchestration/` (process supervisor, shared-memory readers, config), `hardware_full/` (device drivers and services), `webui/`, `storage/`, `models/`, `workflows/`, `scripts/` (producers and tools), and `tests/`. |
|
||||
| `run_config_examples/` | Ready-made configuration profiles, one per hardware setup. |
|
||||
| `deploy/` | systemd daemon installer, and the USB update kit for the Pi and Jetson (`pack-usb.sh`, `update.sh`). |
|
||||
@@ -96,8 +99,7 @@ so an older in-flight result cannot overwrite new geometry.
|
||||
## Requirements
|
||||
|
||||
- Linux (POSIX shared memory; GPIO on Raspberry Pi). The deployment target is a
|
||||
**Raspberry Pi 5**; hardware PWM gating for the generator sweep is Pi 5
|
||||
specific (GPIO12/13/18/19, `dtoverlay=pwm-2chan`).
|
||||
**Raspberry Pi 5**.
|
||||
- A C++20 compiler and `make`; Eigen and nlohmann/json are vendored.
|
||||
- `libusb-1.0` runtime and `libatomic.so.1` (`libatomic1` on Debian/Ubuntu).
|
||||
Python ring readers/writers use lock-free atomic loads, stores and cursor CAS
|
||||
@@ -121,7 +123,7 @@ python3 -m venv .venv
|
||||
.venv/bin/python -m pip install --upgrade pip
|
||||
.venv/bin/python -m pip install -r requirements.txt
|
||||
make # data_preprocessor and data_processor in build/bin/
|
||||
make kamil_adc_collector # also required for Kamil ADC acquisition
|
||||
make rph_radar_collector # also required for RadioPhotonic radar (RPH) acquisition
|
||||
```
|
||||
|
||||
`make` builds the sar3d GPU code when it finds CUDA (see Requirements) and
|
||||
@@ -163,7 +165,7 @@ not need to be replaced.
|
||||
|
||||
The default `run_config.json` already sets `radar.driver_mode: "mock"`, so the
|
||||
producer generates synthetic S-parameters and no radar is needed. Mock acquisition
|
||||
supports the LibreVNA models; Kamil ADC needs native acquisition.
|
||||
supports the LibreVNA models; RadioPhotonic radar (RPH) needs native acquisition.
|
||||
Both LibreVNA modes share one simulator: a point reflector moves back and forth
|
||||
along the antenna array and between depths 3.25–4.25 m over an eight-second cycle.
|
||||
Each S21 uses the actual transmitter–target–receiver distance from the configured
|
||||
@@ -250,9 +252,9 @@ are never overwritten; missing raw/preprocessed counterparts produce empty array
|
||||
and stages pair only by the exact acquisition key. Its `vna_config_profile.json` companion
|
||||
leaves the NumPy dataset's `config_profile.json` intact. A streaming write failure
|
||||
keeps all collections already published; only the incomplete collection is discarded.
|
||||
The offline snapshot converter uses this same exporter. Legacy snapshots without
|
||||
capture windows retain repeated IDs through one-to-one time/index matching;
|
||||
processing completion timestamps are not compared with acquisition timestamps.
|
||||
Legacy snapshots without capture windows retain repeated IDs through one-to-one
|
||||
time/index matching; processing completion timestamps are not compared with
|
||||
acquisition timestamps.
|
||||
|
||||
### Calibration and reference sets
|
||||
|
||||
@@ -342,8 +344,38 @@ back to the root profile. **Load Config** and **Save Config** manage desktop pro
|
||||
|
||||
Hardware examples are in `run_config_examples/`. The root `run_config.json` is
|
||||
intentionally mock; select a hardware profile explicitly on a real device. A
|
||||
change of model, serials or sweep parameters changes the `radar_key` used to find
|
||||
calibration/reference sets; select or capture matching sets before **Start**.
|
||||
change of model, serials, sweep parameters or multi-device switch positions changes
|
||||
the `radar_key` used to find calibration/reference sets; select or capture matching
|
||||
sets before **Start**. Changing the multi-device reference-clock source also resets
|
||||
display history. For RadioPhotonic radar (RPH), identity follows the phase anchors, output
|
||||
band and acquisition/laser settings; executable/TTY paths and LibreVNA-only sweep
|
||||
fields do not affect it. RPH processing frequency controls use its output band.
|
||||
Phase, band and timeout errors are rejected before a running pipeline is stopped.
|
||||
|
||||
Profiles with model `kamil_adc` are rejected with an explicit error: use
|
||||
`rph_radar` and the `radar.rph_radar` section. An inactive `radar.kamil_adc`
|
||||
section left in older saved profiles is ignored; there are no aliases or automatic
|
||||
data migrations. Old RPH calibration/reference sets use a different
|
||||
measurement key and are not selected by the renamed mode.
|
||||
|
||||
### RPH phase calibration
|
||||
|
||||
Use `run_config_examples/run_config_rph_radar.example.json` for **RadioPhotonic radar (RPH)**.
|
||||
Apply the desired laser settings with **Apply Radar**, then stop acquisition before
|
||||
running the calibration tool:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m python_app.scripts.rph_radar_calibrate --config run_config.json --sweeps 200
|
||||
.venv/bin/python -m python_app.scripts.rph_radar_calibrate --config run_config.json --sweeps 200 --apply
|
||||
```
|
||||
|
||||
Pass the path to your active RPH profile. The first command reports measured phase
|
||||
anchors; `--apply` captures again and writes only `phase0_rad` and `phase1_rad` in
|
||||
`radar.rph_radar.phase_calibration`. Frequency anchors must already describe the
|
||||
known sweep endpoints. The tool uses current acquisition settings, sends no laser
|
||||
commands, preserves every other JSON byte, and refuses to overwrite a profile
|
||||
edited during capture. Band and point count stay unchanged. `--warmup` controls
|
||||
how many initial sweeps to discard (default 10).
|
||||
|
||||
## Deployment
|
||||
|
||||
@@ -394,7 +426,7 @@ ASAN_OPTIONS=detect_leaks=0 make check-native NATIVE_SANITIZERS=address,undefine
|
||||
The tests cover failed writes and settings commits, recordings during manual Save,
|
||||
repeated Start/Stop/Close, web-action cancellation, malformed IPC frames, hardware
|
||||
resource cleanup with mock failures, and an isolated deployment update. Native tests
|
||||
also exercise mutated frames, ring concurrency, locator input and ADC spool recovery.
|
||||
also exercise mutated frames, ring concurrency, locator input and ADC whole-sweep PTY overflow recovery.
|
||||
Firmware host tests check cart timer rollover and SBUS packing; they do not validate
|
||||
physical timing. Real devices, GPIO and the CUDA backend need their own hardware run.
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
# Kamil ADC collector
|
||||
|
||||
Standalone acquisition binary for the **L-Card E-502** ADC. It captures the
|
||||
dual-channel main + reference IQ stream and publishes it as 8-byte framed packets
|
||||
over a PTY. `KamilAdcService` in `python_app/hardware_full/kamil_adc` reads and
|
||||
processes that stream for `python_app.scripts.kamil_adc_raw_producer`.
|
||||
|
||||
This is the data *source* for `radar.model == "kamil_adc"`, paired with the Python
|
||||
`kamil_adc_raw_producer`. LibreVNA acquisition uses Python drivers.
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | Contents |
|
||||
|------|----------|
|
||||
| `src/` | Collector implementation (`main.cpp`) and the PTY / capture-file writers. |
|
||||
| `include/` | Headers for the writers plus `switch_sequencer.h`. |
|
||||
| `vendor/lcard/` | L-Card X502/E502 SDK headers (`x502api.h`, `e502api.h`, `lcard_pstdint.h`), vendored verbatim so the collector builds with no external include path. |
|
||||
|
||||
In switch-aware mode, `SwitchSequencer` drives the RF switches, stepping to the
|
||||
next combination inside the hardware idle gap between sweeps and flagging a
|
||||
sweep *dirty* when a readout backlog means the toggle could not have landed in
|
||||
that gap. The Python reader
|
||||
drops dirty sweeps; the collector repeats that combination without another
|
||||
switch transition. The collector uses the common H7992 driver and `run_config`
|
||||
loader, with its own include set and `build/kamil/` object tree.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
make kamil_adc_collector # -> build/bin/kamil_adc_collector
|
||||
```
|
||||
|
||||
It is **not** part of `make all`; `start.sh` builds it automatically when the
|
||||
active config selects `radar.model == "kamil_adc"`, unless `--skip-build` is set.
|
||||
The Linux build uses the repository's common sources and vendored headers and
|
||||
links with `-pthread -ldl -lutil`; no installed L-Card SDK is needed to build it.
|
||||
|
||||
## Runtime
|
||||
|
||||
The collector loads the proprietary `libx502api.so` / `libe502api.so` at run time
|
||||
via `dlopen`, so those libraries must be discoverable by the dynamic loader.
|
||||
The Kamil ADC service prepends `~/.local/lib` (their default install location)
|
||||
unless the config pins `LD_LIBRARY_PATH` in `radar.kamil_adc.env`.
|
||||
|
||||
The command-line arguments (capture profile, clocking, the `do8_freq_ref`
|
||||
reference overlay, etc.) are supplied by `radar.kamil_adc.args` in the run config;
|
||||
the service appends `tty:<path>` from `radar.kamil_adc.tty_path`.
|
||||
|
||||
When both RF switches use `driver_mode: "native"`, the producer also passes
|
||||
`config:<path>` to the collector. After each sweep boundary, the collector emits
|
||||
an 8-byte combo tag `[0x00C0, input_pos, output_pos, dirty]` (four little-endian
|
||||
16-bit words). The producer accepts only clean sweeps for the requested combo.
|
||||
Otherwise, the producer selects and settles the switches itself; the collector
|
||||
runs without `config:<path>` and emits no combo tags. Calibration captures also
|
||||
use this Python-controlled path.
|
||||
@@ -1,53 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
struct CapturePacket {
|
||||
std::size_t packet_index = 0;
|
||||
std::size_t channel_count = 2;
|
||||
bool has_di1_trace = false;
|
||||
std::vector<double> ch1;
|
||||
std::vector<double> ch2;
|
||||
std::vector<uint8_t> di1;
|
||||
};
|
||||
|
||||
class CaptureFileWriter {
|
||||
public:
|
||||
CaptureFileWriter(std::string csv_path,
|
||||
std::string svg_path,
|
||||
std::string live_html_path,
|
||||
std::string live_json_path);
|
||||
|
||||
void write(const std::vector<CapturePacket>& packets,
|
||||
double frame_freq_hz,
|
||||
double nominal_range_v) const;
|
||||
|
||||
void initialize_live_plot() const;
|
||||
void initialize_csv(std::size_t channel_count, bool has_di1_trace) const;
|
||||
void append_csv_packet(const CapturePacket& packet) const;
|
||||
|
||||
void update_live_plot(const CapturePacket& packet,
|
||||
std::size_t packets_seen,
|
||||
double packets_per_second,
|
||||
double frame_freq_hz,
|
||||
const std::string& close_reason,
|
||||
std::size_t zeroed_samples,
|
||||
std::size_t stored_samples) const;
|
||||
|
||||
const std::string& live_html_path() const;
|
||||
const std::string& live_json_path() const;
|
||||
|
||||
private:
|
||||
void finalize_csv_from_spool(double frame_freq_hz) const;
|
||||
void write_svg(const std::vector<CapturePacket>& packets,
|
||||
double frame_freq_hz,
|
||||
double nominal_range_v) const;
|
||||
|
||||
std::string csv_path_;
|
||||
std::string svg_path_;
|
||||
std::string live_html_path_;
|
||||
std::string live_json_path_;
|
||||
};
|
||||
@@ -1,146 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "shared_types.hpp"
|
||||
#include "h7992_minimal_driver.hpp"
|
||||
|
||||
namespace radar::kamil {
|
||||
|
||||
/**
|
||||
* @brief Drives the input/output RF switches in lock-step with sweep boundaries.
|
||||
*
|
||||
* The E-502 stream is free-running: sweeps are delimited by DI_SYN2 edges with a
|
||||
* hardware idle gap between them. On every completed sweep we step to the next
|
||||
* switch combination *during that gap*, so the next sweep starts already settled
|
||||
* in the new state and no samples are lost.
|
||||
*
|
||||
* The one failure mode of doing this purely in software is the readout backlog:
|
||||
* if, at the moment we observe a sweep's end, we are running so far behind real
|
||||
* time that the toggle could not have landed within the gap, the upcoming sweep
|
||||
* straddles the transition. We flag that sweep *dirty*; the consumer drops it and
|
||||
* we re-take the same combination on the following sweep (which is then clean,
|
||||
* because no transition happens during its gap). A clean sweep costs nothing; a
|
||||
* miss costs exactly one extra sweep period for that one combination.
|
||||
*
|
||||
* The sequencer owns the two switch drivers and exposes only what the collector
|
||||
* needs at a sweep boundary: which combination the upcoming sweep belongs to and
|
||||
* whether it is expected to be clean. All timing policy lives here so the recv
|
||||
* loop stays readable.
|
||||
*/
|
||||
class SwitchSequencer {
|
||||
public:
|
||||
struct Settings {
|
||||
/// Combinations to cycle through, one sweep each, in order. Empty disables
|
||||
/// the sequencer entirely (the collector then streams a single passthrough
|
||||
/// channel exactly as before).
|
||||
std::vector<radar::ipc::ComboKey> combos{};
|
||||
/// Guaranteed minimum inter-sweep idle window, in milliseconds. This is a
|
||||
/// hardware property of the sweep generator; treat it as a lower bound.
|
||||
double gap_ms = 10.0;
|
||||
/// RF settle time required after a position change, in milliseconds.
|
||||
double settle_ms = 1.0;
|
||||
};
|
||||
|
||||
SwitchSequencer(
|
||||
Settings settings,
|
||||
std::unique_ptr<drivers::H7992MinimalDriver> input_switch,
|
||||
std::unique_ptr<drivers::H7992MinimalDriver> output_switch
|
||||
)
|
||||
: settings_(std::move(settings)),
|
||||
input_switch_(std::move(input_switch)),
|
||||
output_switch_(std::move(output_switch)) {}
|
||||
|
||||
[[nodiscard]] auto enabled() const -> bool { return !settings_.combos.empty(); }
|
||||
|
||||
/// Open both switch drivers and park at the first combination.
|
||||
void open() {
|
||||
if (!enabled()) {
|
||||
return;
|
||||
}
|
||||
output_switch_->open();
|
||||
input_switch_->open();
|
||||
index_ = 0;
|
||||
applied_ = settings_.combos.front();
|
||||
apply(applied_);
|
||||
upcoming_dirty_ = false; // the first sweep is captured in a settled state
|
||||
}
|
||||
|
||||
void close() {
|
||||
if (!enabled()) {
|
||||
return;
|
||||
}
|
||||
// Best-effort: teardown must never throw out of the collector's stop path.
|
||||
try {
|
||||
output_switch_->close();
|
||||
} catch (...) {
|
||||
}
|
||||
try {
|
||||
input_switch_->close();
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Combination the in-progress / upcoming sweep is captured under.
|
||||
[[nodiscard]] auto current_combo() const -> radar::ipc::ComboKey {
|
||||
return settings_.combos[index_];
|
||||
}
|
||||
|
||||
/// Whether the in-progress / upcoming sweep is expected to straddle a switch
|
||||
/// transition and must be dropped by the consumer.
|
||||
[[nodiscard]] auto current_dirty() const -> bool { return upcoming_dirty_; }
|
||||
|
||||
/**
|
||||
* @brief Advance the sequence at a completed sweep's end (start of the gap).
|
||||
*
|
||||
* @param backlog_ms Estimated readout lag at this instant — how far behind
|
||||
* real time we are, i.e. how late the toggle we issue now will land.
|
||||
*
|
||||
* If the sweep that just ended was clean we step to the next combination and
|
||||
* toggle the switches now, inside the gap. If it was dirty we re-take the same
|
||||
* combination (the switches are already there and long settled). The upcoming
|
||||
* sweep is marked dirty only when a real transition happens *and* the backlog
|
||||
* leaves no room for it to settle before the next sweep starts.
|
||||
*/
|
||||
void on_sweep_end(double backlog_ms) {
|
||||
if (!enabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (upcoming_dirty_) {
|
||||
// The sweep that just ended straddled a transition: re-take the same
|
||||
// combination. The switches are already applied and settled, so the
|
||||
// next sweep is clean without touching the hardware again.
|
||||
upcoming_dirty_ = false;
|
||||
return;
|
||||
}
|
||||
|
||||
index_ = (index_ + 1U) % settings_.combos.size();
|
||||
const radar::ipc::ComboKey next = settings_.combos[index_];
|
||||
const bool position_changed = !(next == applied_);
|
||||
if (position_changed) {
|
||||
apply(next);
|
||||
applied_ = next;
|
||||
}
|
||||
upcoming_dirty_ = position_changed && ((backlog_ms + settings_.settle_ms) > settings_.gap_ms);
|
||||
}
|
||||
|
||||
private:
|
||||
void apply(const radar::ipc::ComboKey& combo) {
|
||||
// Set the transmitter before the receiver.
|
||||
output_switch_->switch_to(combo.output_pos);
|
||||
input_switch_->switch_to(combo.input_pos);
|
||||
}
|
||||
|
||||
Settings settings_;
|
||||
std::unique_ptr<drivers::H7992MinimalDriver> input_switch_;
|
||||
std::unique_ptr<drivers::H7992MinimalDriver> output_switch_;
|
||||
std::size_t index_ = 0;
|
||||
radar::ipc::ComboKey applied_{};
|
||||
bool upcoming_dirty_ = false;
|
||||
};
|
||||
|
||||
} // namespace radar::kamil
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,469 +0,0 @@
|
||||
#include "tty_protocol_writer.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <exception>
|
||||
#include <stdexcept>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
struct TtyProtocolWriter::Impl {};
|
||||
|
||||
TtyProtocolWriter::TtyProtocolWriter(std::string path, std::size_t ring_capacity_bytes)
|
||||
: path_(std::move(path)) {
|
||||
(void) ring_capacity_bytes;
|
||||
throw std::runtime_error("tty output is supported only on Linux/POSIX");
|
||||
}
|
||||
|
||||
TtyProtocolWriter::~TtyProtocolWriter() = default;
|
||||
|
||||
void TtyProtocolWriter::emit_packet_start(uint16_t marker) {
|
||||
(void) marker;
|
||||
}
|
||||
|
||||
void TtyProtocolWriter::emit_step(uint16_t index, int16_t ch1_avg, int16_t ch2_avg) {
|
||||
(void) index;
|
||||
(void) ch1_avg;
|
||||
(void) ch2_avg;
|
||||
}
|
||||
|
||||
void TtyProtocolWriter::enqueue_encoded_frames(const uint16_t* words, std::size_t frame_count) {
|
||||
(void) words;
|
||||
(void) frame_count;
|
||||
}
|
||||
|
||||
TtyProtocolWriter::StatsSnapshot TtyProtocolWriter::stats() const {
|
||||
return {};
|
||||
}
|
||||
|
||||
const std::string& TtyProtocolWriter::path() const {
|
||||
return path_;
|
||||
}
|
||||
|
||||
void TtyProtocolWriter::throw_if_failed() const {}
|
||||
|
||||
void TtyProtocolWriter::shutdown() {}
|
||||
|
||||
void TtyProtocolWriter::enqueue_frame(uint16_t word0, uint16_t word1, uint16_t word2, uint16_t word3) {
|
||||
(void) word0;
|
||||
(void) word1;
|
||||
(void) word2;
|
||||
(void) word3;
|
||||
}
|
||||
|
||||
void TtyProtocolWriter::worker_loop() {}
|
||||
|
||||
#else
|
||||
|
||||
#include <cerrno>
|
||||
#include <condition_variable>
|
||||
#include <fcntl.h>
|
||||
#include <limits.h>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <pty.h>
|
||||
#include <sstream>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <termios.h>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr std::size_t kFrameWordCount = 4U;
|
||||
constexpr std::size_t kFrameByteCount = kFrameWordCount * sizeof(uint16_t);
|
||||
constexpr std::size_t kWriteBatchFrames = 256U;
|
||||
|
||||
using EncodedFrame = std::array<std::uint8_t, kFrameByteCount>;
|
||||
|
||||
std::string io_error(const std::string& action, const std::string& path) {
|
||||
std::ostringstream out;
|
||||
out << action << " '" << path << "': " << std::strerror(errno);
|
||||
return out.str();
|
||||
}
|
||||
|
||||
void close_fd_if_open(int& fd) noexcept {
|
||||
if (fd >= 0) {
|
||||
::close(fd);
|
||||
fd = -1;
|
||||
}
|
||||
}
|
||||
|
||||
void set_fd_raw(int fd) {
|
||||
struct termios tio {};
|
||||
if (::tcgetattr(fd, &tio) != 0) {
|
||||
throw std::runtime_error(io_error("Cannot read tty attributes for", std::to_string(fd)));
|
||||
}
|
||||
::cfmakeraw(&tio);
|
||||
tio.c_cc[VINTR] = _POSIX_VDISABLE;
|
||||
tio.c_cc[VQUIT] = _POSIX_VDISABLE;
|
||||
tio.c_cc[VERASE] = _POSIX_VDISABLE;
|
||||
tio.c_cc[VKILL] = _POSIX_VDISABLE;
|
||||
tio.c_cc[VEOF] = _POSIX_VDISABLE;
|
||||
tio.c_cc[VTIME] = 0;
|
||||
tio.c_cc[VMIN] = 1;
|
||||
#ifdef VSWTC
|
||||
tio.c_cc[VSWTC] = _POSIX_VDISABLE;
|
||||
#endif
|
||||
tio.c_cc[VSTART] = _POSIX_VDISABLE;
|
||||
tio.c_cc[VSTOP] = _POSIX_VDISABLE;
|
||||
tio.c_cc[VSUSP] = _POSIX_VDISABLE;
|
||||
#ifdef VEOL
|
||||
tio.c_cc[VEOL] = _POSIX_VDISABLE;
|
||||
#endif
|
||||
#ifdef VREPRINT
|
||||
tio.c_cc[VREPRINT] = _POSIX_VDISABLE;
|
||||
#endif
|
||||
#ifdef VDISCARD
|
||||
tio.c_cc[VDISCARD] = _POSIX_VDISABLE;
|
||||
#endif
|
||||
#ifdef VWERASE
|
||||
tio.c_cc[VWERASE] = _POSIX_VDISABLE;
|
||||
#endif
|
||||
#ifdef VLNEXT
|
||||
tio.c_cc[VLNEXT] = _POSIX_VDISABLE;
|
||||
#endif
|
||||
#ifdef VEOL2
|
||||
tio.c_cc[VEOL2] = _POSIX_VDISABLE;
|
||||
#endif
|
||||
if (::tcsetattr(fd, TCSANOW, &tio) != 0) {
|
||||
throw std::runtime_error(io_error("Cannot apply raw tty attributes to", std::to_string(fd)));
|
||||
}
|
||||
}
|
||||
|
||||
bool is_character_device_path(const std::string& path) {
|
||||
struct stat st {};
|
||||
if (::stat(path.c_str(), &st) != 0) {
|
||||
if (errno == ENOENT) {
|
||||
return false;
|
||||
}
|
||||
throw std::runtime_error(io_error("Cannot stat tty output", path));
|
||||
}
|
||||
return S_ISCHR(st.st_mode);
|
||||
}
|
||||
|
||||
std::optional<std::string> read_link_target(const std::string& path) {
|
||||
std::array<char, PATH_MAX> buf {};
|
||||
const ssize_t len = ::readlink(path.c_str(), buf.data(), buf.size() - 1U);
|
||||
if (len < 0) {
|
||||
if (errno == EINVAL || errno == ENOENT) {
|
||||
return std::nullopt;
|
||||
}
|
||||
throw std::runtime_error(io_error("Cannot read symlink", path));
|
||||
}
|
||||
buf[static_cast<std::size_t>(len)] = '\0';
|
||||
return std::string(buf.data());
|
||||
}
|
||||
|
||||
EncodedFrame encode_frame(uint16_t word0, uint16_t word1, uint16_t word2, uint16_t word3) {
|
||||
const uint16_t words[kFrameWordCount] = {word0, word1, word2, word3};
|
||||
EncodedFrame frame {};
|
||||
std::memcpy(frame.data(), words, sizeof(words));
|
||||
return frame;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
struct TtyProtocolWriter::Impl {
|
||||
explicit Impl(std::size_t ring_capacity_bytes)
|
||||
: capacity_frames(std::max<std::size_t>(1U, ring_capacity_bytes / kFrameByteCount)),
|
||||
ring(capacity_frames) {}
|
||||
|
||||
int fd = -1;
|
||||
int slave_fd = -1;
|
||||
std::string slave_path;
|
||||
bool owns_link = false;
|
||||
|
||||
const std::size_t capacity_frames;
|
||||
std::vector<EncodedFrame> ring;
|
||||
std::size_t head = 0;
|
||||
std::size_t size = 0;
|
||||
|
||||
mutable std::mutex mutex;
|
||||
std::condition_variable data_ready_cv;
|
||||
std::thread worker;
|
||||
bool stop_requested = false;
|
||||
std::exception_ptr failure;
|
||||
StatsSnapshot stats;
|
||||
};
|
||||
|
||||
TtyProtocolWriter::TtyProtocolWriter(std::string path, std::size_t ring_capacity_bytes)
|
||||
: path_(std::move(path)),
|
||||
impl_(std::make_unique<Impl>(ring_capacity_bytes)) {
|
||||
if (is_character_device_path(path_)) {
|
||||
impl_->fd = ::open(path_.c_str(), O_WRONLY | O_NOCTTY | O_NONBLOCK);
|
||||
if (impl_->fd < 0) {
|
||||
throw std::runtime_error(io_error("Cannot open tty output", path_));
|
||||
}
|
||||
} else {
|
||||
std::array<char, PATH_MAX> slave_name {};
|
||||
if (::openpty(&impl_->fd, &impl_->slave_fd, slave_name.data(), nullptr, nullptr) != 0) {
|
||||
throw std::runtime_error(io_error("Cannot create PTY bridge for", path_));
|
||||
}
|
||||
|
||||
try {
|
||||
impl_->slave_path = slave_name.data();
|
||||
set_fd_raw(impl_->slave_fd);
|
||||
|
||||
struct stat st {};
|
||||
if (::lstat(path_.c_str(), &st) == 0) {
|
||||
if (!S_ISLNK(st.st_mode) && !S_ISREG(st.st_mode)) {
|
||||
throw std::runtime_error("Refusing to replace non-link path '" + path_ + "'");
|
||||
}
|
||||
if (::unlink(path_.c_str()) != 0) {
|
||||
throw std::runtime_error(io_error("Cannot remove existing tty link", path_));
|
||||
}
|
||||
} else if (errno != ENOENT) {
|
||||
throw std::runtime_error(io_error("Cannot inspect tty link path", path_));
|
||||
}
|
||||
|
||||
if (::symlink(impl_->slave_path.c_str(), path_.c_str()) != 0) {
|
||||
throw std::runtime_error(io_error("Cannot create tty symlink", path_));
|
||||
}
|
||||
impl_->owns_link = true;
|
||||
} catch (...) {
|
||||
close_fd_if_open(impl_->slave_fd);
|
||||
close_fd_if_open(impl_->fd);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const auto flags = ::fcntl(impl_->fd, F_GETFL);
|
||||
if (flags < 0 || ::fcntl(impl_->fd, F_SETFL, flags | O_NONBLOCK) < 0) {
|
||||
throw std::runtime_error(io_error("Cannot make tty output nonblocking", path_));
|
||||
}
|
||||
impl_->worker = std::thread([this]() { worker_loop(); });
|
||||
} catch (...) {
|
||||
if (impl_->owns_link) {
|
||||
::unlink(path_.c_str());
|
||||
}
|
||||
close_fd_if_open(impl_->slave_fd);
|
||||
close_fd_if_open(impl_->fd);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
TtyProtocolWriter::~TtyProtocolWriter() {
|
||||
try {
|
||||
shutdown();
|
||||
} catch (...) {
|
||||
}
|
||||
|
||||
if (!impl_) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (impl_->owns_link && !path_.empty()) {
|
||||
try {
|
||||
const auto target = read_link_target(path_);
|
||||
if (target && (*target == impl_->slave_path)) {
|
||||
::unlink(path_.c_str());
|
||||
}
|
||||
} catch (...) {
|
||||
}
|
||||
impl_->owns_link = false;
|
||||
}
|
||||
|
||||
close_fd_if_open(impl_->slave_fd);
|
||||
close_fd_if_open(impl_->fd);
|
||||
}
|
||||
|
||||
void TtyProtocolWriter::emit_packet_start(uint16_t marker) {
|
||||
enqueue_frame(marker, 0xFFFF, 0xFFFF, 0xFFFF);
|
||||
}
|
||||
|
||||
void TtyProtocolWriter::emit_step(uint16_t index, int16_t ch1_avg, int16_t ch2_avg) {
|
||||
enqueue_frame(0x000A,
|
||||
index,
|
||||
static_cast<uint16_t>(ch1_avg),
|
||||
static_cast<uint16_t>(ch2_avg));
|
||||
}
|
||||
|
||||
void TtyProtocolWriter::enqueue_encoded_frames(const uint16_t* words, std::size_t frame_count) {
|
||||
if ((frame_count == 0U) || (words == nullptr)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw_if_failed();
|
||||
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
if (impl_->failure) {
|
||||
std::rethrow_exception(impl_->failure);
|
||||
}
|
||||
if (impl_->stop_requested) {
|
||||
throw std::runtime_error("tty writer is already shut down");
|
||||
}
|
||||
|
||||
std::size_t start_frame = 0;
|
||||
std::size_t frames_to_copy = frame_count;
|
||||
std::size_t dropped_frames = 0;
|
||||
|
||||
if (frame_count >= impl_->capacity_frames) {
|
||||
start_frame = frame_count - impl_->capacity_frames;
|
||||
frames_to_copy = impl_->capacity_frames;
|
||||
dropped_frames = impl_->size + start_frame;
|
||||
impl_->head = 0;
|
||||
impl_->size = 0;
|
||||
} else {
|
||||
const std::size_t available_frames = impl_->capacity_frames - impl_->size;
|
||||
if (frame_count > available_frames) {
|
||||
dropped_frames = frame_count - available_frames;
|
||||
impl_->head = (impl_->head + dropped_frames) % impl_->capacity_frames;
|
||||
impl_->size -= dropped_frames;
|
||||
}
|
||||
}
|
||||
|
||||
if (dropped_frames != 0U) {
|
||||
impl_->stats.frames_dropped += static_cast<std::uint64_t>(dropped_frames);
|
||||
++impl_->stats.ring_overflows;
|
||||
}
|
||||
|
||||
for (std::size_t i = 0; i < frames_to_copy; ++i) {
|
||||
const std::size_t src_index = (start_frame + i) * kFrameWordCount;
|
||||
const std::size_t dst_index = (impl_->head + impl_->size) % impl_->capacity_frames;
|
||||
impl_->ring[dst_index] = encode_frame(words[src_index + 0U],
|
||||
words[src_index + 1U],
|
||||
words[src_index + 2U],
|
||||
words[src_index + 3U]);
|
||||
++impl_->size;
|
||||
}
|
||||
|
||||
impl_->data_ready_cv.notify_one();
|
||||
}
|
||||
|
||||
TtyProtocolWriter::StatsSnapshot TtyProtocolWriter::stats() const {
|
||||
if (!impl_) {
|
||||
return {};
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
return impl_->stats;
|
||||
}
|
||||
|
||||
const std::string& TtyProtocolWriter::path() const {
|
||||
return path_;
|
||||
}
|
||||
|
||||
void TtyProtocolWriter::throw_if_failed() const {
|
||||
if (!impl_) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::exception_ptr failure;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
failure = impl_->failure;
|
||||
}
|
||||
|
||||
if (failure) {
|
||||
std::rethrow_exception(failure);
|
||||
}
|
||||
}
|
||||
|
||||
void TtyProtocolWriter::shutdown() {
|
||||
if (!impl_) {
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
impl_->stop_requested = true;
|
||||
}
|
||||
|
||||
impl_->data_ready_cv.notify_all();
|
||||
|
||||
if (impl_->worker.joinable()) {
|
||||
impl_->worker.join();
|
||||
}
|
||||
close_fd_if_open(impl_->fd);
|
||||
}
|
||||
|
||||
void TtyProtocolWriter::enqueue_frame(uint16_t word0, uint16_t word1, uint16_t word2, uint16_t word3) {
|
||||
const uint16_t words[kFrameWordCount] = {word0, word1, word2, word3};
|
||||
enqueue_encoded_frames(words, 1U);
|
||||
}
|
||||
|
||||
void TtyProtocolWriter::worker_loop() {
|
||||
for (;;) {
|
||||
std::array<std::uint8_t, kWriteBatchFrames * kFrameByteCount> write_batch;
|
||||
std::size_t batch_count = 0;
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(impl_->mutex);
|
||||
impl_->data_ready_cv.wait(lock, [this]() {
|
||||
return impl_->stop_requested || impl_->failure || (impl_->size != 0U);
|
||||
});
|
||||
|
||||
if (impl_->failure || impl_->stop_requested) {
|
||||
return;
|
||||
}
|
||||
|
||||
batch_count = std::min<std::size_t>(impl_->size, kWriteBatchFrames);
|
||||
for (std::size_t i = 0; i < batch_count; ++i) {
|
||||
const EncodedFrame& frame = impl_->ring[impl_->head];
|
||||
std::memcpy(write_batch.data() + (i * kFrameByteCount), frame.data(), kFrameByteCount);
|
||||
impl_->head = (impl_->head + 1U) % impl_->capacity_frames;
|
||||
--impl_->size;
|
||||
}
|
||||
}
|
||||
|
||||
const std::uint8_t* bytes = write_batch.data();
|
||||
std::size_t remaining = batch_count * kFrameByteCount;
|
||||
while (remaining != 0U) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
if (impl_->stop_requested) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const ssize_t written = ::write(impl_->fd, bytes, remaining);
|
||||
if (written < 0) {
|
||||
if (errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
if ((errno == EAGAIN) || (errno == EWOULDBLOCK) || (errno == EIO)) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
if (impl_->stop_requested) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
continue;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
if (!impl_->stop_requested) {
|
||||
impl_->failure = std::make_exception_ptr(
|
||||
std::runtime_error(io_error("Cannot write tty frame to", path_)));
|
||||
}
|
||||
impl_->data_ready_cv.notify_all();
|
||||
return;
|
||||
}
|
||||
if (written == 0) {
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
if (!impl_->stop_requested) {
|
||||
impl_->failure = std::make_exception_ptr(
|
||||
std::runtime_error("tty write returned 0 bytes for '" + path_ + "'"));
|
||||
}
|
||||
impl_->data_ready_cv.notify_all();
|
||||
return;
|
||||
}
|
||||
|
||||
bytes += static_cast<std::size_t>(written);
|
||||
remaining -= static_cast<std::size_t>(written);
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
impl_->stats.frames_written += static_cast<std::uint64_t>(batch_count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -23,7 +23,7 @@
|
||||
// * Matrix radars (`librevna_multi`) fire one Tx at a time and
|
||||
// receive on all Rx channels in parallel. A run of 8 traces is just 2
|
||||
// Tx-events; all (tx_k, *) pairs share one timestamp.
|
||||
// * Sequential radars (single librevna with switches, kamil_adc)
|
||||
// * Sequential radars (single librevna with switches, rph_radar)
|
||||
// measure each pair separately. A run of 8 traces is 8 separate sweeps;
|
||||
// every pair has its own timestamp.
|
||||
// Both cases reduce to: there are N events in the frame, each event lasts
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# RadioPhotonic radar (RPH) collector
|
||||
|
||||
Application-owned acquisition binary for the **L-Card E-502** ADC. It captures the
|
||||
dual-channel main + reference IQ stream and publishes complete `RPH1` sweeps over
|
||||
a PTY. `RphRadarService` in `python_app/hardware_full/rph_radar` reads and
|
||||
processes that stream for `python_app.scripts.rph_radar_raw_producer`.
|
||||
|
||||
This is the data *source* for `radar.model == "rph_radar"`, paired with the Python
|
||||
`rph_radar_raw_producer`. LibreVNA acquisition uses Python drivers.
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | Contents |
|
||||
|------|----------|
|
||||
| `src/` | Collector implementation (`main.cpp`) and the PTY writer. |
|
||||
| `include/` | PTY header and `switch_sequencer.h`. |
|
||||
| `vendor/lcard/` | L-Card X502/E502 SDK headers (`x502api.h`, `e502api.h`, `lcard_pstdint.h`), vendored verbatim so the collector builds with no external include path. |
|
||||
|
||||
In switch-aware mode, `SwitchSequencer` drives the H7992 switches at actual
|
||||
DI_SYN2 sweep ends. Duration limits and Stop never advance the switches. Every
|
||||
physical combination change discards the remaining decoded ADC/DIN block and
|
||||
recreates the source session after `max(1 ms, run.settling_ms)` has elapsed.
|
||||
Timestamps never decide whether a sweep belongs to the new combination. The
|
||||
collector completes a cycle only after a full, clean sweep for each combination.
|
||||
An unchanged combination keeps the existing session.
|
||||
|
||||
A restart stops cyclic DOUT with `WAIT_DONE`, stops and disables the streams,
|
||||
restores DO2 high / DO1 and DO8 low, then closes and frees the SDK descriptor.
|
||||
After settling, a new descriptor is created, opened and configured. The collector
|
||||
reloads the DO1/DO8 pattern, waits for `OutCycleSetup(WAIT_DONE)`, and starts
|
||||
ADC/DIN/DOUT together. Pending samples and phase accumulators are cleared;
|
||||
source sweep and cycle IDs remain continuous. The initial high DI_SYN2 portion
|
||||
is dirty: acquisition waits for a complete rising-to-falling sweep. This can
|
||||
skip a variable number of physical sweeps while reopening; no fixed skip count
|
||||
or firmware-pause estimate is used. Hardware validation must measure reopen
|
||||
latency, USB resource release and multi-combination throughput.
|
||||
|
||||
The application uses the internal ADC clock. ADC and DIN start together,
|
||||
continuously; DI_SYN2 gates sweeps in software. Source times are the monotonic
|
||||
clock immediately before `StreamsStart` plus the ADC sample index divided by the
|
||||
configured sample rate. These are source-clock estimates, not hardware timestamps;
|
||||
startup latency shifts the initial epoch earlier. USB and PTY queueing do not
|
||||
shift timestamps to the time Python reads a sweep. An estimated source timestamp
|
||||
ahead of receipt stops acquisition instead of publishing a future capture window.
|
||||
ADC/host drift can trigger this guard during long single-combination runs; its
|
||||
bound and the accuracy of capture windows need measurement on hardware. Drift
|
||||
cannot make data from an old SDK session clean after a physical switch.
|
||||
|
||||
The collector uses the common H7992 driver, with its own include set and
|
||||
`build/rph/` object tree.
|
||||
|
||||
## PTY format
|
||||
|
||||
Each packet is a 48-byte little-endian header (`<4sIQQQQHHI`) followed by the
|
||||
payload: `RPH1`, payload bytes (u32), source sweep ID, cycle ID, start/end ns
|
||||
(four u64), input/output positions (two u16), flags (u32; bit 0 means dirty).
|
||||
Without collector switching both positions are `0xffff`, and cycle equals sweep
|
||||
ID. Payload points remain four u16 words: marker (`0x000a` main, `0x00a8`
|
||||
reference), step index, signed I, signed Q. There are no boundary or combo frames.
|
||||
The queue drops complete sweeps on overflow; a packet already being written is
|
||||
never truncated. Maximum payload is 1,048,544 bytes. Python matches complete
|
||||
cycles. Python-controlled switching closes the previous collector and PTY reader,
|
||||
then starts a new collector after settling; phase-processing state is preserved.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
make rph_radar_collector # -> build/bin/rph_radar_collector
|
||||
```
|
||||
|
||||
It is **not** part of `make all`; `start.sh` builds it automatically when the
|
||||
active config selects `radar.model == "rph_radar"`, unless `--skip-build` is set.
|
||||
The Linux build uses the repository's common sources and vendored headers and
|
||||
links with `-pthread -ldl -lutil`; no installed L-Card SDK is needed to build it.
|
||||
|
||||
## Runtime
|
||||
|
||||
The collector loads the proprietary `libx502api.so` / `libe502api.so` at run time
|
||||
via `dlopen`, so those libraries must be discoverable by the dynamic loader.
|
||||
The RPH radar service prepends `~/.local/lib` (their default install location)
|
||||
unless the config pins `LD_LIBRARY_PATH` in `radar.rph_radar.env`.
|
||||
|
||||
There is one acquisition path: USB, differential ADC channels 2/3, maximum
|
||||
internal-clock rate, rising/falling DI_SYN2 gating, DO1 pair subtraction and DO8
|
||||
reference overlay. `radar.rph_radar.args` sets ADC range, internal reference,
|
||||
maximum sweep duration and DO8 period. The example's fixed-path options remain
|
||||
accepted as guards; incompatible modes and unused diagnostic options are rejected.
|
||||
The service appends `tty:<path>` from `radar.rph_radar.tty_path`.
|
||||
|
||||
When both RF switches use `driver_mode: "native"`, Python passes
|
||||
`switching:<JSON>` with the already decoded input/output switch settings, combos
|
||||
and settling time. The collector reads no profile file. Otherwise, Python selects
|
||||
the switches and starts a new collector after settling when the combination
|
||||
changes. Calibration captures also use this Python-controlled path.
|
||||
|
||||
The launching service owns the collector process and forwards its output to the
|
||||
application log. On Linux, parent death sends SIGKILL to the collector; normal
|
||||
Stop drains and closes the service's own child. No global process-name kill is
|
||||
used.
|
||||
@@ -0,0 +1,98 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "shared_types.hpp"
|
||||
#include "h7992_minimal_driver.hpp"
|
||||
|
||||
namespace radar::rph {
|
||||
|
||||
// Every real GPIO transition invalidates the ADC session. No timestamp can
|
||||
// declare queued data clean; only a newly started source session can do that.
|
||||
class SwitchSequencer {
|
||||
public:
|
||||
struct Settings {
|
||||
/// Combinations to cycle through, one sweep each, in order. Empty disables
|
||||
/// the sequencer entirely (the collector then streams a single passthrough
|
||||
/// channel exactly as before).
|
||||
std::vector<radar::ipc::ComboKey> combos{};
|
||||
/// RF settling allowance after both GPIO writes complete.
|
||||
double settle_ms = 1.0;
|
||||
};
|
||||
|
||||
SwitchSequencer(
|
||||
Settings settings,
|
||||
std::unique_ptr<drivers::H7992MinimalDriver> input_switch,
|
||||
std::unique_ptr<drivers::H7992MinimalDriver> output_switch
|
||||
)
|
||||
: settings_(std::move(settings)),
|
||||
input_switch_(std::move(input_switch)),
|
||||
output_switch_(std::move(output_switch)) {}
|
||||
|
||||
[[nodiscard]] auto enabled() const -> bool { return !settings_.combos.empty(); }
|
||||
|
||||
/// Open both switch drivers and park at the first combination.
|
||||
void open() {
|
||||
if (!enabled()) {
|
||||
return;
|
||||
}
|
||||
output_switch_->open();
|
||||
input_switch_->open();
|
||||
index_ = 0;
|
||||
applied_ = settings_.combos.front();
|
||||
apply(applied_);
|
||||
upcoming_dirty_ = true;
|
||||
}
|
||||
|
||||
/// Combination the in-progress / upcoming sweep is captured under.
|
||||
[[nodiscard]] auto current_combo() const -> radar::ipc::ComboKey {
|
||||
return settings_.combos[index_];
|
||||
}
|
||||
|
||||
/// Whether the in-progress / upcoming sweep is expected to straddle a switch
|
||||
/// transition and must be dropped by the consumer.
|
||||
[[nodiscard]] auto current_dirty() const -> bool { return upcoming_dirty_; }
|
||||
|
||||
[[nodiscard]] auto cycle_id() const -> std::uint64_t { return cycle_id_; }
|
||||
|
||||
[[nodiscard]] auto settled_after_ns() const -> std::uint64_t { return settled_after_ns_; }
|
||||
void session_started() { upcoming_dirty_ = false; }
|
||||
|
||||
bool on_sweep_end(bool complete = true) {
|
||||
if (!enabled() || !complete || upcoming_dirty_) { return false; }
|
||||
index_ = (index_ + 1U) % settings_.combos.size();
|
||||
if (index_ == 0) { ++cycle_id_; }
|
||||
const radar::ipc::ComboKey next = settings_.combos[index_];
|
||||
if (!(next == applied_)) {
|
||||
apply(next);
|
||||
applied_ = next;
|
||||
upcoming_dirty_ = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private:
|
||||
void apply(const radar::ipc::ComboKey& combo) {
|
||||
// Set the transmitter before the receiver.
|
||||
output_switch_->switch_to(combo.output_pos);
|
||||
input_switch_->switch_to(combo.input_pos);
|
||||
settled_after_ns_ = static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::nanoseconds>(
|
||||
std::chrono::steady_clock::now().time_since_epoch()).count()) +
|
||||
static_cast<std::uint64_t>(settings_.settle_ms * 1e6);
|
||||
}
|
||||
|
||||
Settings settings_;
|
||||
std::unique_ptr<drivers::H7992MinimalDriver> input_switch_;
|
||||
std::unique_ptr<drivers::H7992MinimalDriver> output_switch_;
|
||||
std::size_t index_ = 0;
|
||||
radar::ipc::ComboKey applied_{};
|
||||
bool upcoming_dirty_ = false;
|
||||
std::uint64_t settled_after_ns_ = 0, cycle_id_ = 1;
|
||||
};
|
||||
|
||||
} // namespace radar::rph
|
||||
+7
-4
@@ -4,6 +4,7 @@
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
class TtyProtocolWriter {
|
||||
public:
|
||||
@@ -21,9 +22,12 @@ public:
|
||||
TtyProtocolWriter(TtyProtocolWriter&& other) noexcept = delete;
|
||||
TtyProtocolWriter& operator=(TtyProtocolWriter&& other) noexcept = delete;
|
||||
|
||||
void emit_packet_start(uint16_t marker = 0x000A);
|
||||
void emit_step(uint16_t index, int16_t ch1_avg, int16_t ch2_avg);
|
||||
void enqueue_encoded_frames(const uint16_t* words, std::size_t frame_count);
|
||||
struct SweepMetadata {
|
||||
std::uint64_t source_id, cycle_id, capture_start_ns, capture_end_ns;
|
||||
std::uint16_t input = 0xFFFF, output = 0xFFFF;
|
||||
bool dirty = false;
|
||||
};
|
||||
void enqueue_sweep(const std::vector<uint16_t>& words, const SweepMetadata& metadata);
|
||||
StatsSnapshot stats() const;
|
||||
|
||||
const std::string& path() const;
|
||||
@@ -31,7 +35,6 @@ public:
|
||||
void shutdown();
|
||||
|
||||
private:
|
||||
void enqueue_frame(uint16_t word0, uint16_t word1, uint16_t word2, uint16_t word3);
|
||||
void worker_loop();
|
||||
|
||||
std::string path_;
|
||||
@@ -0,0 +1,530 @@
|
||||
#include "x502api.h"
|
||||
#include "e502api.h"
|
||||
#include "tty_protocol_writer.h"
|
||||
#include "switch_sequencer.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <csignal>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <dlfcn.h>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <numeric>
|
||||
#include <string>
|
||||
#include <sys/prctl.h>
|
||||
#include <unistd.h>
|
||||
#include <vector>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
namespace {
|
||||
using Json = nlohmann::json;
|
||||
constexpr uint32_t kDiSyn2 = (1U << 13U) | (1U << 17U);
|
||||
constexpr uint32_t kDi8 = 1U << 6U; // Hardware DI8 readback offset, verified by loopback.
|
||||
constexpr uint32_t kDo8 = 1U << 7U;
|
||||
constexpr uint32_t kBlockWords = 65536;
|
||||
|
||||
[[noreturn]] void fail(const std::string& message) { throw std::runtime_error(message); }
|
||||
|
||||
void bind_to_parent() {
|
||||
const auto parent = ::getppid();
|
||||
if (::prctl(PR_SET_PDEATHSIG, SIGKILL) != 0 || ::getppid() != parent || parent == 1) {
|
||||
fail("Collector must remain owned by its launching application");
|
||||
}
|
||||
}
|
||||
|
||||
std::uint64_t monotonic_ns() {
|
||||
return static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::nanoseconds>(
|
||||
std::chrono::steady_clock::now().time_since_epoch()).count());
|
||||
}
|
||||
|
||||
struct Config {
|
||||
std::string tty_path;
|
||||
Json switching;
|
||||
uint32_t range = X502_ADC_RANGE_2;
|
||||
uint32_t reference_hz = X502_REF_FREQ_2000KHZ;
|
||||
uint32_t do8_period = 8;
|
||||
double duration_ms = 100;
|
||||
};
|
||||
|
||||
Config parse_args(int argc, char** argv) {
|
||||
Config cfg;
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
const std::string arg = argv[i];
|
||||
const auto colon = arg.find(':');
|
||||
const auto key = arg.substr(0, colon);
|
||||
const auto value = colon == std::string::npos ? "" : arg.substr(colon + 1);
|
||||
if (key == "tty") { cfg.tty_path = value; }
|
||||
else if (key == "switching") { cfg.switching = Json::parse(value); }
|
||||
else if (key == "duration_ms") {
|
||||
std::size_t consumed = 0;
|
||||
cfg.duration_ms = std::stod(value, &consumed);
|
||||
if (consumed != value.size()) { fail("Invalid duration_ms"); }
|
||||
}
|
||||
else if (key == "do8_cycle_period") {
|
||||
std::size_t consumed = 0;
|
||||
const auto period = std::stoull(value, &consumed);
|
||||
if (consumed != value.size() || period < 4 || period > 262144 ||
|
||||
std::lcm(std::uint64_t{4}, period) > 262144) {
|
||||
fail("Combined DO1+DO8 cyclic pattern exceeds 262144 words or has an invalid period");
|
||||
}
|
||||
cfg.do8_period = static_cast<uint32_t>(period);
|
||||
} else if (key == "internal_ref_hz") {
|
||||
if (value == "2000000") { cfg.reference_hz = X502_REF_FREQ_2000KHZ; }
|
||||
else if (value == "1500000") { cfg.reference_hz = X502_REF_FREQ_1500KHZ; }
|
||||
else { fail("internal_ref_hz must be 1500000 or 2000000"); }
|
||||
} else if (key == "range") {
|
||||
const std::array<std::string, 6> ranges{"10", "5", "2", "1", "0.5", "0.2"};
|
||||
const auto found = std::find(ranges.begin(), ranges.end(), value);
|
||||
if (found == ranges.end()) { fail("Unsupported ADC range: " + value); }
|
||||
cfg.range = static_cast<uint32_t>(found - ranges.begin());
|
||||
} else if (arg != "profile:phase" && arg != "clock:internal" &&
|
||||
arg != "start:di_syn2_rise" && arg != "stop:di_syn2_fall" &&
|
||||
arg != "sample_clock_hz:max" && arg != "packet_limit:0" &&
|
||||
arg != "do1_toggle_per_frame" && arg != "do1_pair_subtract_avg" && arg != "do8_freq_ref") {
|
||||
fail("Unsupported application collector option: " + arg);
|
||||
}
|
||||
}
|
||||
if (cfg.tty_path.empty()) { fail("Application must supply tty:<path>"); }
|
||||
if (!std::isfinite(cfg.duration_ms) || cfg.duration_ms <= 0 || cfg.duration_ms > 60000) {
|
||||
fail("duration_ms must be finite and in (0, 60000]");
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
void* open_library(const char* name) {
|
||||
auto* handle = ::dlopen(name, RTLD_NOW | RTLD_LOCAL);
|
||||
if (!handle) { fail(std::string("Cannot load ") + name + ": " + ::dlerror()); }
|
||||
return handle;
|
||||
}
|
||||
|
||||
template <typename Fn>
|
||||
Fn symbol(void* library, const char* name) {
|
||||
auto fn = reinterpret_cast<Fn>(::dlsym(library, name));
|
||||
if (!fn) { fail(std::string("Missing L-Card SDK symbol: ") + name); }
|
||||
return fn;
|
||||
}
|
||||
|
||||
struct Api {
|
||||
void* x502 = open_library("libx502api.so");
|
||||
void* e502 = open_library("libe502api.so");
|
||||
decltype(&X502_Create) Create = symbol<decltype(Create)>(x502, "X502_Create");
|
||||
decltype(&X502_Free) Free = symbol<decltype(Free)>(x502, "X502_Free");
|
||||
decltype(&X502_Close) Close = symbol<decltype(Close)>(x502, "X502_Close");
|
||||
decltype(&X502_GetErrorString) GetErrorString = symbol<decltype(GetErrorString)>(x502, "X502_GetErrorString");
|
||||
decltype(&X502_SetMode) SetMode = symbol<decltype(SetMode)>(x502, "X502_SetMode");
|
||||
decltype(&X502_StreamsStop) StreamsStop = symbol<decltype(StreamsStop)>(x502, "X502_StreamsStop");
|
||||
decltype(&X502_StreamsDisable) StreamsDisable = symbol<decltype(StreamsDisable)>(x502, "X502_StreamsDisable");
|
||||
decltype(&X502_SetSyncMode) SetSyncMode = symbol<decltype(SetSyncMode)>(x502, "X502_SetSyncMode");
|
||||
decltype(&X502_SetSyncStartMode) SetSyncStartMode = symbol<decltype(SetSyncStartMode)>(x502, "X502_SetSyncStartMode");
|
||||
decltype(&X502_SetLChannelCount) SetLChannelCount = symbol<decltype(SetLChannelCount)>(x502, "X502_SetLChannelCount");
|
||||
decltype(&X502_SetLChannel) SetLChannel = symbol<decltype(SetLChannel)>(x502, "X502_SetLChannel");
|
||||
decltype(&X502_SetAdcFreq) SetAdcFreq = symbol<decltype(SetAdcFreq)>(x502, "X502_SetAdcFreq");
|
||||
decltype(&X502_GetAdcFreq) GetAdcFreq = symbol<decltype(GetAdcFreq)>(x502, "X502_GetAdcFreq");
|
||||
decltype(&X502_SetDinFreq) SetDinFreq = symbol<decltype(SetDinFreq)>(x502, "X502_SetDinFreq");
|
||||
decltype(&X502_SetOutFreq) SetOutFreq = symbol<decltype(SetOutFreq)>(x502, "X502_SetOutFreq");
|
||||
decltype(&X502_AsyncOutDig) AsyncOutDig = symbol<decltype(AsyncOutDig)>(x502, "X502_AsyncOutDig");
|
||||
decltype(&X502_SetRefFreq) SetRefFreq = symbol<decltype(SetRefFreq)>(x502, "X502_SetRefFreq");
|
||||
decltype(&X502_SetStreamBufSize) SetStreamBufSize = symbol<decltype(SetStreamBufSize)>(x502, "X502_SetStreamBufSize");
|
||||
decltype(&X502_SetStreamStep) SetStreamStep = symbol<decltype(SetStreamStep)>(x502, "X502_SetStreamStep");
|
||||
decltype(&X502_SetDigInPullup) SetDigInPullup = symbol<decltype(SetDigInPullup)>(x502, "X502_SetDigInPullup");
|
||||
decltype(&X502_Configure) Configure = symbol<decltype(Configure)>(x502, "X502_Configure");
|
||||
decltype(&X502_StreamsEnable) StreamsEnable = symbol<decltype(StreamsEnable)>(x502, "X502_StreamsEnable");
|
||||
decltype(&X502_StreamsStart) StreamsStart = symbol<decltype(StreamsStart)>(x502, "X502_StreamsStart");
|
||||
decltype(&X502_GetRecvReadyCount) GetRecvReadyCount = symbol<decltype(GetRecvReadyCount)>(x502, "X502_GetRecvReadyCount");
|
||||
decltype(&X502_Recv) Recv = symbol<decltype(Recv)>(x502, "X502_Recv");
|
||||
decltype(&X502_OutCycleLoadStart) OutCycleLoadStart = symbol<decltype(OutCycleLoadStart)>(x502, "X502_OutCycleLoadStart");
|
||||
decltype(&X502_OutCycleSetup) OutCycleSetup = symbol<decltype(OutCycleSetup)>(x502, "X502_OutCycleSetup");
|
||||
decltype(&X502_OutCycleStop) OutCycleStop = symbol<decltype(OutCycleStop)>(x502, "X502_OutCycleStop");
|
||||
decltype(&X502_Send) Send = symbol<decltype(Send)>(x502, "X502_Send");
|
||||
decltype(&X502_PrepareData) PrepareData = symbol<decltype(PrepareData)>(x502, "X502_PrepareData");
|
||||
decltype(&X502_ProcessData) ProcessData = symbol<decltype(ProcessData)>(x502, "X502_ProcessData");
|
||||
decltype(&E502_OpenUsb) OpenUsb = symbol<decltype(OpenUsb)>(e502, "E502_OpenUsb");
|
||||
~Api() { ::dlclose(e502); ::dlclose(x502); }
|
||||
};
|
||||
|
||||
void expect_ok(const Api& api, int32_t error, const char* operation) {
|
||||
if (error != X502_ERR_OK) {
|
||||
const char* description = api.GetErrorString(error);
|
||||
fail(std::string(operation) + ": " + std::to_string(error) +
|
||||
(description ? std::string(" (") + description + ")" : ""));
|
||||
}
|
||||
}
|
||||
|
||||
struct DeviceHandle {
|
||||
const Api& api;
|
||||
t_x502_hnd hnd;
|
||||
bool opened = false;
|
||||
bool streams_started = false;
|
||||
explicit DeviceHandle(const Api& sdk) : api(sdk), hnd(api.Create()) {
|
||||
if (!hnd) { fail("X502_Create failed"); }
|
||||
}
|
||||
~DeviceHandle() {
|
||||
if (streams_started) { api.OutCycleStop(hnd, 0); api.StreamsStop(hnd); }
|
||||
if (opened) { api.Close(hnd); }
|
||||
api.Free(hnd);
|
||||
}
|
||||
};
|
||||
|
||||
volatile std::sig_atomic_t stop_requested = 0;
|
||||
void stop_handler(int signal) {
|
||||
if (stop_requested) { std::_Exit(128 + signal); }
|
||||
stop_requested = 1;
|
||||
}
|
||||
|
||||
std::unique_ptr<radar::rph::SwitchSequencer> make_switch_sequencer(const Config& cfg) {
|
||||
if (cfg.switching.is_null()) { return nullptr; }
|
||||
const auto driver = [](const Json& item) {
|
||||
if (item.value("driver", std::string("h7992")) != "h7992" || item.value("invert_logic", false)) {
|
||||
fail("Switches support only h7992 with invert_logic=false");
|
||||
}
|
||||
const auto mode = item.at("driver_mode").get<std::string>();
|
||||
if (mode != "native" && mode != "mock") { fail("Invalid switch driver_mode"); }
|
||||
return std::make_unique<radar::drivers::H7992MinimalDriver>(radar::drivers::H7992MinimalDriverSettings{
|
||||
.name = item.at("name").get<std::string>(),
|
||||
.mode = mode == "native" ? radar::config::DriverMode::Native : radar::config::DriverMode::Mock,
|
||||
.positions = item.at("positions").get<uint32_t>(),
|
||||
.default_position = item.at("default_position").get<uint32_t>(),
|
||||
.gpio_chip = item.at("gpio_chip").get<std::string>(),
|
||||
.pin_a = item.at("pin_a").get<int32_t>(), .pin_b = item.at("pin_b").get<int32_t>()});
|
||||
};
|
||||
radar::rph::SwitchSequencer::Settings settings;
|
||||
settings.settle_ms = cfg.switching.at("settling_ms").get<double>();
|
||||
if (!std::isfinite(settings.settle_ms) || settings.settle_ms < 0 || settings.settle_ms > 60000) {
|
||||
fail("Switch settling_ms must be finite and in [0, 60000]");
|
||||
}
|
||||
settings.settle_ms = std::max(1.0, settings.settle_ms);
|
||||
const auto& input = cfg.switching.at("input");
|
||||
const auto& output = cfg.switching.at("output");
|
||||
for (const auto& pair : cfg.switching.at("combos")) {
|
||||
if (!pair.is_array() || pair.size() != 2 || !pair[0].is_number_unsigned() || !pair[1].is_number_unsigned() ||
|
||||
pair[0].get<uint64_t>() >= input.at("positions").get<uint64_t>() ||
|
||||
pair[1].get<uint64_t>() >= output.at("positions").get<uint64_t>()) {
|
||||
fail("Switch combination out of range");
|
||||
}
|
||||
settings.combos.push_back({pair[0].get<uint32_t>(), pair[1].get<uint32_t>()});
|
||||
}
|
||||
if (settings.combos.empty()) { fail("Collector switching requires nonempty combos"); }
|
||||
return std::make_unique<radar::rph::SwitchSequencer>(std::move(settings), driver(input), driver(output));
|
||||
}
|
||||
|
||||
int16_t pack_raw_code_to_int16(double value) {
|
||||
const auto rounded = std::llround(value * 32767.0 / static_cast<double>(X502_ADC_SCALE_CODE_MAX));
|
||||
return static_cast<int16_t>(std::clamp<long long>(rounded, -32768, 32767));
|
||||
}
|
||||
struct Do1PairSubtractAverageState {
|
||||
struct PairSample {
|
||||
double ch1 = 0.0;
|
||||
double ch2 = 0.0;
|
||||
};
|
||||
|
||||
std::deque<PairSample> low_pairs;
|
||||
std::deque<PairSample> high_pairs;
|
||||
bool run_initialized = false;
|
||||
bool run_di1_high = false;
|
||||
double diff_sum_ch1 = 0.0;
|
||||
double diff_sum_ch2 = 0.0;
|
||||
uint32_t diff_count = 0;
|
||||
|
||||
bool pending_ch1_valid = false;
|
||||
double pending_ch1_raw = 0.0;
|
||||
bool pending_ch1_di1_high = false;
|
||||
bool pending_ch1_di2_high = false;
|
||||
bool pending_ch1_di8_high = false; // raw DI8 level (not guard-filtered)
|
||||
|
||||
uint16_t next_index = 1;
|
||||
|
||||
void clear_run_pairs() {
|
||||
low_pairs.clear();
|
||||
high_pairs.clear();
|
||||
diff_sum_ch1 = 0.0;
|
||||
diff_sum_ch2 = 0.0;
|
||||
diff_count = 0;
|
||||
}
|
||||
|
||||
void clear_pending_ch1() {
|
||||
pending_ch1_valid = false;
|
||||
pending_ch1_raw = 0.0;
|
||||
pending_ch1_di1_high = false;
|
||||
pending_ch1_di2_high = false;
|
||||
pending_ch1_di8_high = false;
|
||||
}
|
||||
|
||||
void reset_packet() {
|
||||
clear_run_pairs();
|
||||
run_initialized = false;
|
||||
run_di1_high = false;
|
||||
clear_pending_ch1();
|
||||
next_index = 1;
|
||||
}
|
||||
|
||||
void start_run(bool di1_high) {
|
||||
clear_run_pairs();
|
||||
run_initialized = true;
|
||||
run_di1_high = di1_high;
|
||||
}
|
||||
|
||||
void clear_run() {
|
||||
clear_run_pairs();
|
||||
run_initialized = false;
|
||||
}
|
||||
|
||||
void add_pending_ch1(double raw_code, bool di1_high, bool di2_high,
|
||||
bool di8_high_level = false) {
|
||||
pending_ch1_valid = true;
|
||||
pending_ch1_raw = raw_code;
|
||||
pending_ch1_di1_high = di1_high;
|
||||
pending_ch1_di2_high = di2_high;
|
||||
pending_ch1_di8_high = di8_high_level;
|
||||
}
|
||||
|
||||
void add_pair_sample(bool di2_high, double ch1_value, double ch2_value) {
|
||||
const PairSample sample{ch1_value, ch2_value};
|
||||
if (di2_high) {
|
||||
high_pairs.push_back(sample);
|
||||
} else {
|
||||
low_pairs.push_back(sample);
|
||||
}
|
||||
|
||||
while (!low_pairs.empty() && !high_pairs.empty()) {
|
||||
const PairSample low = low_pairs.front();
|
||||
const PairSample high = high_pairs.front();
|
||||
low_pairs.pop_front();
|
||||
high_pairs.pop_front();
|
||||
|
||||
diff_sum_ch1 += (low.ch1 - high.ch1);
|
||||
diff_sum_ch2 += (low.ch2 - high.ch2);
|
||||
++diff_count;
|
||||
}
|
||||
}
|
||||
|
||||
bool has_output() const { return diff_count != 0U; }
|
||||
|
||||
double avg_diff_ch1() const {
|
||||
if (diff_count == 0U) {
|
||||
return 0.0;
|
||||
}
|
||||
return diff_sum_ch1 / static_cast<double>(diff_count);
|
||||
}
|
||||
|
||||
double avg_diff_ch2() const {
|
||||
if (diff_count == 0U) {
|
||||
return 0.0;
|
||||
}
|
||||
return diff_sum_ch2 / static_cast<double>(diff_count);
|
||||
}
|
||||
};
|
||||
|
||||
int run(const Config& cfg) {
|
||||
stop_requested = 0;
|
||||
struct sigaction action{};
|
||||
action.sa_handler = stop_handler;
|
||||
sigemptyset(&action.sa_mask);
|
||||
if (::sigaction(SIGINT, &action, nullptr) != 0 || ::sigaction(SIGTERM, &action, nullptr) != 0) {
|
||||
fail("Cannot install collector stop handlers");
|
||||
}
|
||||
Api api;
|
||||
auto sequencer = make_switch_sequencer(cfg);
|
||||
if (sequencer) { sequencer->open(); }
|
||||
std::unique_ptr<DeviceHandle> device;
|
||||
bool restart_requested = true;
|
||||
double adc_hz = 0;
|
||||
uint64_t epoch_ns = 0, max_frames = 0;
|
||||
TtyProtocolWriter writer(cfg.tty_path, 2U * (48U + 65534U * 16U));
|
||||
std::vector<uint32_t> raw(kBlockWords), din(kBlockWords);
|
||||
std::vector<double> adc(kBlockWords);
|
||||
std::deque<double> pending_adc;
|
||||
std::deque<uint32_t> pending_din;
|
||||
std::vector<uint16_t> words;
|
||||
Do1PairSubtractAverageState pairs;
|
||||
std::array<double, 2> reference_sum{};
|
||||
uint32_t reference_count = 0;
|
||||
bool active = false, previous_gate = false, initialized = false;
|
||||
uint64_t sample_index = 0, sweep_id = 0, frames = 0;
|
||||
TtyProtocolWriter::SweepMetadata metadata{};
|
||||
const auto append = [&](uint16_t marker, uint16_t step, double i, double q) {
|
||||
words.insert(words.end(), {marker, step, static_cast<uint16_t>(pack_raw_code_to_int16(i)),
|
||||
static_cast<uint16_t>(pack_raw_code_to_int16(q))});
|
||||
};
|
||||
const auto finish_step = [&]() {
|
||||
if (pairs.run_initialized && pairs.has_output()) {
|
||||
if (pairs.next_index == 0xffff) { fail("Sweep exceeds 65534 phase points"); }
|
||||
const auto step = pairs.next_index++;
|
||||
append(0x000a, step, pairs.avg_diff_ch1(), pairs.avg_diff_ch2());
|
||||
if (reference_count) {
|
||||
append(0x00a8, step, reference_sum[0] / reference_count, reference_sum[1] / reference_count);
|
||||
}
|
||||
}
|
||||
pairs.clear_run();
|
||||
reference_sum = {};
|
||||
reference_count = 0;
|
||||
};
|
||||
const auto finish_sweep = [&](uint64_t end_ns, bool complete) {
|
||||
finish_step();
|
||||
metadata.capture_end_ns = end_ns;
|
||||
metadata.dirty = metadata.dirty || !complete;
|
||||
if (!words.empty() && end_ns > metadata.capture_start_ns) { writer.enqueue_sweep(words, metadata); }
|
||||
if (sequencer && frames && !metadata.dirty) { restart_requested = sequencer->on_sweep_end(complete); }
|
||||
words.clear();
|
||||
active = false;
|
||||
};
|
||||
const auto stop_session = [&]() {
|
||||
if (!device) { return; }
|
||||
expect_ok(api, api.OutCycleStop(device->hnd, X502_OUT_CYCLE_FLAGS_WAIT_DONE), "Stop DOUT cycle");
|
||||
expect_ok(api, api.StreamsStop(device->hnd), "Stop streams");
|
||||
device->streams_started = false;
|
||||
expect_ok(api, api.StreamsDisable(device->hnd, X502_STREAM_ALL_IN | X502_STREAM_ALL_OUT), "Disable streams");
|
||||
expect_ok(api, api.AsyncOutDig(device->hnd, 2, ~(3U | kDo8)), "Restore DO2 high");
|
||||
expect_ok(api, api.Close(device->hnd), "Close ADC session");
|
||||
device->opened = false;
|
||||
device.reset(); // X502_Free destroys this session's decoder/DMA buffers.
|
||||
};
|
||||
const auto start_session = [&]() {
|
||||
stop_session();
|
||||
pending_adc.clear(); pending_din.clear(); words.clear();
|
||||
pairs.reset_packet(); reference_sum = {}; reference_count = 0;
|
||||
active = false; previous_gate = false; initialized = false;
|
||||
sample_index = 0; frames = 0;
|
||||
while (sequencer && monotonic_ns() < sequencer->settled_after_ns() && !stop_requested) { ::usleep(1000); }
|
||||
if (stop_requested) { return; }
|
||||
device = std::make_unique<DeviceHandle>(api);
|
||||
expect_ok(api, api.OpenUsb(device->hnd, nullptr), "Open E-502");
|
||||
device->opened = true;
|
||||
expect_ok(api, api.SetMode(device->hnd, X502_MODE_FPGA), "Set FPGA mode");
|
||||
expect_ok(api, api.StreamsStop(device->hnd), "Stop prior streams");
|
||||
expect_ok(api, api.StreamsDisable(device->hnd, X502_STREAM_ALL_IN | X502_STREAM_ALL_OUT), "Disable prior streams");
|
||||
expect_ok(api, api.SetSyncMode(device->hnd, X502_SYNC_INTERNAL), "Set internal sample clock");
|
||||
expect_ok(api, api.SetSyncStartMode(device->hnd, X502_SYNC_INTERNAL), "Set immediate start");
|
||||
expect_ok(api, api.SetRefFreq(device->hnd, cfg.reference_hz), "Set reference clock");
|
||||
expect_ok(api, api.SetLChannelCount(device->hnd, 2), "Set logical channels");
|
||||
for (uint32_t ch = 0; ch < 2; ++ch) {
|
||||
expect_ok(api, api.SetLChannel(device->hnd, ch, ch + 2, X502_LCH_MODE_DIFF, cfg.range, 1), "Set ADC channel");
|
||||
}
|
||||
adc_hz = cfg.reference_hz;
|
||||
double frame_hz = 0;
|
||||
expect_ok(api, api.SetAdcFreq(device->hnd, &adc_hz, nullptr), "Set maximum ADC frequency");
|
||||
expect_ok(api, api.GetAdcFreq(device->hnd, &adc_hz, &frame_hz), "Get ADC frequencies");
|
||||
double din_hz = adc_hz, dout_hz = adc_hz;
|
||||
expect_ok(api, api.SetDinFreq(device->hnd, &din_hz), "Set DIN frequency");
|
||||
expect_ok(api, api.SetOutFreq(device->hnd, &dout_hz), "Set DOUT frequency");
|
||||
if (!std::isfinite(adc_hz) || adc_hz <= 0 || !std::isfinite(frame_hz) || frame_hz <= 0 ||
|
||||
!std::isfinite(din_hz) || !std::isfinite(dout_hz) || adc_hz > cfg.reference_hz ||
|
||||
std::abs(frame_hz * 2 - adc_hz) > 0.5 || std::abs(din_hz - adc_hz) > 0.5 || std::abs(dout_hz - adc_hz) > 0.5) {
|
||||
fail("ADC, DIN and DOUT must have the same finite sample frequency");
|
||||
}
|
||||
for (const auto stream : {X502_STREAM_CH_IN, X502_STREAM_CH_OUT}) {
|
||||
expect_ok(api, api.SetStreamBufSize(device->hnd, stream, 16U * 1024U * 1024U), "Set stream buffer");
|
||||
expect_ok(api, api.SetStreamStep(device->hnd, stream, kBlockWords), "Set stream step");
|
||||
}
|
||||
expect_ok(api, api.SetDigInPullup(device->hnd, 0), "Set input pulls");
|
||||
expect_ok(api, api.Configure(device->hnd, 0), "Configure E-502");
|
||||
expect_ok(api, api.AsyncOutDig(device->hnd, 2, 0), "Set DO2 high");
|
||||
expect_ok(api, api.StreamsEnable(device->hnd, X502_STREAM_ADC | X502_STREAM_DIN | X502_STREAM_DOUT), "Enable streams");
|
||||
|
||||
// Parse bounds were checked before loading the SDK or allocating either vector.
|
||||
const auto pattern_size = std::lcm(uint32_t{4}, cfg.do8_period);
|
||||
std::vector<uint32_t> pattern(pattern_size), encoded(pattern_size);
|
||||
for (std::size_t i = 0; i < pattern.size(); ++i) {
|
||||
pattern[i] = 2U | (i % 4U >= 2U ? 1U : 0U) |
|
||||
(i % cfg.do8_period >= cfg.do8_period - 2U ? kDo8 : 0U);
|
||||
}
|
||||
expect_ok(api, api.OutCycleLoadStart(device->hnd, pattern_size), "Allocate DOUT cycle");
|
||||
expect_ok(api, api.PrepareData(device->hnd, nullptr, nullptr, pattern.data(), pattern_size, 0, encoded.data()), "Encode DOUT cycle");
|
||||
for (std::size_t sent = 0; sent < encoded.size();) {
|
||||
const auto n = api.Send(device->hnd, encoded.data() + sent, static_cast<uint32_t>(encoded.size() - sent), 50);
|
||||
if (n <= 0) { fail("Send DOUT cycle failed or timed out: " + std::to_string(n)); }
|
||||
sent += static_cast<std::size_t>(n);
|
||||
}
|
||||
expect_ok(api, api.OutCycleSetup(device->hnd, X502_OUT_CYCLE_FLAGS_WAIT_DONE), "Activate DOUT cycle");
|
||||
max_frames = std::max<uint64_t>(1, static_cast<uint64_t>(std::llround(cfg.duration_ms * frame_hz / 1000.0)));
|
||||
// Diagnostic source-clock estimate only. Freshness follows the new SDK
|
||||
// session after settled GPIO, independently of either clock's drift.
|
||||
epoch_ns = monotonic_ns();
|
||||
expect_ok(api, api.StreamsStart(device->hnd), "Start ADC/DIN/DOUT streams");
|
||||
device->streams_started = true;
|
||||
if (sequencer) { sequencer->session_started(); }
|
||||
restart_requested = false;
|
||||
};
|
||||
while (!stop_requested) {
|
||||
if (restart_requested) { start_session(); }
|
||||
if (stop_requested) { break; }
|
||||
writer.throw_if_failed();
|
||||
uint32_t ready = 0;
|
||||
expect_ok(api, api.GetRecvReadyCount(device->hnd, &ready), "Read ADC backlog");
|
||||
const auto count = api.Recv(device->hnd, raw.data(), ready ? std::min(ready, kBlockWords) : kBlockWords, ready ? 0 : 50);
|
||||
if (count < 0) { expect_ok(api, count, "Receive ADC/DIN"); }
|
||||
if (count == 0) { continue; }
|
||||
uint32_t adc_count = kBlockWords, din_count = kBlockWords;
|
||||
expect_ok(api, api.ProcessData(device->hnd, raw.data(), static_cast<uint32_t>(count), 0,
|
||||
adc.data(), &adc_count, din.data(), &din_count), "Decode ADC/DIN");
|
||||
pending_adc.insert(pending_adc.end(), adc.begin(), adc.begin() + adc_count);
|
||||
pending_din.insert(pending_din.end(), din.begin(), din.begin() + din_count);
|
||||
if (pending_adc.size() > 1'000'000 || pending_din.size() > 1'000'000) {
|
||||
fail("ADC/DIN alignment backlog exceeded its bound");
|
||||
}
|
||||
const auto received_at_ns = monotonic_ns();
|
||||
while (!pending_adc.empty() && !pending_din.empty() && !stop_requested && !restart_requested) {
|
||||
const auto value = pending_adc.front(); pending_adc.pop_front();
|
||||
const auto bits = pending_din.front(); pending_din.pop_front();
|
||||
const auto at_ns = epoch_ns + static_cast<uint64_t>(static_cast<long double>(sample_index) * 1e9L / adc_hz);
|
||||
if (at_ns > received_at_ns) { fail("ADC source clock leads host monotonic clock; restart acquisition"); }
|
||||
const auto channel = sample_index++ % 2U;
|
||||
const bool gate = (bits & kDiSyn2) != 0;
|
||||
if (!initialized || (!previous_gate && gate)) {
|
||||
if (gate) {
|
||||
pairs.reset_packet(); reference_sum = {}; reference_count = 0;
|
||||
words.clear(); frames = 0; ++sweep_id;
|
||||
metadata = {sweep_id, sequencer ? sequencer->cycle_id() : sweep_id, at_ns, at_ns,
|
||||
0xffff, 0xffff, !initialized};
|
||||
if (sequencer) {
|
||||
const auto combo = sequencer->current_combo();
|
||||
metadata.input = static_cast<uint16_t>(combo.input_pos);
|
||||
metadata.output = static_cast<uint16_t>(combo.output_pos);
|
||||
metadata.dirty = metadata.dirty || sequencer->current_dirty();
|
||||
}
|
||||
active = true;
|
||||
}
|
||||
initialized = true;
|
||||
}
|
||||
if (active && previous_gate && !gate) { finish_sweep(at_ns, true); }
|
||||
previous_gate = gate;
|
||||
if (!active) { continue; }
|
||||
const bool di1 = (bits & 1U) != 0, di2 = (bits & 2U) != 0, di8 = (bits & kDi8) != 0;
|
||||
if (channel == 0) {
|
||||
pairs.add_pending_ch1(value, di1, di2, di8);
|
||||
} else {
|
||||
if (pairs.pending_ch1_valid && pairs.pending_ch1_di1_high == di1 && pairs.pending_ch1_di2_high == di2) {
|
||||
if (pairs.pending_ch1_di8_high && di8) {
|
||||
reference_sum[0] += pairs.pending_ch1_raw;
|
||||
reference_sum[1] += value;
|
||||
++reference_count;
|
||||
} else if (!pairs.pending_ch1_di8_high && !di8) {
|
||||
if (!pairs.run_initialized) { pairs.start_run(di1); }
|
||||
else if (pairs.run_di1_high != di1) { finish_step(); pairs.start_run(di1); }
|
||||
pairs.add_pair_sample(di2, pairs.pending_ch1_raw, value);
|
||||
}
|
||||
}
|
||||
pairs.clear_pending_ch1();
|
||||
if (++frames >= max_frames) { finish_sweep(at_ns, false); }
|
||||
}
|
||||
}
|
||||
}
|
||||
// Stop discards the incomplete current sweep. It never advances the switches.
|
||||
writer.shutdown();
|
||||
stop_session();
|
||||
return 0;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
try {
|
||||
bind_to_parent();
|
||||
return run(parse_args(argc, argv));
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "Collector error: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
#include "tty_protocol_writer.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
#include <exception>
|
||||
#include <stdexcept>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <cerrno>
|
||||
#include <condition_variable>
|
||||
#include <fcntl.h>
|
||||
#include <limits.h>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <pty.h>
|
||||
#include <sstream>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <termios.h>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace {
|
||||
|
||||
std::string io_error(const std::string& action, const std::string& path) {
|
||||
std::ostringstream out;
|
||||
out << action << " '" << path << "': " << std::strerror(errno);
|
||||
return out.str();
|
||||
}
|
||||
|
||||
void close_fd_if_open(int& fd) noexcept {
|
||||
if (fd >= 0) {
|
||||
::close(fd);
|
||||
fd = -1;
|
||||
}
|
||||
}
|
||||
|
||||
void set_fd_raw(int fd) {
|
||||
struct termios tio {};
|
||||
if (::tcgetattr(fd, &tio) != 0) {
|
||||
throw std::runtime_error(io_error("Cannot read tty attributes for", std::to_string(fd)));
|
||||
}
|
||||
::cfmakeraw(&tio);
|
||||
tio.c_cc[VMIN] = 1;
|
||||
tio.c_cc[VTIME] = 0;
|
||||
if (::tcsetattr(fd, TCSANOW, &tio) != 0) {
|
||||
throw std::runtime_error(io_error("Cannot apply raw tty attributes to", std::to_string(fd)));
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<std::string> read_link_target(const std::string& path) {
|
||||
std::array<char, PATH_MAX> buf {};
|
||||
const ssize_t len = ::readlink(path.c_str(), buf.data(), buf.size() - 1U);
|
||||
if (len < 0) {
|
||||
if (errno == EINVAL || errno == ENOENT) {
|
||||
return std::nullopt;
|
||||
}
|
||||
throw std::runtime_error(io_error("Cannot read symlink", path));
|
||||
}
|
||||
buf[static_cast<std::size_t>(len)] = '\0';
|
||||
return std::string(buf.data());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
struct TtyProtocolWriter::Impl {
|
||||
explicit Impl(std::size_t bytes) : capacity_bytes(bytes) {}
|
||||
|
||||
int fd = -1;
|
||||
int slave_fd = -1;
|
||||
std::string slave_path;
|
||||
bool owns_link = false;
|
||||
const std::size_t capacity_bytes;
|
||||
std::deque<std::vector<std::uint8_t>> packets;
|
||||
std::size_t queued_bytes = 0;
|
||||
|
||||
mutable std::mutex mutex;
|
||||
std::condition_variable data_ready_cv;
|
||||
std::thread worker;
|
||||
bool stop_requested = false;
|
||||
std::exception_ptr failure;
|
||||
StatsSnapshot stats;
|
||||
};
|
||||
|
||||
TtyProtocolWriter::TtyProtocolWriter(std::string path, std::size_t ring_capacity_bytes)
|
||||
: path_(std::move(path)),
|
||||
impl_(std::make_unique<Impl>(ring_capacity_bytes)) {
|
||||
std::array<char, PATH_MAX> slave_name {};
|
||||
if (::openpty(&impl_->fd, &impl_->slave_fd, slave_name.data(), nullptr, nullptr) != 0) {
|
||||
throw std::runtime_error(io_error("Cannot create PTY bridge for", path_));
|
||||
}
|
||||
|
||||
try {
|
||||
impl_->slave_path = slave_name.data();
|
||||
set_fd_raw(impl_->slave_fd);
|
||||
|
||||
struct stat st {};
|
||||
if (::lstat(path_.c_str(), &st) == 0) {
|
||||
if (!S_ISLNK(st.st_mode) && !S_ISREG(st.st_mode)) {
|
||||
throw std::runtime_error("Refusing to replace non-link path '" + path_ + "'");
|
||||
}
|
||||
if (::unlink(path_.c_str()) != 0) {
|
||||
throw std::runtime_error(io_error("Cannot remove existing tty link", path_));
|
||||
}
|
||||
} else if (errno != ENOENT) {
|
||||
throw std::runtime_error(io_error("Cannot inspect tty link path", path_));
|
||||
}
|
||||
|
||||
if (::symlink(impl_->slave_path.c_str(), path_.c_str()) != 0) {
|
||||
throw std::runtime_error(io_error("Cannot create tty symlink", path_));
|
||||
}
|
||||
impl_->owns_link = true;
|
||||
} catch (...) {
|
||||
close_fd_if_open(impl_->slave_fd);
|
||||
close_fd_if_open(impl_->fd);
|
||||
throw;
|
||||
}
|
||||
|
||||
try {
|
||||
const auto flags = ::fcntl(impl_->fd, F_GETFL);
|
||||
if (flags < 0 || ::fcntl(impl_->fd, F_SETFL, flags | O_NONBLOCK) < 0) {
|
||||
throw std::runtime_error(io_error("Cannot make tty output nonblocking", path_));
|
||||
}
|
||||
impl_->worker = std::thread([this]() { worker_loop(); });
|
||||
} catch (...) {
|
||||
if (impl_->owns_link) {
|
||||
::unlink(path_.c_str());
|
||||
}
|
||||
close_fd_if_open(impl_->slave_fd);
|
||||
close_fd_if_open(impl_->fd);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
TtyProtocolWriter::~TtyProtocolWriter() {
|
||||
try {
|
||||
shutdown();
|
||||
} catch (...) {
|
||||
}
|
||||
|
||||
if (!impl_) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (impl_->owns_link && !path_.empty()) {
|
||||
try {
|
||||
const auto target = read_link_target(path_);
|
||||
if (target && (*target == impl_->slave_path)) {
|
||||
::unlink(path_.c_str());
|
||||
}
|
||||
} catch (...) {
|
||||
}
|
||||
impl_->owns_link = false;
|
||||
}
|
||||
|
||||
close_fd_if_open(impl_->slave_fd);
|
||||
close_fd_if_open(impl_->fd);
|
||||
}
|
||||
|
||||
void TtyProtocolWriter::enqueue_sweep(const std::vector<uint16_t>& words, const SweepMetadata& metadata) {
|
||||
if (words.empty()) { return; }
|
||||
if (words.size() % 4U != 0U || words.size() > 65534U * 8U ||
|
||||
metadata.source_id == 0 || metadata.cycle_id == 0 ||
|
||||
metadata.capture_start_ns == 0 || metadata.capture_end_ns <= metadata.capture_start_ns) {
|
||||
throw std::runtime_error("Invalid RPH sweep packet");
|
||||
}
|
||||
std::vector<std::uint8_t> packet{'R', 'P', 'H', '1'};
|
||||
packet.reserve(48U + words.size() * 2U);
|
||||
const auto append = [&](std::uint64_t value, unsigned bytes) {
|
||||
for (unsigned i = 0; i < bytes; ++i) { packet.push_back(static_cast<std::uint8_t>(value >> (i * 8U))); }
|
||||
};
|
||||
append(words.size() * 2U, 4);
|
||||
append(metadata.source_id, 8);
|
||||
append(metadata.cycle_id, 8);
|
||||
append(metadata.capture_start_ns, 8);
|
||||
append(metadata.capture_end_ns, 8);
|
||||
append(metadata.input, 2);
|
||||
append(metadata.output, 2);
|
||||
append(metadata.dirty, 4);
|
||||
for (const auto word : words) { append(word, 2); }
|
||||
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
if (impl_->failure) { std::rethrow_exception(impl_->failure); }
|
||||
if (impl_->stop_requested) { throw std::runtime_error("tty writer is already shut down"); }
|
||||
if (packet.size() > impl_->capacity_bytes) {
|
||||
throw std::runtime_error("RPH sweep exceeds tty queue capacity");
|
||||
}
|
||||
while (impl_->queued_bytes + packet.size() > impl_->capacity_bytes) {
|
||||
impl_->stats.frames_dropped += (impl_->packets.front().size() - 48U) / 8U;
|
||||
impl_->queued_bytes -= impl_->packets.front().size();
|
||||
impl_->packets.pop_front();
|
||||
++impl_->stats.ring_overflows;
|
||||
}
|
||||
impl_->queued_bytes += packet.size();
|
||||
impl_->packets.push_back(std::move(packet));
|
||||
impl_->data_ready_cv.notify_one();
|
||||
}
|
||||
|
||||
TtyProtocolWriter::StatsSnapshot TtyProtocolWriter::stats() const {
|
||||
if (!impl_) {
|
||||
return {};
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
return impl_->stats;
|
||||
}
|
||||
|
||||
const std::string& TtyProtocolWriter::path() const {
|
||||
return path_;
|
||||
}
|
||||
|
||||
void TtyProtocolWriter::throw_if_failed() const {
|
||||
if (!impl_) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::exception_ptr failure;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
failure = impl_->failure;
|
||||
}
|
||||
|
||||
if (failure) {
|
||||
std::rethrow_exception(failure);
|
||||
}
|
||||
}
|
||||
|
||||
void TtyProtocolWriter::shutdown() {
|
||||
if (!impl_) {
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
impl_->stop_requested = true;
|
||||
}
|
||||
|
||||
impl_->data_ready_cv.notify_all();
|
||||
|
||||
if (impl_->worker.joinable()) {
|
||||
impl_->worker.join();
|
||||
}
|
||||
close_fd_if_open(impl_->fd);
|
||||
}
|
||||
|
||||
void TtyProtocolWriter::worker_loop() {
|
||||
for (;;) {
|
||||
std::vector<std::uint8_t> packet;
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(impl_->mutex);
|
||||
impl_->data_ready_cv.wait(lock, [this]() {
|
||||
return impl_->stop_requested || impl_->failure || !impl_->packets.empty();
|
||||
});
|
||||
if (impl_->failure || impl_->stop_requested) { return; }
|
||||
packet = std::move(impl_->packets.front());
|
||||
impl_->packets.pop_front();
|
||||
impl_->queued_bytes -= packet.size();
|
||||
}
|
||||
// Once a packet starts writing it is indivisible, even if the reader
|
||||
// stalls. Overflow drops only complete packets still in the queue.
|
||||
const std::uint8_t* bytes = packet.data();
|
||||
std::size_t remaining = packet.size();
|
||||
while (remaining != 0U) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
if (impl_->stop_requested) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const ssize_t written = ::write(impl_->fd, bytes, remaining);
|
||||
if (written < 0) {
|
||||
if (errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
if ((errno == EAGAIN) || (errno == EWOULDBLOCK) || (errno == EIO)) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
if (impl_->stop_requested) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
continue;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
if (!impl_->stop_requested) {
|
||||
impl_->failure = std::make_exception_ptr(
|
||||
std::runtime_error(io_error("Cannot write tty frame to", path_)));
|
||||
}
|
||||
impl_->data_ready_cv.notify_all();
|
||||
return;
|
||||
}
|
||||
if (written == 0) {
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
if (!impl_->stop_requested) {
|
||||
impl_->failure = std::make_exception_ptr(
|
||||
std::runtime_error("tty write returned 0 bytes for '" + path_ + "'"));
|
||||
}
|
||||
impl_->data_ready_cv.notify_all();
|
||||
return;
|
||||
}
|
||||
|
||||
bytes += static_cast<std::size_t>(written);
|
||||
remaining -= static_cast<std::size_t>(written);
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
impl_->stats.frames_written += (packet.size() - 48U) / 8U;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// Exercise the production collector with a test-only SDK and a real PTY.
|
||||
#define main collector_program_main
|
||||
#include "../rph_radar_collector/src/main.cpp"
|
||||
#undef main
|
||||
#include <sys/wait.h>
|
||||
#include <fcntl.h>
|
||||
#include <fstream>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
|
||||
|
||||
void test_source_session_restart() {
|
||||
char directory[] = "/tmp/rph-collector-XXXXXX";
|
||||
if (!::mkdtemp(directory)) { fail("mkdtemp failed"); }
|
||||
const std::string tty = std::string(directory) + "/tty", log = std::string(directory) + "/sdk.log";
|
||||
const auto child = ::fork();
|
||||
if (child == 0) {
|
||||
::setenv("RPH_SDK_LOG", log.c_str(), 1);
|
||||
Config cfg;
|
||||
cfg.tty_path = tty;
|
||||
cfg.switching = Json::parse(R"({"input":{"name":"input","driver_mode":"mock","positions":2,"default_position":0,"gpio_chip":"/dev/unused","pin_a":1,"pin_b":2},"output":{"name":"output","driver_mode":"mock","positions":2,"default_position":0,"gpio_chip":"/dev/unused","pin_a":3,"pin_b":4},"combos":[[0,0],[0,1]],"settling_ms":0})");
|
||||
try { ::_exit(run(cfg)); }
|
||||
catch (const std::exception& error) { std::cerr << error.what() << '\n'; ::_exit(2); }
|
||||
}
|
||||
if (child < 0) { std::filesystem::remove_all(directory); fail("collector fork failed"); }
|
||||
int fd = -1, status = 0;
|
||||
std::vector<unsigned char> packets;
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(3);
|
||||
bool finished = false;
|
||||
const auto drain = [&]() {
|
||||
unsigned char bytes[4096];
|
||||
while (fd >= 0) {
|
||||
const auto count = ::read(fd, bytes, sizeof(bytes));
|
||||
if (count <= 0) { break; }
|
||||
packets.insert(packets.end(), bytes, bytes + count);
|
||||
}
|
||||
};
|
||||
while (!finished && std::chrono::steady_clock::now() < deadline) {
|
||||
if (fd < 0) { fd = ::open(tty.c_str(), O_RDONLY | O_NONBLOCK); }
|
||||
drain();
|
||||
finished = ::waitpid(child, &status, WNOHANG) == child;
|
||||
if (!finished) { ::usleep(1000); }
|
||||
}
|
||||
drain();
|
||||
if (fd >= 0) { ::close(fd); }
|
||||
if (!finished) { ::kill(child, SIGKILL); (void)::waitpid(child, &status, 0); }
|
||||
std::ifstream file(log);
|
||||
const std::string calls{std::istreambuf_iterator<char>(file), std::istreambuf_iterator<char>()};
|
||||
std::filesystem::remove_all(directory);
|
||||
if (!finished || !WIFEXITED(status) || WEXITSTATUS(status)) { fail("SDK session regression did not finish successfully"); }
|
||||
const std::string session = "Create\nOpen\nStreamsStop\nStreamsDisable\nConfigure\nOutCycleLoadStart\nPrepareData\nSend\nOutCycleSetup WAIT_DONE\nStart\nOutCycleStop WAIT_DONE\nStreamsStop\nStreamsDisable\nClose\nFree\n";
|
||||
if (calls != session + session + session + session) { fail("SDK session/DOUT restart order changed: " + calls); }
|
||||
const auto read_le = [&packets](std::size_t offset, unsigned size) {
|
||||
uint64_t value = 0;
|
||||
for (unsigned i = 0; i < size; ++i) { value |= uint64_t{packets.at(offset + i)} << (8 * i); }
|
||||
return value;
|
||||
};
|
||||
unsigned clean = 0, total = 0;
|
||||
for (std::size_t offset = 0; offset < packets.size();) {
|
||||
if (packets.size() - offset < 48 || std::memcmp(packets.data() + offset, "RPH1", 4)) { fail("Invalid SDK fixture packet"); }
|
||||
const auto payload = read_le(offset + 4, 4), dirty = read_le(offset + 44, 4);
|
||||
if (payload != 16 || offset + 48 + payload > packets.size()) { fail("Incomplete phase sweep from SDK fixture"); }
|
||||
++total;
|
||||
if (!dirty) {
|
||||
++clean;
|
||||
if (read_le(offset + 8, 8) != 2 * clean || read_le(offset + 16, 8) != (clean + 1) / 2 ||
|
||||
read_le(offset + 40, 2) != 0 || read_le(offset + 42, 2) != (clean - 1) % 2 ||
|
||||
static_cast<int16_t>(read_le(offset + 52, 2)) != pack_raw_code_to_int16(10000 * clean)) {
|
||||
fail("Old ADC block crossed GPIO change, or source/cycle identity reset");
|
||||
}
|
||||
}
|
||||
offset += 48 + payload;
|
||||
}
|
||||
if (clean != 3 || total != 6) { fail("Expected only new-session full sweeps and dirty initial partials"); }
|
||||
}
|
||||
|
||||
int main() {
|
||||
try {
|
||||
test_source_session_restart();
|
||||
for (const auto* period : {"do8_cycle_period:262145", "do8_cycle_period:4294967295"}) {
|
||||
std::vector<std::string> args{"collector", "profile:phase", "clock:internal", "tty:/tmp/unused",
|
||||
"do1_toggle_per_frame", "do1_pair_subtract_avg", "do8_freq_ref", period};
|
||||
std::vector<char*> argv;
|
||||
for (auto& arg : args) { argv.push_back(arg.data()); }
|
||||
bool rejected = false;
|
||||
try { (void)parse_args(static_cast<int>(argv.size()), argv.data()); }
|
||||
catch (const std::runtime_error& error) {
|
||||
rejected = std::string(error.what()).find("cyclic pattern exceeds") != std::string::npos;
|
||||
}
|
||||
if (!rejected) { throw std::runtime_error("DO8 overflow must be rejected before allocation or SDK access"); }
|
||||
}
|
||||
// Preserve the application's low-minus-high IQ averaging and quantization.
|
||||
Do1PairSubtractAverageState state;
|
||||
state.start_run(false);
|
||||
state.add_pair_sample(false, 1000, -3000);
|
||||
state.add_pair_sample(false, 5000, 1000);
|
||||
state.add_pair_sample(true, 200, -1000);
|
||||
state.add_pair_sample(true, 1000, -1000);
|
||||
if (state.diff_count != 2 || state.avg_diff_ch1() != 2400 || state.avg_diff_ch2() != 0 ||
|
||||
pack_raw_code_to_int16(X502_ADC_SCALE_CODE_MAX) != 32767 ||
|
||||
pack_raw_code_to_int16(-2 * X502_ADC_SCALE_CODE_MAX) != -32768) {
|
||||
throw std::runtime_error("phase IQ subtraction/averaging changed");
|
||||
}
|
||||
// An SDK teardown can hang or ignore SIGTERM; parent death must still
|
||||
// release the ADC. Reap the orphan here so the regression leaks nothing.
|
||||
if (::prctl(PR_SET_CHILD_SUBREAPER, 1) != 0) { throw std::runtime_error("subreaper failed"); }
|
||||
int ready[2], identity[2];
|
||||
if (::pipe(ready) != 0 || ::pipe(identity) != 0) { throw std::runtime_error("pipe failed"); }
|
||||
const auto owner = ::fork();
|
||||
if (owner == 0) {
|
||||
const auto collector = ::fork();
|
||||
if (collector < 0) { ::_exit(2); }
|
||||
if (collector == 0) {
|
||||
::close(ready[0]);
|
||||
::signal(SIGTERM, SIG_IGN);
|
||||
bind_to_parent();
|
||||
const char started = 1;
|
||||
if (::write(ready[1], &started, 1) != 1) { ::_exit(2); }
|
||||
for (;;) { ::pause(); }
|
||||
}
|
||||
::close(ready[1]);
|
||||
if (::write(identity[1], &collector, sizeof(collector)) != sizeof(collector)) { ::_exit(2); }
|
||||
char started = 0;
|
||||
::_exit(::read(ready[0], &started, 1) == 1 ? 0 : 2);
|
||||
}
|
||||
if (owner < 0) { throw std::runtime_error("fork failed"); }
|
||||
::close(ready[0]);
|
||||
::close(ready[1]);
|
||||
::close(identity[1]);
|
||||
int status = 0;
|
||||
(void)::waitpid(owner, &status, 0);
|
||||
pid_t child = 0;
|
||||
if (::read(identity[0], &child, sizeof(child)) != sizeof(child)) { throw std::runtime_error("missing child PID"); }
|
||||
::close(identity[0]);
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(1);
|
||||
pid_t orphan = 0;
|
||||
while ((orphan = ::waitpid(-1, &status, WNOHANG)) == 0 && std::chrono::steady_clock::now() < deadline) {
|
||||
::usleep(1000);
|
||||
}
|
||||
if (orphan <= 0 || !WIFSIGNALED(status) || WTERMSIG(status) != SIGKILL) {
|
||||
if (child > 0) { ::kill(child, SIGKILL); (void)::waitpid(child, &status, 0); }
|
||||
throw std::runtime_error("collector survived parent death or entered SIGTERM teardown");
|
||||
}
|
||||
std::cout << "collector regressions passed\n";
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
#include "capture_file_writer.h"
|
||||
#include "channel_bundle_support.hpp"
|
||||
#include "data_preprocessor.hpp"
|
||||
#include "locator/tcp_server.hpp"
|
||||
@@ -7,6 +6,8 @@
|
||||
#include "runtime_wait.hpp"
|
||||
#include "shm_ring.hpp"
|
||||
#include "tty_protocol_writer.h"
|
||||
#include "switch_sequencer.h"
|
||||
#include <fcntl.h>
|
||||
|
||||
#include <sys/mman.h>
|
||||
#include <sys/socket.h>
|
||||
@@ -307,40 +308,6 @@ void test_cosine_notch_taper() {
|
||||
}
|
||||
}
|
||||
|
||||
void test_csv_failures(const Workspace& workspace) {
|
||||
const auto csv = workspace.path / "capture.csv";
|
||||
const auto spool = workspace.path / "capture.capture_spool.bin";
|
||||
CaptureFileWriter writer(csv.string(), (workspace.path / "capture.svg").string(), "", "");
|
||||
writer.initialize_csv(2, false);
|
||||
CapturePacket packet{.ch1 = {1, 2}, .ch2 = {3, 4}, .di1 = {}};
|
||||
auto invalid_packet = packet;
|
||||
invalid_packet.ch2.pop_back();
|
||||
expect_error([&] { writer.append_csv_packet(invalid_packet); });
|
||||
require(std::filesystem::file_size(spool) == 16, "invalid packet partially changed the spool");
|
||||
writer.append_csv_packet(packet);
|
||||
std::filesystem::remove(csv);
|
||||
std::filesystem::create_symlink("/dev/full", csv);
|
||||
expect_error([&] { writer.write({packet}, 1000, 10); });
|
||||
require(std::filesystem::file_size(spool) > 16, "failed CSV write deleted the recovery spool");
|
||||
std::filesystem::remove(csv);
|
||||
writer.write({packet}, 1000, 10);
|
||||
require(!std::filesystem::exists(spool), "successful CSV export retained the spool");
|
||||
std::ifstream csv_input(csv);
|
||||
const std::string csv_text((std::istreambuf_iterator<char>(csv_input)), {});
|
||||
require(csv_text.find("1.000000000,3.000000000") != std::string::npos,
|
||||
"CSV retry lost channel samples");
|
||||
writer.initialize_csv(2, false);
|
||||
std::ofstream(spool, std::ios::binary | std::ios::app).put('x');
|
||||
expect_error([&] { writer.write({}, 1000, 10); });
|
||||
require(std::filesystem::exists(spool), "truncated packet header deleted the spool");
|
||||
writer.initialize_csv(2, false);
|
||||
const std::array<std::uint64_t, 4> corrupt_packet{0, 2, 0xffffffffffffffffULL, 0};
|
||||
std::ofstream(spool, std::ios::binary | std::ios::app).write(
|
||||
reinterpret_cast<const char*>(corrupt_packet.data()), sizeof(corrupt_packet));
|
||||
expect_error([&] { writer.write({}, 1000, 10); });
|
||||
require(std::filesystem::exists(spool), "oversized packet count deleted the spool");
|
||||
}
|
||||
|
||||
void test_asset_reload_preserves_data(const Workspace& workspace) {
|
||||
const auto save = [&](const char* name, float sample) {
|
||||
return save_bundle(workspace, name, {.traces = {
|
||||
@@ -497,10 +464,77 @@ void test_locator_packet_errors() {
|
||||
for (unsigned iteration = 0; iteration < 30; ++iteration) { server.start(); server.stop(); }
|
||||
}
|
||||
|
||||
void test_tty_sweep_overflow(const Workspace& workspace) {
|
||||
TtyProtocolWriter writer((workspace.path / "overflow_tty").string(), 32'096);
|
||||
const int fd = ::open(writer.path().c_str(), O_RDONLY | O_NONBLOCK);
|
||||
require(fd >= 0, "Cannot open test PTY");
|
||||
std::vector<std::uint16_t> words(8'000, 42);
|
||||
for (std::uint64_t id = 1; id <= 100; ++id) {
|
||||
writer.enqueue_sweep(words, {id, id, id * 100, id * 100 + 50, 1, 2, false});
|
||||
}
|
||||
require(writer.stats().ring_overflows > 0, "test did not overflow the queue");
|
||||
std::vector<std::uint8_t> received;
|
||||
std::uint64_t last_id = 0;
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
|
||||
while (last_id < 100 && std::chrono::steady_clock::now() < deadline) {
|
||||
std::array<std::uint8_t, 8192> chunk{};
|
||||
const auto n = ::read(fd, chunk.data(), chunk.size());
|
||||
if (n > 0) { received.insert(received.end(), chunk.begin(), chunk.begin() + n); }
|
||||
while (received.size() >= 16'048) {
|
||||
require(std::memcmp(received.data(), "RPH1", 4) == 0, "overflow truncated a sweep header");
|
||||
const auto value = [&](std::size_t at, unsigned size) {
|
||||
std::uint64_t result = 0;
|
||||
for (unsigned i = 0; i < size; ++i) { result |= std::uint64_t{received[at + i]} << (8U * i); }
|
||||
return result;
|
||||
};
|
||||
require(value(4, 4) == 16'000 && value(8, 8) > last_id, "lost framing or source order");
|
||||
last_id = value(8, 8);
|
||||
require(value(16, 8) == last_id && value(24, 8) == last_id * 100 &&
|
||||
value(32, 8) == last_id * 100 + 50 && value(40, 2) == 1 && value(42, 2) == 2,
|
||||
"sweep lost source identity, timing, or combo");
|
||||
require(received[48] == 42 && received[16'047] == 0, "corrupt point payload");
|
||||
received.erase(received.begin(), received.begin() + 16'048);
|
||||
}
|
||||
if (n <= 0) { std::this_thread::sleep_for(std::chrono::milliseconds(1)); }
|
||||
}
|
||||
::close(fd);
|
||||
require(last_id == 100 && received.empty(), "latest complete sweep did not arrive");
|
||||
}
|
||||
|
||||
void test_switch_backlog() {
|
||||
drivers::H7992MinimalDriverSettings settings{.mode = config::DriverMode::Mock};
|
||||
rph::SwitchSequencer sequencer({.combos = {{0, 0}, {0, 1}}},
|
||||
std::make_unique<drivers::H7992MinimalDriver>(settings),
|
||||
std::make_unique<drivers::H7992MinimalDriver>(settings));
|
||||
sequencer.open();
|
||||
sequencer.session_started();
|
||||
require(!sequencer.on_sweep_end(false), "duration limit requested a new session");
|
||||
require(sequencer.current_combo().output_pos == 0 && sequencer.cycle_id() == 1,
|
||||
"duration limit switched hardware before the physical sweep ended");
|
||||
require(sequencer.on_sweep_end(), "GPIO change must request a new source session");
|
||||
for (unsigned i = 0; i < 10; ++i) {
|
||||
require(sequencer.current_dirty(), "queued pre-switch sweep incorrectly clean");
|
||||
require(!sequencer.on_sweep_end(), "dirty backlog requested another session");
|
||||
require(sequencer.current_combo().output_pos == 1 && sequencer.cycle_id() == 1,
|
||||
"dirty backlog advanced the combo or cycle");
|
||||
}
|
||||
sequencer.session_started();
|
||||
require(!sequencer.current_dirty(), "new source session must permit a complete sweep");
|
||||
sequencer.on_sweep_end();
|
||||
require(sequencer.current_combo().output_pos == 0 && sequencer.cycle_id() == 2,
|
||||
"clean cycle did not advance");
|
||||
rph::SwitchSequencer single({.combos = {{0, 0}}},
|
||||
std::make_unique<drivers::H7992MinimalDriver>(settings),
|
||||
std::make_unique<drivers::H7992MinimalDriver>(settings));
|
||||
single.open(); single.session_started();
|
||||
require(!single.on_sweep_end() && single.cycle_id() == 2 && !single.current_dirty(),
|
||||
"unchanged combination restarted its source or stopped advancing cycles");
|
||||
}
|
||||
|
||||
void test_unread_tty_shutdown(const Workspace& workspace) {
|
||||
TtyProtocolWriter writer((workspace.path / "tty").string(), 160'000);
|
||||
TtyProtocolWriter writer((workspace.path / "tty").string(), 160'048);
|
||||
const std::vector<std::uint16_t> words(80'000, 42);
|
||||
writer.enqueue_encoded_frames(words.data(), words.size() / 4U);
|
||||
writer.enqueue_sweep(words, {1, 1, 100, 200});
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
writer.shutdown();
|
||||
@@ -524,9 +558,10 @@ int main(int argc, char** argv) {
|
||||
test_acquisition_identity();
|
||||
test_interruptible_wait();
|
||||
test_cosine_notch_taper();
|
||||
test_csv_failures(workspace);
|
||||
test_locator_packet_errors();
|
||||
test_tty_sweep_overflow(workspace);
|
||||
test_switch_backlog();
|
||||
test_unread_tty_shutdown(workspace);
|
||||
test_locator_packet_errors();
|
||||
std::cout << "native regressions passed (10,000 decoder mutations; 20,000 concurrent ring frames)\n";
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "native regression failed: " << error.what() << '\n';
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// SDK contract fixture: Stop retains queued input; only a new descriptor is fresh.
|
||||
#include "x502api.h"
|
||||
#include "e502api.h"
|
||||
#include <algorithm>
|
||||
#include <csignal>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace {
|
||||
struct Session { unsigned id; bool consumed = false; std::vector<uint32_t> input; };
|
||||
unsigned sessions = 0;
|
||||
void record(const char* name) { std::ofstream(std::getenv("RPH_SDK_LOG"), std::ios::app) << name << '\n'; }
|
||||
Session& session(t_x502_hnd hnd) { return *reinterpret_cast<Session*>(hnd); }
|
||||
void generate(Session& s) {
|
||||
for (unsigned sweep = 0; sweep < 4; ++sweep) {
|
||||
for (unsigned step = 0; step < 2; ++step) {
|
||||
for (unsigned n = 0; n < 8; ++n) {
|
||||
const bool high = n % 4 >= 2;
|
||||
s.input.push_back(high ? 0 : 10000 * s.id);
|
||||
s.input.push_back((1U << 13) | step | (high ? 2 : 0));
|
||||
}
|
||||
}
|
||||
for (unsigned n = 0; n < 4; ++n) { s.input.push_back(0); s.input.push_back(0); }
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
extern "C" int32_t E502_OpenUsb(t_x502_hnd hnd, const char *serial) { record("Open"); return 0; }
|
||||
extern "C" int32_t X502_AsyncOutDig(t_x502_hnd hnd, uint32_t val, uint32_t msk) { return 0; }
|
||||
extern "C" int32_t X502_Close(t_x502_hnd hnd) { record("Close"); return 0; }
|
||||
extern "C" int32_t X502_Configure(t_x502_hnd hnd, uint32_t flags) { record("Configure"); return 0; }
|
||||
extern "C" t_x502_hnd X502_Create(void) { record("Create"); auto* s = new Session{++sessions}; generate(*s); return reinterpret_cast<t_x502_hnd>(s); }
|
||||
extern "C" int32_t X502_Free(t_x502_hnd hnd) { record("Free"); delete &session(hnd); return 0; }
|
||||
extern "C" int32_t X502_GetAdcFreq(t_x502_hnd hnd, double *f_acq, double *f_frame) { *f_acq = 2000000; *f_frame = 1000000; return 0; }
|
||||
extern "C" const char* X502_GetErrorString(int32_t err) { return "SDK contract fixture"; }
|
||||
extern "C" int32_t X502_GetRecvReadyCount(t_x502_hnd hnd, uint32_t *rdy_cnt) { *rdy_cnt = session(hnd).input.size(); return 0; }
|
||||
extern "C" int32_t X502_OutCycleLoadStart(t_x502_hnd hnd, uint32_t size) { record("OutCycleLoadStart"); return 0; }
|
||||
extern "C" int32_t X502_OutCycleSetup(t_x502_hnd hnd, uint32_t flags) { if (flags != X502_OUT_CYCLE_FLAGS_WAIT_DONE) { return -1; } record("OutCycleSetup WAIT_DONE"); return 0; }
|
||||
extern "C" int32_t X502_OutCycleStop(t_x502_hnd hnd, uint32_t flags) { if (flags != X502_OUT_CYCLE_FLAGS_WAIT_DONE) { return -1; } record("OutCycleStop WAIT_DONE"); return 0; }
|
||||
extern "C" int32_t X502_PrepareData(t_x502_hnd hnd, const double* dac1, const double* dac2, const uint32_t* digout, uint32_t size, int32_t flags, uint32_t* out_buf) { record("PrepareData"); std::copy_n(digout, size, out_buf); return 0; }
|
||||
extern "C" int32_t X502_ProcessData(t_x502_hnd hnd, const uint32_t* src, uint32_t size, uint32_t flags, double *adc_data, uint32_t *adc_data_size, uint32_t *din_data, uint32_t *din_data_size) { *adc_data_size = *din_data_size = size / 2;
|
||||
for (unsigned n = 0; n < size / 2; ++n) {
|
||||
adc_data[n] = static_cast<int32_t>(src[2 * n]); din_data[n] = src[2 * n + 1];
|
||||
}
|
||||
return 0; }
|
||||
extern "C" int32_t X502_Recv(t_x502_hnd hnd, uint32_t* buf, uint32_t size, uint32_t tout) { auto& s = session(hnd); usleep(20000);
|
||||
if (s.consumed) { raise(SIGTERM); return 0; }
|
||||
if (size < s.input.size()) { return -1; }
|
||||
std::copy(s.input.begin(), s.input.end(), buf); s.consumed = true;
|
||||
return static_cast<int32_t>(s.input.size()); }
|
||||
extern "C" int32_t X502_Send(t_x502_hnd hnd, const uint32_t* buf, uint32_t size, uint32_t tout) { record("Send"); return size; }
|
||||
extern "C" int32_t X502_SetAdcFreq(t_x502_hnd hnd, double *f_acq, double *f_frame) { *f_acq = 2000000; if (f_frame) { *f_frame = 1000000; } return 0; }
|
||||
extern "C" int32_t X502_SetDigInPullup(t_x502_hnd hnd, uint32_t pullups) { return 0; }
|
||||
extern "C" int32_t X502_SetDinFreq(t_x502_hnd hnd, double *f_din) { return 0; }
|
||||
extern "C" int32_t X502_SetLChannel(t_x502_hnd hnd, uint32_t lch, uint32_t phy_ch, uint32_t mode, uint32_t range, uint32_t avg) { return 0; }
|
||||
extern "C" int32_t X502_SetLChannelCount(t_x502_hnd hnd, uint32_t lch_cnt) { return 0; }
|
||||
extern "C" int32_t X502_SetMode(t_x502_hnd hnd, uint32_t mode) { return 0; }
|
||||
extern "C" int32_t X502_SetOutFreq(t_x502_hnd hnd, double *f_dout) { return 0; }
|
||||
extern "C" int32_t X502_SetRefFreq(t_x502_hnd hnd, uint32_t freq) { return 0; }
|
||||
extern "C" int32_t X502_SetStreamBufSize(t_x502_hnd hnd, uint32_t ch, uint32_t size) { return 0; }
|
||||
extern "C" int32_t X502_SetStreamStep(t_x502_hnd hnd, uint32_t dma_ch, uint32_t step) { return 0; }
|
||||
extern "C" int32_t X502_SetSyncMode(t_x502_hnd hnd, uint32_t sync_mode) { return 0; }
|
||||
extern "C" int32_t X502_SetSyncStartMode(t_x502_hnd hnd, uint32_t sync_start_mode) { return 0; }
|
||||
extern "C" int32_t X502_StreamsDisable(t_x502_hnd hnd, uint32_t streams) { record("StreamsDisable"); return 0; }
|
||||
extern "C" int32_t X502_StreamsEnable(t_x502_hnd hnd, uint32_t streams) { return 0; }
|
||||
extern "C" int32_t X502_StreamsStart(t_x502_hnd hnd) { record("Start"); if (session(hnd).id == 4) { raise(SIGTERM); } return 0; }
|
||||
extern "C" int32_t X502_StreamsStop(t_x502_hnd hnd) { record("StreamsStop"); return 0; }
|
||||
+1
-1
@@ -181,7 +181,7 @@ else
|
||||
"${venv}/bin/python" -m pip install -r "${target}/requirements.txt"
|
||||
fi
|
||||
say "building the C++ part"
|
||||
make -C "${target}" -j"$(nproc)" all kamil_adc_collector
|
||||
make -C "${target}" -j"$(nproc)" all rph_radar_collector
|
||||
|
||||
echo "${archive##*/}" > "${target}/RELEASE"
|
||||
trap - ERR
|
||||
|
||||
@@ -90,8 +90,9 @@ multi-device: `python_app/hardware_full/librevna_driver/protocol/frame.py`,
|
||||
Структура — `Protocol.hpp:186`: `uint32 ExtRefOuputFreq` плюс битовое поле
|
||||
`AutomaticSwitch` и `UseExternalRef`.
|
||||
|
||||
Шлём `output_frequency_hz = 0` и флаг внешней опоры: выход `Ref Out` не
|
||||
включаем, а устройству принудительно указываем использовать внешние 10 МГц.
|
||||
Шлём `output_frequency_hz = 0` (выход `Ref Out` не включаем) и `UseExternalRef` из
|
||||
`multi_device.force_external_reference` профиля; `AutomaticSwitch` всегда 0. Во всех
|
||||
профилях сейчас `false`, то есть приборы работают от внутренней опоры.
|
||||
Строит `build_reference_settings_payload` (`protocol.py:48`), отправляет
|
||||
`controller.py`.
|
||||
|
||||
|
||||
@@ -31,14 +31,14 @@ _SWEEP_FIELDS = (
|
||||
("radar.sweep.power_dbm", "_power_input"),
|
||||
)
|
||||
_ADC_FIELDS = (
|
||||
("radar.kamil_adc.project_dir", "_adc_project_dir_input"),
|
||||
("radar.kamil_adc.executable_path", "_adc_executable_path_input"),
|
||||
("radar.kamil_adc.tty_path", "_adc_tty_path_input"),
|
||||
("radar.kamil_adc.args", "_adc_args_input"),
|
||||
("radar.kamil_adc.env", "_adc_env_input"),
|
||||
("radar.kamil_adc.startup_timeout_s", "_adc_startup_timeout_s_input"),
|
||||
("radar.kamil_adc.sweep_timeout_s", "_adc_sweep_timeout_s_input"),
|
||||
("radar.kamil_adc.stop_timeout_s", "_adc_stop_timeout_s_input"),
|
||||
("radar.rph_radar.project_dir", "_adc_project_dir_input"),
|
||||
("radar.rph_radar.executable_path", "_adc_executable_path_input"),
|
||||
("radar.rph_radar.tty_path", "_adc_tty_path_input"),
|
||||
("radar.rph_radar.args", "_adc_args_input"),
|
||||
("radar.rph_radar.env", "_adc_env_input"),
|
||||
("radar.rph_radar.startup_timeout_s", "_adc_startup_timeout_s_input"),
|
||||
("radar.rph_radar.sweep_timeout_s", "_adc_sweep_timeout_s_input"),
|
||||
("radar.rph_radar.stop_timeout_s", "_adc_stop_timeout_s_input"),
|
||||
("radar.laser_control.enabled", "_optical_enabled_checkbox"),
|
||||
("radar.laser_control.port", "_optical_port_input"),
|
||||
("radar.laser_control.mode", "_optical_mode_combo"),
|
||||
@@ -50,7 +50,6 @@ _ADC_FIELDS = (
|
||||
("radar.laser_control.manual.temp2", "_optical_manual_temp2_input"),
|
||||
("radar.laser_control.manual.current1", "_optical_manual_current1_input"),
|
||||
("radar.laser_control.manual.current2", "_optical_manual_current2_input"),
|
||||
("radar.laser_control.variation.variation_type", "_optical_variation_type_combo"),
|
||||
("radar.laser_control.variation.static_temp1", "_optical_static_temp1_input"),
|
||||
("radar.laser_control.variation.static_temp2", "_optical_static_temp2_input"),
|
||||
("radar.laser_control.variation.static_current1", "_optical_static_current1_input"),
|
||||
@@ -137,7 +136,7 @@ class ConfigController:
|
||||
return Path(str(self._scalar("_save_path_input"))).expanduser(), str(self._scalar("_save_name_input")), prefix
|
||||
|
||||
def _read_radar(self, config: RunConfigModel) -> None:
|
||||
for path, widget_id in (*_SWEEP_FIELDS, *(_ADC_FIELDS if config.is_kamil_adc else ())):
|
||||
for path, widget_id in (*_SWEEP_FIELDS, *(_ADC_FIELDS if config.is_rph_radar else ())):
|
||||
owner, name = _model_field(config, path)
|
||||
original = getattr(owner, name)
|
||||
value = self._scalar(widget_id)
|
||||
|
||||
@@ -21,7 +21,7 @@ from python_app.gui.runtime.constraints import validate_processing_mode_constrai
|
||||
from python_app.gui.runtime.history import build_processor_run_signature, build_run_history_signature
|
||||
from python_app.gui.runtime.pipeline_pause import PipelinePause
|
||||
from python_app.gui.settings_defaults import env_enabled
|
||||
from python_app.hardware_full.kamil_adc import apply_kamil_adc_laser_control
|
||||
from python_app.hardware_full.rph_radar.laser import apply_rph_radar_laser_control
|
||||
from python_app.hardware_full.single_radar_service import create_single_radar_service
|
||||
from python_app.models.collection_identity import collection_key, index_collections
|
||||
from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection
|
||||
@@ -331,7 +331,7 @@ class PipelineController:
|
||||
self._prepare_radar(config)
|
||||
logger.info("Radar settings applied: start=%g Hz, stop=%g Hz, points=%s, ifbw=%g Hz, power=%g dBm",
|
||||
config.radar.sweep.start_hz, config.radar.sweep.stop_hz,
|
||||
"from Kamil ADC stream" if config.is_kamil_adc else config.radar.sweep.points,
|
||||
"from RadioPhotonic radar (RPH) stream" if config.is_rph_radar else config.radar.sweep.points,
|
||||
config.radar.sweep.if_bandwidth_hz, config.radar.sweep.power_dbm)
|
||||
except InterruptedError:
|
||||
self.operation_cancelled()
|
||||
@@ -344,9 +344,9 @@ class PipelineController:
|
||||
logger.info("Radar pre-configuration skipped (mock mode)")
|
||||
elif config.is_multi_device:
|
||||
logger.info("Multi-device raw producer will configure all LibreVNA devices")
|
||||
elif config.is_kamil_adc:
|
||||
applied = apply_kamil_adc_laser_control(config)
|
||||
logger.info("Kamil ADC laser_control %s", "applied via Apply Radar" if applied else "disabled")
|
||||
elif config.is_rph_radar:
|
||||
applied = apply_rph_radar_laser_control(config)
|
||||
logger.info("RadioPhotonic radar (RPH) laser_control %s", "applied via Apply Radar" if applied else "disabled")
|
||||
else:
|
||||
service = create_single_radar_service(config)
|
||||
try:
|
||||
|
||||
@@ -134,7 +134,7 @@ class ProfileController:
|
||||
self.state.gui = profile.gui or default_gui_state_for_config(profile.run_config, self._project_root)
|
||||
_remember(self.state, path, self._session, self._log)
|
||||
config, sweep = profile.run_config, profile.run_config.radar.sweep
|
||||
points = "from Kamil ADC stream" if config.is_kamil_adc else str(sweep.points)
|
||||
points = "from RadioPhotonic radar (RPH) stream" if config.is_rph_radar else str(sweep.points)
|
||||
self._log.info(
|
||||
f"Config profile saved: path={path}, combos={len(config.combos)}, "
|
||||
f"sweep={sweep.start_hz:g}..{sweep.stop_hz:g} Hz, points={points}, "
|
||||
|
||||
@@ -48,7 +48,7 @@ class RadarSettingsController:
|
||||
|
||||
def sync_profile(self, combo_mode: str) -> None:
|
||||
self.set_combo_mode(combo_mode)
|
||||
adc = self._defaults().is_kamil_adc
|
||||
adc = self._defaults().is_rph_radar
|
||||
self._radar.vna_panel.setVisible(not adc)
|
||||
self._radar.adc_panel.setVisible(adc)
|
||||
self.sync_adc_controls()
|
||||
@@ -79,7 +79,7 @@ class RadarSettingsController:
|
||||
|
||||
def refresh_limits(self) -> bool:
|
||||
config = self._defaults()
|
||||
if config.is_kamil_adc:
|
||||
if config.is_rph_radar:
|
||||
return self.apply_limits(None)
|
||||
service = (LibreVnaService(serial=config.radar.serial or None) if config.is_multi_device
|
||||
else create_single_radar_service(config))
|
||||
@@ -136,7 +136,10 @@ class RadarSettingsController:
|
||||
|
||||
def sync_frequency_limits(self) -> bool:
|
||||
try:
|
||||
bounds = sorted(float(widget.text()) / 1e6 for widget in self._radar.sweep_edits[:2])
|
||||
config = self._defaults()
|
||||
band = config.radar.rph_radar.band
|
||||
bounds = ([band.start_hz / 1e6, band.stop_hz / 1e6] if config.is_rph_radar else
|
||||
sorted(float(widget.text()) / 1e6 for widget in self._radar.sweep_edits[:2]))
|
||||
except ValueError:
|
||||
return False
|
||||
changed = False
|
||||
|
||||
@@ -348,6 +348,7 @@ def build_processing_group(config: RunConfigModel, gui: GuiStateModel) -> Proces
|
||||
form.addRow(processing_mode_pages)
|
||||
form.addRow(gpr_common_page)
|
||||
|
||||
band = config.radar.rph_radar.band if config.is_rph_radar else config.radar.sweep
|
||||
for binding in GUI_FIELDS:
|
||||
if binding.path[0] == "processing":
|
||||
widget = view.fields[binding.spec.widget]
|
||||
@@ -355,7 +356,7 @@ def build_processing_group(config: RunConfigModel, gui: GuiStateModel) -> Proces
|
||||
raise TypeError("Geometry is not a scalar GUI setting")
|
||||
if binding.path[-1] in {"start_freq_mhz", "stop_freq_mhz"}:
|
||||
assert isinstance(widget, QDoubleSpinBox)
|
||||
widget.setRange(config.radar.sweep.start_hz / 1e6, config.radar.sweep.stop_hz / 1e6)
|
||||
widget.setRange(band.start_hz / 1e6, band.stop_hz / 1e6)
|
||||
if binding.spec.live in {"gpr_input_positions", "gpr_output_positions"}:
|
||||
assert isinstance(widget, QLineEdit)
|
||||
widget.setValidator(QRegularExpressionValidator(QRegularExpression(POSITION_CSV_PATTERN), widget))
|
||||
|
||||
@@ -62,8 +62,8 @@ def build_radar_group(config: RunConfigModel) -> RadarView:
|
||||
|
||||
layout.addWidget(vna_radar_settings_panel)
|
||||
layout.addWidget(adc_radar_settings_panel)
|
||||
vna_radar_settings_panel.setVisible(not config.is_kamil_adc)
|
||||
adc_radar_settings_panel.setVisible(config.is_kamil_adc)
|
||||
vna_radar_settings_panel.setVisible(not config.is_rph_radar)
|
||||
adc_radar_settings_panel.setVisible(config.is_rph_radar)
|
||||
return RadarView(group=group, fields=view.fields,
|
||||
sweep_edits=(start_hz_input, stop_hz_input, points_input, ifbw_input, power_input),
|
||||
sweep_labels=radar_labels,
|
||||
@@ -80,7 +80,7 @@ def _build_adc_settings_panel(view: SectionView, config: RunConfigModel):
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(8)
|
||||
|
||||
adc = config.radar.kamil_adc
|
||||
adc = config.radar.rph_radar
|
||||
optical = config.radar.laser_control
|
||||
manual = optical.manual
|
||||
variation = optical.variation
|
||||
@@ -110,16 +110,6 @@ def _build_adc_settings_panel(view: SectionView, config: RunConfigModel):
|
||||
optical_manual_current1_input = view.add("_optical_manual_current1_input", QLineEdit(f"{manual.current1:g}"))
|
||||
optical_manual_current2_input = view.add("_optical_manual_current2_input", QLineEdit(f"{manual.current2:g}"))
|
||||
|
||||
optical_variation_type_combo = view.add("_optical_variation_type_combo", QComboBox())
|
||||
optical_variation_type_combo.addItems(
|
||||
[
|
||||
"CHANGE_CURRENT_LD1",
|
||||
"CHANGE_CURRENT_LD2",
|
||||
"CHANGE_TEMPERATURE_LD1",
|
||||
"CHANGE_TEMPERATURE_LD2",
|
||||
]
|
||||
)
|
||||
write_setting(optical_variation_type_combo, variation.variation_type)
|
||||
optical_static_temp1_input = view.add("_optical_static_temp1_input", QLineEdit(f"{variation.static_temp1:g}"))
|
||||
optical_static_temp2_input = view.add("_optical_static_temp2_input", QLineEdit(f"{variation.static_temp2:g}"))
|
||||
optical_static_current1_input = view.add("_optical_static_current1_input", QLineEdit(f"{variation.static_current1:g}"))
|
||||
@@ -166,7 +156,6 @@ def _build_adc_settings_panel(view: SectionView, config: RunConfigModel):
|
||||
optical_variation_panel = build_two_column_form_widget(
|
||||
panel,
|
||||
[
|
||||
("Variation type", optical_variation_type_combo),
|
||||
("Static temp 1", optical_static_temp1_input),
|
||||
("Static temp 2", optical_static_temp2_input),
|
||||
("Static current 1", optical_static_current1_input),
|
||||
@@ -186,7 +175,7 @@ def _build_adc_settings_panel(view: SectionView, config: RunConfigModel):
|
||||
(optical_port_input, optical_mode_combo, optical_pi_coeff1_p_input, optical_pi_coeff1_i_input,
|
||||
optical_pi_coeff2_p_input, optical_pi_coeff2_i_input),
|
||||
(optical_manual_temp1_input, optical_manual_temp2_input, optical_manual_current1_input, optical_manual_current2_input),
|
||||
(optical_variation_type_combo, optical_static_temp1_input, optical_static_temp2_input,
|
||||
(optical_static_temp1_input, optical_static_temp2_input,
|
||||
optical_static_current1_input, optical_static_current2_input, optical_min_value_input,
|
||||
optical_max_value_input, optical_step_input, optical_time_step_input, optical_delay_time_input),
|
||||
optical_manual_panel, optical_variation_panel)
|
||||
|
||||
@@ -60,18 +60,10 @@ def build_processor_run_signature(
|
||||
locator settings, live-file path and polling interval require a restart.
|
||||
"""
|
||||
combos_signature = tuple((int(combo.input), int(combo.output)) for combo in config.combos)
|
||||
sweep_points_signature: object = "adc" if config.is_kamil_adc else int(config.radar.sweep.points)
|
||||
return (
|
||||
str(config.radar.model),
|
||||
config.radar_key(),
|
||||
config.is_multi_device and config.radar.multi_device.force_external_reference,
|
||||
str(config.radar.driver_mode),
|
||||
str(config.radar.serial),
|
||||
tuple(str(value) for value in config.radar.multi_device.slave_serials),
|
||||
bool(config.radar.multi_device.force_external_reference),
|
||||
float(config.radar.sweep.start_hz),
|
||||
float(config.radar.sweep.stop_hz),
|
||||
sweep_points_signature,
|
||||
float(config.radar.sweep.if_bandwidth_hz),
|
||||
float(config.radar.sweep.power_dbm),
|
||||
str(config.input_switch.driver_mode),
|
||||
int(config.input_switch.positions),
|
||||
str(config.output_switch.driver_mode),
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Kamil ADC reference-channel acquisition.
|
||||
|
||||
Layered into small, single-responsibility modules:
|
||||
|
||||
* :mod:`~python_app.hardware_full.kamil_adc.protocol` — TTY wire format → aligned
|
||||
:class:`RawSweep` (pure, incremental parser).
|
||||
* :mod:`~python_app.hardware_full.kamil_adc.processing` — reference-phase
|
||||
frequency axis, amplitude normalization, crop + resample to a fixed grid (pure).
|
||||
* :mod:`~python_app.hardware_full.kamil_adc.tty_reader` — background thread that
|
||||
drains the TTY and publishes the latest sweep.
|
||||
* :mod:`~python_app.hardware_full.kamil_adc.service` — collector process lifecycle
|
||||
and ``acquire() -> SweepResult``.
|
||||
* :mod:`~python_app.hardware_full.kamil_adc.laser` — pre-acquisition laser setup.
|
||||
"""
|
||||
|
||||
from python_app.hardware_full.kamil_adc.laser import apply_kamil_adc_laser_control
|
||||
from python_app.hardware_full.kamil_adc.processing import (
|
||||
KamilAdcProcessingParams,
|
||||
KamilAdcSweepProcessor,
|
||||
)
|
||||
from python_app.hardware_full.kamil_adc.protocol import (
|
||||
KamilAdcStreamParser,
|
||||
RawSweep,
|
||||
)
|
||||
from python_app.hardware_full.kamil_adc.service import KamilAdcService
|
||||
from python_app.hardware_full.kamil_adc.tty_reader import KamilAdcTtyReader
|
||||
|
||||
__all__ = [
|
||||
"KamilAdcProcessingParams",
|
||||
"KamilAdcService",
|
||||
"KamilAdcStreamParser",
|
||||
"KamilAdcSweepProcessor",
|
||||
"KamilAdcTtyReader",
|
||||
"RawSweep",
|
||||
"apply_kamil_adc_laser_control",
|
||||
]
|
||||
@@ -1,129 +0,0 @@
|
||||
"""Laser-controller setup applied before Kamil ADC acquisition.
|
||||
|
||||
This mirrors the legacy ``device_main`` command sequence: connect to the laser
|
||||
controller, reset it, and apply either manual or variation mode per
|
||||
``radar.laser_control``. The wire protocol is unchanged from the standalone tool;
|
||||
only its home moved into the project.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from python_app.models.run_config_schema import RunConfigModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def apply_kamil_adc_laser_control(config: RunConfigModel) -> bool:
|
||||
"""Apply configured laser settings via the device_main command sequence.
|
||||
|
||||
Returns ``True`` when settings were applied, ``False`` when laser control is
|
||||
disabled. The controller is always disconnected before returning.
|
||||
"""
|
||||
laser = config.radar.laser_control
|
||||
if not laser.enabled:
|
||||
logger.debug("Kamil ADC laser control disabled; skipping")
|
||||
return False
|
||||
|
||||
_validate_laser_control_config(config)
|
||||
|
||||
from python_app.hardware_full.laser_control.controller import (
|
||||
DEVICE_MAIN_MESSAGE_ID,
|
||||
LaserController,
|
||||
)
|
||||
from python_app.hardware_full.laser_control.models import VariationType
|
||||
|
||||
controller = LaserController(
|
||||
port=laser.port,
|
||||
pi_coeff1_p=laser.pi_coeff1_p,
|
||||
pi_coeff1_i=laser.pi_coeff1_i,
|
||||
pi_coeff2_p=laser.pi_coeff2_p,
|
||||
pi_coeff2_i=laser.pi_coeff2_i,
|
||||
)
|
||||
try:
|
||||
controller.connect()
|
||||
controller.reset()
|
||||
mode = laser.mode.strip().lower()
|
||||
logger.info("Applying Kamil ADC laser control in %s mode", mode)
|
||||
if mode == "manual":
|
||||
manual = laser.manual
|
||||
controller.set_manual_mode(
|
||||
temp1=manual.temp1,
|
||||
temp2=manual.temp2,
|
||||
current1=manual.current1,
|
||||
current2=manual.current2,
|
||||
message_id=DEVICE_MAIN_MESSAGE_ID,
|
||||
)
|
||||
return True
|
||||
if mode == "variation":
|
||||
variation = laser.variation
|
||||
try:
|
||||
variation_type = VariationType[variation.variation_type]
|
||||
except KeyError as exc:
|
||||
raise ValueError(
|
||||
f"Unsupported radar.laser_control.variation.variation_type: {variation.variation_type}"
|
||||
) from exc
|
||||
|
||||
controller.set_manual_mode(
|
||||
temp1=variation.static_temp1,
|
||||
temp2=variation.static_temp2,
|
||||
current1=variation.static_current1,
|
||||
current2=variation.static_current2,
|
||||
message_id=DEVICE_MAIN_MESSAGE_ID,
|
||||
)
|
||||
controller.start_variation(
|
||||
variation_type=variation_type,
|
||||
params={
|
||||
"static_temp1": variation.static_temp1,
|
||||
"static_temp2": variation.static_temp2,
|
||||
"static_current1": variation.static_current1,
|
||||
"static_current2": variation.static_current2,
|
||||
"min_value": variation.min_value,
|
||||
"max_value": variation.max_value,
|
||||
"step": variation.step,
|
||||
"time_step": variation.time_step,
|
||||
"delay_time": variation.delay_time,
|
||||
},
|
||||
)
|
||||
_write_variation_session(variation)
|
||||
return True
|
||||
raise RuntimeError(f"Unsupported laser_control mode: {laser.mode}")
|
||||
finally:
|
||||
controller.disconnect()
|
||||
|
||||
|
||||
def _write_variation_session(variation) -> None:
|
||||
"""Freeze the variation's static temperature targets for the checker.
|
||||
|
||||
Best-effort: a failure to write the session snapshot must never abort the
|
||||
acquisition setup, so any error is logged and swallowed.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
from python_app.hardware_full.laser_control.monitoring.session import (
|
||||
LaserVariationSession,
|
||||
)
|
||||
|
||||
LaserVariationSession(
|
||||
variation_type=variation.variation_type,
|
||||
target_temp1=variation.static_temp1,
|
||||
target_temp2=variation.static_temp2,
|
||||
tolerance_c=variation.temp_tolerance_c,
|
||||
started_at_iso=datetime.now().isoformat(timespec="seconds"),
|
||||
).save()
|
||||
logger.debug("Wrote laser variation session snapshot for the temperature checker")
|
||||
except Exception: # noqa: BLE001 — session snapshot is auxiliary, never fatal
|
||||
logger.warning("Failed to write laser variation session snapshot", exc_info=True)
|
||||
|
||||
|
||||
def _validate_laser_control_config(config: RunConfigModel) -> None:
|
||||
laser = config.radar.laser_control
|
||||
if not laser.port:
|
||||
raise ValueError("radar.laser_control.port is required when laser_control is enabled")
|
||||
mode = laser.mode.strip().lower()
|
||||
if mode not in {"manual", "variation"}:
|
||||
raise ValueError("radar.laser_control.mode must be 'manual' or 'variation'")
|
||||
if mode == "variation" and not laser.variation.variation_type:
|
||||
raise ValueError("radar.laser_control.variation.variation_type is required")
|
||||
@@ -1,171 +0,0 @@
|
||||
"""Wire protocol for the Kamil ADC collector's TTY stream.
|
||||
|
||||
The collector publishes a stream of 8-byte little-endian frames, four 16-bit
|
||||
words each: ``[marker, step, ch1, ch2]`` (``ch1``/``ch2`` signed). Three frame
|
||||
kinds appear in ``do8_freq_ref`` mode:
|
||||
|
||||
* **Sweep boundary** — ``marker, 0xFFFF, 0xFFFF, 0xFFFF``. Delimits sweeps.
|
||||
* **Main point** — ``0x000A, step, I, Q``. One complex main sample at ``step``.
|
||||
* **Reference point** — ``0x00A8, step, I, Q``. The reference sample paired with
|
||||
the main sample of the same ``step`` (emitted only where the DI8 loopback
|
||||
settled, so reference points are sparser than main points).
|
||||
|
||||
:class:`KamilAdcStreamParser` consumes raw bytes incrementally and yields one
|
||||
:class:`RawSweep` per completed sweep. Main and reference points are aligned by
|
||||
step index; only steps carrying *both* survive (a step needs its reference for
|
||||
the frequency axis and its main for the signal). Anything other than the three
|
||||
known frame kinds is a protocol violation and raises — the reader fails fast and
|
||||
lets the supervisor relaunch a clean collector rather than silently resyncing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import struct
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
# Wire-format constants.
|
||||
FRAME_BYTES = 8
|
||||
MAIN_MARKER = 0x000A
|
||||
REFERENCE_MARKER = 0x00A8
|
||||
# Combo tag — ``0x00C0, input_pos, output_pos, dirty``. Emitted by the switch-aware
|
||||
# collector right after a sweep boundary to label the upcoming sweep with the RF
|
||||
# switch combination it was captured under (``dirty != 0`` means the sweep straddled
|
||||
# a switch transition and must be dropped). Absent in the standalone/calibration
|
||||
# collector, where every sweep carries no combo (``combo is None``).
|
||||
COMBO_MARKER = 0x00C0
|
||||
_BOUNDARY_STEP = 0xFFFF
|
||||
|
||||
# marker (u16), step (u16), ch1 (i16), ch2 (i16) — point frames carry signed I/Q.
|
||||
_FRAME = struct.Struct("<HHhh")
|
||||
# The last three words of a boundary frame are all 0xFFFF; a real step is
|
||||
# 1..0xFFFE, so this tail unambiguously marks a sweep boundary regardless of the
|
||||
# (profile-dependent) start marker.
|
||||
_BOUNDARY_TAIL = b"\xff\xff\xff\xff\xff\xff"
|
||||
# Frame-alignment anchor: the phase-profile sweep-boundary frame.
|
||||
_START_FRAME = struct.pack("<HHHH", MAIN_MARKER, 0xFFFF, 0xFFFF, 0xFFFF)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RawSweep:
|
||||
"""One sweep's main and reference samples, aligned and ordered by step.
|
||||
|
||||
``steps`` is strictly ascending; ``main`` and ``reference`` are the complex
|
||||
samples at those steps. All three arrays share the same length.
|
||||
"""
|
||||
|
||||
steps: NDArray[np.int32]
|
||||
main: NDArray[np.complex64]
|
||||
reference: NDArray[np.complex64]
|
||||
combo: tuple[int, int] | None = None
|
||||
dirty: bool = False
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return int(self.steps.size)
|
||||
|
||||
|
||||
class KamilAdcStreamParser:
|
||||
"""Incremental, frame-aligning parser turning the TTY byte stream into sweeps.
|
||||
|
||||
Usage: call :meth:`feed` with each chunk of bytes; it returns the list of
|
||||
sweeps completed by that chunk (usually zero or one). The parser is stateful
|
||||
but holds no I/O and is cheap to unit-test.
|
||||
"""
|
||||
|
||||
__slots__ = ("_buffer", "_aligned", "_main", "_reference", "_pending_combo", "_pending_dirty")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._buffer = bytearray()
|
||||
self._aligned = False
|
||||
self._main: dict[int, complex] = {}
|
||||
self._reference: dict[int, complex] = {}
|
||||
# Combo tag for the sweep currently being accumulated (set by the combo
|
||||
# frame right after each boundary; ``None`` in non-switch collector modes).
|
||||
self._pending_combo: tuple[int, int] | None = None
|
||||
self._pending_dirty = False
|
||||
|
||||
def feed(self, data: bytes) -> list[RawSweep]:
|
||||
"""Append ``data`` and return any sweeps completed by it."""
|
||||
self._buffer.extend(data)
|
||||
if not self._aligned and not self._align():
|
||||
return []
|
||||
|
||||
sweeps: list[RawSweep] = []
|
||||
buffer = self._buffer
|
||||
while len(buffer) >= FRAME_BYTES:
|
||||
frame = bytes(buffer[:FRAME_BYTES])
|
||||
del buffer[:FRAME_BYTES]
|
||||
if frame[2:] == _BOUNDARY_TAIL:
|
||||
sweep = self._take_sweep()
|
||||
if sweep is not None:
|
||||
sweeps.append(sweep)
|
||||
continue
|
||||
marker, step, real, imag = _FRAME.unpack(frame)
|
||||
if marker == MAIN_MARKER:
|
||||
self._main[step] = complex(real, imag)
|
||||
elif marker == REFERENCE_MARKER:
|
||||
self._reference[step] = complex(real, imag)
|
||||
elif marker == COMBO_MARKER:
|
||||
# step = input_pos, real = output_pos, imag = dirty flag.
|
||||
self._pending_combo = (int(step), int(real))
|
||||
self._pending_dirty = imag != 0
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Kamil ADC protocol violation: unexpected frame marker 0x{marker:04x}"
|
||||
)
|
||||
return sweeps
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Drop all buffered state (e.g. after the collector is relaunched)."""
|
||||
self._buffer.clear()
|
||||
self._aligned = False
|
||||
self._main.clear()
|
||||
self._reference.clear()
|
||||
self._pending_combo = None
|
||||
self._pending_dirty = False
|
||||
|
||||
def _align(self) -> bool:
|
||||
"""Discard pre-roll up to and including the first sweep boundary.
|
||||
|
||||
Returns ``True`` once frame-aligned. Keeps a short tail so a boundary
|
||||
split across two feeds can still be found on the next chunk.
|
||||
"""
|
||||
index = self._buffer.find(_START_FRAME)
|
||||
if index < 0:
|
||||
if len(self._buffer) >= FRAME_BYTES:
|
||||
del self._buffer[: -(FRAME_BYTES - 1)]
|
||||
return False
|
||||
del self._buffer[: index + FRAME_BYTES]
|
||||
self._main.clear()
|
||||
self._reference.clear()
|
||||
self._pending_combo = None
|
||||
self._pending_dirty = False
|
||||
self._aligned = True
|
||||
return True
|
||||
|
||||
def _take_sweep(self) -> RawSweep | None:
|
||||
"""Assemble the buffered points into a sweep and reset for the next one."""
|
||||
shared = sorted(self._main.keys() & self._reference.keys())
|
||||
main = self._main
|
||||
reference = self._reference
|
||||
combo = self._pending_combo
|
||||
dirty = self._pending_dirty
|
||||
self._main = {}
|
||||
self._reference = {}
|
||||
# The next sweep's combo is set by its own combo frame (right after this
|
||||
# boundary); clear so a sweep without one reports combo=None rather than
|
||||
# inheriting a stale tag.
|
||||
self._pending_combo = None
|
||||
self._pending_dirty = False
|
||||
if not shared:
|
||||
return None
|
||||
return RawSweep(
|
||||
steps=np.asarray(shared, dtype=np.int32),
|
||||
main=np.asarray([main[step] for step in shared], dtype=np.complex64),
|
||||
reference=np.asarray([reference[step] for step in shared], dtype=np.complex64),
|
||||
combo=combo,
|
||||
dirty=dirty,
|
||||
)
|
||||
@@ -1,3 +1 @@
|
||||
"""Vendored laser-control package used by the Kamil ADC hardware path."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
"""Vendored laser-control package used by the RadioPhotonic radar (RPH) hardware path."""
|
||||
|
||||
@@ -9,43 +9,8 @@ laser control board.
|
||||
BAUDRATE = 115200
|
||||
SERIAL_TIMEOUT_SEC = 1.0
|
||||
|
||||
GET_DATA_TOTAL_LENGTH = 30 # bytes in device DATA response
|
||||
SEND_PARAMS_TOTAL_LENGTH = 30 # bytes in DECODE_ENABLE command
|
||||
TASK_ENABLE_COMMAND_LENGTH = 32 # bytes in TASK_ENABLE command
|
||||
|
||||
WAIT_AFTER_SEND_SEC = 0.15 # delay after sending a command
|
||||
|
||||
# ---- Command codes (as sent to device, already flipped to LE)
|
||||
|
||||
CMD_DECODE_ENABLE = 0x1111 # Set control parameters
|
||||
CMD_DEFAULT_ENABLE = 0x2222 # Reset device
|
||||
CMD_TRANS_ENABLE = 0x4444 # Request last data
|
||||
CMD_REMOVE_FILE = 0x5555 # Delete saved data
|
||||
CMD_STATE = 0x6666 # Request state
|
||||
CMD_TASK_ENABLE = 0x7777 # Start a task
|
||||
|
||||
# ---- Error codes from device STATE response (after flipfour)
|
||||
|
||||
STATE_OK = '0000'
|
||||
STATE_SD_ERR = '0001' # SD Card read/write error
|
||||
STATE_UART_ERR = '0002' # Command (UART) error
|
||||
STATE_UART_DECODE_ERR = '0004' # Wrong parameter value
|
||||
STATE_TEC1_ERR = '0008' # Laser 1 TEC driver overheat
|
||||
STATE_TEC2_ERR = '0010' # Laser 2 TEC driver overheat
|
||||
STATE_DEFAULT_ERR = '0020' # System reset error
|
||||
STATE_REMOVE_ERR = '0040' # File deletion error
|
||||
|
||||
STATE_DESCRIPTIONS = {
|
||||
STATE_OK: "All ok.",
|
||||
STATE_SD_ERR: "SD Card reading/writing error (SD_ERR).",
|
||||
STATE_UART_ERR: "Command error (UART_ERR).",
|
||||
STATE_UART_DECODE_ERR:"Wrong parameter value error (UART_DECODE_ERR).",
|
||||
STATE_TEC1_ERR: "Laser 1: TEC driver overheat (TEC1_ERR).",
|
||||
STATE_TEC2_ERR: "Laser 2: TEC driver overheat (TEC2_ERR).",
|
||||
STATE_DEFAULT_ERR: "Resetting system error (DEFAULT_ERR).",
|
||||
STATE_REMOVE_ERR: "File deletion error (REMOVE_ERR).",
|
||||
}
|
||||
|
||||
# ---- Physical / hardware constants (from device_conversion.py)
|
||||
|
||||
VREF = 2.5 # Reference voltage, Volts
|
||||
@@ -60,26 +25,13 @@ R6 = 56000 # Ohm
|
||||
RREF = 10 # Current-setting resistor, Ohm
|
||||
# (@1550 nm – 28.7 Ohm; @840 nm – 10 Ohm)
|
||||
|
||||
# External thermistor divider resistors
|
||||
R7 = 22000 # Ohm
|
||||
R8 = 22000 # Ohm
|
||||
R9 = 5100 # Ohm
|
||||
R10 = 180000 # Ohm
|
||||
|
||||
# Thermistor Steinhart–Hart B-coefficient (internal / external)
|
||||
# Thermistor Steinhart–Hart B-coefficient
|
||||
BETA_INTERNAL = 3900 # K
|
||||
BETA_EXTERNAL = 3455 # K
|
||||
T0_K = 298 # Kelvin (25 °C reference)
|
||||
R0 = 10000 # Ohm (thermistor nominal at 25 °C)
|
||||
|
||||
# ADC resolution
|
||||
# DAC resolution
|
||||
ADC_BITS_16 = 65535 # 2^16 - 1
|
||||
ADC_BITS_12 = 4095 # 2^12 - 1
|
||||
|
||||
# Voltage conversion coefficients
|
||||
U3V3_COEFF = 1.221e-3 # counts → Volts for 3.3V rail
|
||||
U5V_COEFF = 1.8315e-3 # counts → Volts for 5V rails
|
||||
U7V_COEFF = 6.72e-3 # counts → Volts for 7V rail
|
||||
|
||||
# ---- Operational limits (validated in validators.py)
|
||||
|
||||
@@ -93,9 +45,6 @@ CURRENT_MAX_MA = 60.0 # Maximum allowed laser current, mA
|
||||
CURRENT_STEP_MIN_MA = 0.002 # Minimum current variation step, mA
|
||||
CURRENT_STEP_MAX_MA = 0.5 # Maximum current variation step, mA
|
||||
|
||||
TEMP_STEP_MIN_C = 0.05 # Minimum temperature variation step, °C
|
||||
TEMP_STEP_MAX_C = 1.0 # Maximum temperature variation step, °C
|
||||
|
||||
# Time parameter limits
|
||||
TIME_STEP_MIN_US = 20 # Minimum time step, microseconds
|
||||
TIME_STEP_MAX_US = 100 # Maximum time step, microseconds
|
||||
|
||||
@@ -1,355 +1,60 @@
|
||||
"""
|
||||
Main laser controller for the laser control module.
|
||||
"""Board commands with mandatory acknowledgements."""
|
||||
|
||||
Provides a high-level API for controlling dual laser systems.
|
||||
All input parameters are validated before being sent to the device.
|
||||
Can be embedded in any Python application without GUI dependencies.
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional, Callable
|
||||
import time
|
||||
|
||||
from .protocol import Protocol, TaskType as ProtoTaskType
|
||||
from .validators import ParameterValidator
|
||||
from .models import (
|
||||
VariationType,
|
||||
Measurements,
|
||||
)
|
||||
from .exceptions import (
|
||||
CommunicationError,
|
||||
DeviceNotRespondingError,
|
||||
)
|
||||
from .constants import WAIT_AFTER_SEND_SEC
|
||||
from .exceptions import CommunicationError
|
||||
from .protocol import Protocol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default PI regulator coefficients (match firmware defaults)
|
||||
DEFAULT_PI_P = 2560 # 10 * 256
|
||||
DEFAULT_PI_I = 128 # 0.5 * 256
|
||||
DEFAULT_PI_P = 2560
|
||||
DEFAULT_PI_I = 128
|
||||
DEVICE_MAIN_MESSAGE_ID = 0x00FF
|
||||
|
||||
|
||||
class LaserController:
|
||||
"""
|
||||
High-level controller for the dual laser board.
|
||||
"""Control constant setpoints or LD1 current variation through one serial port."""
|
||||
|
||||
Usage example::
|
||||
|
||||
ctrl = LaserController(port='/dev/ttyUSB0')
|
||||
ctrl.connect()
|
||||
ctrl.set_manual_mode(temp1=25.0, temp2=30.0,
|
||||
current1=40.0, current2=35.0)
|
||||
data = ctrl.get_measurements()
|
||||
print(data.voltage_3v3)
|
||||
ctrl.disconnect()
|
||||
|
||||
All public methods raise :class:`ValidationError` for bad parameters
|
||||
and :class:`CommunicationError` for transport-level problems.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
port: Optional[str] = None,
|
||||
pi_coeff1_p: int = DEFAULT_PI_P,
|
||||
pi_coeff1_i: int = DEFAULT_PI_I,
|
||||
pi_coeff2_p: int = DEFAULT_PI_P,
|
||||
pi_coeff2_i: int = DEFAULT_PI_I,
|
||||
on_data: Optional[Callable[[Measurements], None]] = None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
port: Serial port (e.g. '/dev/ttyUSB0'). None = auto-detect.
|
||||
pi_coeff1_p: Proportional coefficient for laser 1 PI regulator.
|
||||
pi_coeff1_i: Integral coefficient for laser 1 PI regulator.
|
||||
pi_coeff2_p: Proportional coefficient for laser 2 PI regulator.
|
||||
pi_coeff2_i: Integral coefficient for laser 2 PI regulator.
|
||||
on_data: Optional callback called whenever new measurements
|
||||
are received. Signature: ``callback(Measurements)``.
|
||||
"""
|
||||
def __init__(self, port: str,
|
||||
pi_coeff1_p: int = DEFAULT_PI_P, pi_coeff1_i: int = DEFAULT_PI_I,
|
||||
pi_coeff2_p: int = DEFAULT_PI_P, pi_coeff2_i: int = DEFAULT_PI_I):
|
||||
self._protocol = Protocol(port)
|
||||
self._pi1_p = pi_coeff1_p
|
||||
self._pi1_i = pi_coeff1_i
|
||||
self._pi2_p = pi_coeff2_p
|
||||
self._pi2_i = pi_coeff2_i
|
||||
self._on_data = on_data
|
||||
self._message_id = 0
|
||||
self._last_measurements: Optional[Measurements] = None
|
||||
# Last manual-mode params, used to restore state after stop_task()
|
||||
self._last_temp1: float = 25.0
|
||||
self._last_temp2: float = 25.0
|
||||
self._last_current1: float = 30.0
|
||||
self._last_current2: float = 30.0
|
||||
|
||||
# ---- Connection -------------------------------------------------------
|
||||
self._pi = dict(pi_coeff1_p=pi_coeff1_p, pi_coeff1_i=pi_coeff1_i,
|
||||
pi_coeff2_p=pi_coeff2_p, pi_coeff2_i=pi_coeff2_i)
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""
|
||||
Open connection to the device.
|
||||
|
||||
Returns:
|
||||
True if connection succeeded.
|
||||
|
||||
Raises:
|
||||
CommunicationError: If the port cannot be opened.
|
||||
"""
|
||||
self._protocol.connect()
|
||||
logger.info("Connected to laser controller on port %s",
|
||||
self._protocol._port_name or "auto")
|
||||
logger.info("Connected to laser controller on port %s", self._protocol.port)
|
||||
return True
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Close the serial port gracefully."""
|
||||
self._protocol.disconnect()
|
||||
logger.info("Disconnected from laser controller")
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""True if the serial port is open."""
|
||||
return self._protocol.is_connected
|
||||
def set_manual_mode(self, temp1: float, temp2: float, current1: float, current2: float,
|
||||
message_id: int) -> None:
|
||||
manual = dict(temp1=temp1, temp2=temp2, current1=current1, current2=current2)
|
||||
self._send_and_read_state(Protocol.encode_decode_enable(**manual, **self._pi, message_id=message_id))
|
||||
logger.debug("Manual setpoints: %s", manual)
|
||||
|
||||
# ---- Public API -------------------------------------------------------
|
||||
|
||||
def set_manual_mode(
|
||||
self,
|
||||
temp1: float,
|
||||
temp2: float,
|
||||
current1: float,
|
||||
current2: float,
|
||||
message_id: Optional[int] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Set manual control parameters for both lasers.
|
||||
|
||||
Args:
|
||||
temp1: Setpoint temperature for laser 1, °C.
|
||||
Valid range: [15.0 … 40.0] °C.
|
||||
temp2: Setpoint temperature for laser 2, °C.
|
||||
Valid range: [15.0 … 40.0] °C.
|
||||
current1: Drive current for laser 1, mA.
|
||||
Valid range: [15.0 … 60.0] mA.
|
||||
current2: Drive current for laser 2, mA.
|
||||
Valid range: [15.0 … 60.0] mA.
|
||||
message_id: Optional fixed DECODE_ENABLE message id. When
|
||||
omitted, the id is incremented for backward
|
||||
compatibility with the refactored API.
|
||||
|
||||
Raises:
|
||||
ValidationError: If any parameter is out of range.
|
||||
CommunicationError: If the command cannot be sent.
|
||||
"""
|
||||
validated = ParameterValidator.validate_manual_mode_params(
|
||||
temp1, temp2, current1, current2
|
||||
)
|
||||
if message_id is None:
|
||||
self._message_id = (self._message_id + 1) & 0xFFFF
|
||||
else:
|
||||
self._message_id = int(message_id) & 0xFFFF
|
||||
|
||||
cmd = Protocol.encode_decode_enable(
|
||||
temp1=validated['temp1'],
|
||||
temp2=validated['temp2'],
|
||||
current1=validated['current1'],
|
||||
current2=validated['current2'],
|
||||
pi_coeff1_p=self._pi1_p,
|
||||
pi_coeff1_i=self._pi1_i,
|
||||
pi_coeff2_p=self._pi2_p,
|
||||
pi_coeff2_i=self._pi2_i,
|
||||
message_id=self._message_id,
|
||||
)
|
||||
self._send_and_read_state(cmd)
|
||||
self._last_temp1 = validated['temp1']
|
||||
self._last_temp2 = validated['temp2']
|
||||
self._last_current1 = validated['current1']
|
||||
self._last_current2 = validated['current2']
|
||||
logger.debug("Manual mode set: T1=%.2f T2=%.2f I1=%.2f I2=%.2f",
|
||||
validated['temp1'], validated['temp2'],
|
||||
validated['current1'], validated['current2'])
|
||||
|
||||
def start_variation(
|
||||
self,
|
||||
variation_type: VariationType,
|
||||
params: dict,
|
||||
) -> None:
|
||||
"""
|
||||
Start a parameter variation task.
|
||||
|
||||
Args:
|
||||
variation_type: Which parameter to vary
|
||||
(:class:`VariationType.CHANGE_CURRENT_LD1` or
|
||||
:class:`VariationType.CHANGE_CURRENT_LD2`).
|
||||
params: Dictionary with the following keys:
|
||||
|
||||
- ``min_value`` – minimum value of the varied parameter.
|
||||
- ``max_value`` – maximum value of the varied parameter.
|
||||
- ``step`` – step size.
|
||||
- ``time_step`` – discretisation time step, µs [20 … 100].
|
||||
- ``delay_time``– delay between pulses, ms [3 … 10].
|
||||
- ``static_temp1`` – fixed temperature for laser 1, °C.
|
||||
- ``static_temp2`` – fixed temperature for laser 2, °C.
|
||||
- ``static_current1`` – fixed current for laser 1, mA.
|
||||
- ``static_current2`` – fixed current for laser 2, mA.
|
||||
|
||||
Raises:
|
||||
ValidationError: If any parameter fails validation.
|
||||
CommunicationError: If the command cannot be sent.
|
||||
"""
|
||||
# Validate variation-specific params
|
||||
validated = ParameterValidator.validate_variation_params(
|
||||
params, variation_type
|
||||
)
|
||||
|
||||
# Validate static parameters
|
||||
static_temp1 = ParameterValidator.validate_temperature(
|
||||
params.get('static_temp1', 25.0), 'static_temp1'
|
||||
)
|
||||
static_temp2 = ParameterValidator.validate_temperature(
|
||||
params.get('static_temp2', 25.0), 'static_temp2'
|
||||
)
|
||||
static_current1 = ParameterValidator.validate_current(
|
||||
params.get('static_current1', 30.0), 'static_current1'
|
||||
)
|
||||
static_current2 = ParameterValidator.validate_current(
|
||||
params.get('static_current2', 30.0), 'static_current2'
|
||||
)
|
||||
|
||||
# Map VariationType → protocol TaskType
|
||||
task_type_map = {
|
||||
VariationType.CHANGE_CURRENT_LD1: ProtoTaskType.CHANGE_CURRENT_LD1,
|
||||
VariationType.CHANGE_CURRENT_LD2: ProtoTaskType.CHANGE_CURRENT_LD2,
|
||||
VariationType.CHANGE_TEMPERATURE_LD1: ProtoTaskType.CHANGE_TEMPERATURE_LD1,
|
||||
VariationType.CHANGE_TEMPERATURE_LD2: ProtoTaskType.CHANGE_TEMPERATURE_LD2,
|
||||
}
|
||||
proto_task = task_type_map[validated['variation_type']]
|
||||
|
||||
cmd = Protocol.encode_task_enable(
|
||||
task_type=proto_task,
|
||||
static_temp1=static_temp1,
|
||||
static_temp2=static_temp2,
|
||||
static_current1=static_current1,
|
||||
static_current2=static_current2,
|
||||
min_value=validated['min_value'],
|
||||
max_value=validated['max_value'],
|
||||
step=validated['step'],
|
||||
time_step=validated['time_step'],
|
||||
delay_time=validated['delay_time'],
|
||||
message_id=self._message_id,
|
||||
pi_coeff1_p=self._pi1_p,
|
||||
pi_coeff1_i=self._pi1_i,
|
||||
pi_coeff2_p=self._pi2_p,
|
||||
pi_coeff2_i=self._pi2_i,
|
||||
)
|
||||
self._send_and_read_state(cmd)
|
||||
logger.info("Variation task started: type=%s min=%.3f max=%.3f step=%.3f",
|
||||
validated['variation_type'].name,
|
||||
validated['min_value'],
|
||||
validated['max_value'],
|
||||
validated['step'])
|
||||
|
||||
def stop_task(self, restore_message_id: Optional[int] = None) -> None:
|
||||
"""Stop the current task and restore manual mode.
|
||||
|
||||
Sends DEFAULT_ENABLE (reset) followed by DECODE_ENABLE with the last
|
||||
known manual-mode parameters. This two-step sequence matches the
|
||||
original firmware protocol: after DEFAULT_ENABLE the board is in a
|
||||
reset state and must receive DECODE_ENABLE before it can respond to
|
||||
TRANS_ENABLE data requests again.
|
||||
"""
|
||||
cmd_reset = Protocol.encode_default_enable()
|
||||
self._send_and_read_state(cmd_reset)
|
||||
logger.info("Task stopped (DEFAULT_ENABLE sent)")
|
||||
|
||||
self.set_manual_mode(
|
||||
temp1=self._last_temp1,
|
||||
temp2=self._last_temp2,
|
||||
current1=self._last_current1,
|
||||
current2=self._last_current2,
|
||||
message_id=restore_message_id,
|
||||
)
|
||||
logger.info("Manual mode restored after task stop")
|
||||
|
||||
def get_measurements(self) -> Optional[Measurements]:
|
||||
"""
|
||||
Request and return the latest measurements from the device.
|
||||
|
||||
Returns:
|
||||
:class:`Measurements` dataclass, or None if no data available.
|
||||
|
||||
Raises:
|
||||
CommunicationError: On transport errors.
|
||||
"""
|
||||
cmd = Protocol.encode_trans_enable()
|
||||
self._send(cmd)
|
||||
|
||||
raw = self._protocol.receive_raw(30)
|
||||
if not raw or len(raw) != 30:
|
||||
logger.warning("No data received from device")
|
||||
return None
|
||||
|
||||
response = Protocol.decode_response(raw)
|
||||
measurements = response.to_measurements()
|
||||
self._last_measurements = measurements
|
||||
|
||||
if self._on_data:
|
||||
self._on_data(measurements)
|
||||
|
||||
return measurements
|
||||
def start_variation(self, params: dict) -> None:
|
||||
self._send_and_read_state(Protocol.encode_task_enable(
|
||||
**params, pi_coeff1_p=self._pi["pi_coeff1_p"], pi_coeff1_i=self._pi["pi_coeff1_i"],
|
||||
pi_coeff2_p=self._pi["pi_coeff2_p"], pi_coeff2_i=self._pi["pi_coeff2_i"],
|
||||
))
|
||||
logger.info("LD1 current variation started: %s", params)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Send a hardware reset command to the device."""
|
||||
cmd = Protocol.encode_default_enable()
|
||||
self._send_and_read_state(cmd)
|
||||
self._send_and_read_state(b"\x22\x22")
|
||||
logger.info("Device reset command sent")
|
||||
|
||||
# ---- Internal helpers -------------------------------------------------
|
||||
|
||||
def _send(self, cmd: bytes) -> None:
|
||||
"""Send command bytes and wait for the device to process."""
|
||||
if not self.is_connected:
|
||||
raise CommunicationError("Not connected to device. Call connect() first.")
|
||||
self._protocol.send_raw(cmd)
|
||||
def _send(self, command: bytes) -> None:
|
||||
self._protocol.send_raw(command)
|
||||
time.sleep(WAIT_AFTER_SEND_SEC)
|
||||
|
||||
def _send_and_read_state(self, cmd: bytes) -> int:
|
||||
"""Send command and read the 2-byte STATE response the device always returns.
|
||||
|
||||
Commands DECODE_ENABLE, TASK_ENABLE and DEFAULT_ENABLE each trigger a
|
||||
STATE reply from the firmware. If we don't consume those bytes here,
|
||||
they accumulate in the serial buffer and corrupt the next DATA read.
|
||||
|
||||
Returns the decoded state code (0x0000 = OK).
|
||||
"""
|
||||
self._send(cmd)
|
||||
raw = self._protocol.receive_raw(2)
|
||||
if raw and len(raw) == 2:
|
||||
state = Protocol.decode_state(raw)
|
||||
if state != 0:
|
||||
# Surface a device-reported non-OK STATE instead of silently
|
||||
# treating a board-rejected command as success.
|
||||
logger.warning(
|
||||
"Device returned non-OK STATE 0x%04x after command: %s",
|
||||
state,
|
||||
Protocol.state_to_description(f"{state:04x}"),
|
||||
)
|
||||
else:
|
||||
logger.debug("STATE response after command: 0x%04x", state)
|
||||
return state
|
||||
return 0
|
||||
|
||||
# ---- Context manager support -----------------------------------------
|
||||
|
||||
def __enter__(self):
|
||||
self.connect()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
# Always try to stop any running task before closing the port.
|
||||
# If we don't, the board stays in TASK state and ignores all future
|
||||
# commands until its power is cycled.
|
||||
if self.is_connected:
|
||||
try:
|
||||
self.stop_task()
|
||||
except Exception:
|
||||
logger.warning("Failed to stop laser task on exit; closing port anyway", exc_info=True)
|
||||
self.disconnect()
|
||||
return False
|
||||
def _send_and_read_state(self, command: bytes) -> None:
|
||||
self._send(command)
|
||||
state = Protocol.decode_state(self._protocol.receive_raw(2))
|
||||
if state:
|
||||
raise CommunicationError(f"Device rejected command: STATE 0x{state:04x}")
|
||||
|
||||
@@ -5,14 +5,7 @@ values used by the device firmware, using the hardware's bridge/divider formulas
|
||||
"""
|
||||
|
||||
import math
|
||||
from .constants import (
|
||||
VREF, R1, R3, R4, R5, R6,
|
||||
R7, R8, R9, R10,
|
||||
RREF,
|
||||
BETA_INTERNAL, BETA_EXTERNAL, T0_K, R0,
|
||||
ADC_BITS_16, ADC_BITS_12,
|
||||
U3V3_COEFF, U5V_COEFF, U7V_COEFF,
|
||||
)
|
||||
from .constants import ADC_BITS_16, BETA_INTERNAL, R0, R1, R3, R4, R5, R6, RREF, T0_K, VREF
|
||||
|
||||
|
||||
def temp_c_to_n(temp_c: float) -> int:
|
||||
@@ -34,38 +27,6 @@ def temp_c_to_n(temp_c: float) -> int:
|
||||
return n
|
||||
|
||||
|
||||
def temp_n_to_c(n: int) -> float:
|
||||
"""
|
||||
Convert 16-bit ADC integer to temperature (°C).
|
||||
|
||||
Args:
|
||||
n: Raw ADC value in [0, 65535].
|
||||
|
||||
Returns:
|
||||
Temperature in degrees Celsius.
|
||||
"""
|
||||
u = n * VREF / ADC_BITS_16
|
||||
rt = R1 * (VREF * R4 * (R5 + R6) - u * R5 * (R3 + R4)) / (
|
||||
u * R5 * (R3 + R4) + VREF * R3 * R6 - VREF * R4 * R5
|
||||
)
|
||||
t = 1 / (1 / T0_K + 1 / BETA_INTERNAL * math.log(rt / R0)) - 273
|
||||
return t
|
||||
|
||||
|
||||
def temp_ext_n_to_c(n: int) -> float:
|
||||
"""
|
||||
Convert 12-bit ADC integer to external thermistor temperature (°C).
|
||||
|
||||
Args:
|
||||
n: Raw 12-bit ADC value in [0, 4095].
|
||||
|
||||
Returns:
|
||||
Temperature in degrees Celsius.
|
||||
"""
|
||||
u = n * VREF / ADC_BITS_12 * 1 / (1 + 100000 / R10) + VREF * R9 / (R8 + R9)
|
||||
rt = R7 * u / (VREF - u)
|
||||
t = 1 / (1 / T0_K + 1 / BETA_EXTERNAL * math.log(rt / R0)) - 273
|
||||
return t
|
||||
|
||||
|
||||
def current_ma_to_n(current_ma: float) -> int:
|
||||
@@ -81,31 +42,3 @@ def current_ma_to_n(current_ma: float) -> int:
|
||||
n = int(ADC_BITS_16 / 2000 * RREF * current_ma)
|
||||
n = max(0, min(ADC_BITS_16, n))
|
||||
return n
|
||||
|
||||
|
||||
def current_n_to_ma(n: int) -> float:
|
||||
"""
|
||||
Convert raw ADC integer to photodiode current (mA).
|
||||
|
||||
Args:
|
||||
n: Raw ADC value in [0, 65535].
|
||||
|
||||
Returns:
|
||||
Current in milliamps.
|
||||
"""
|
||||
return n * 2.5 / (ADC_BITS_16 * 4.4) - 1 / 20.4
|
||||
|
||||
|
||||
def voltage_3v3_n_to_v(n: int) -> float:
|
||||
"""Convert 3.3V rail ADC count to volts."""
|
||||
return n * U3V3_COEFF
|
||||
|
||||
|
||||
def voltage_5v_n_to_v(n: int) -> float:
|
||||
"""Convert 5V rail ADC count to volts (both 5V1 and 5V2)."""
|
||||
return n * U5V_COEFF
|
||||
|
||||
|
||||
def voltage_7v_n_to_v(n: int) -> float:
|
||||
"""Convert 7V rail ADC count to volts."""
|
||||
return n * U7V_COEFF
|
||||
@@ -16,58 +16,11 @@ class ValidationError(LaserControlError):
|
||||
pass
|
||||
|
||||
|
||||
class TemperatureOutOfRangeError(ValidationError):
|
||||
"""Exception raised when temperature is outside valid range."""
|
||||
|
||||
def __init__(self, param_name: str, value: float, min_val: float, max_val: float):
|
||||
self.param_name = param_name
|
||||
self.value = value
|
||||
self.min_val = min_val
|
||||
self.max_val = max_val
|
||||
super().__init__(
|
||||
f"{param_name}: Temperature {value}°C is out of range "
|
||||
f"[{min_val}°C - {max_val}°C]"
|
||||
)
|
||||
|
||||
|
||||
class CurrentOutOfRangeError(ValidationError):
|
||||
"""Exception raised when current is outside valid range."""
|
||||
|
||||
def __init__(self, param_name: str, value: float, min_val: float, max_val: float):
|
||||
self.param_name = param_name
|
||||
self.value = value
|
||||
self.min_val = min_val
|
||||
self.max_val = max_val
|
||||
super().__init__(
|
||||
f"{param_name}: Current {value}mA is out of range "
|
||||
f"[{min_val}mA - {max_val}mA]"
|
||||
)
|
||||
|
||||
|
||||
class InvalidParameterError(ValidationError):
|
||||
"""Exception raised for invalid parameter types or values."""
|
||||
|
||||
def __init__(self, param_name: str, message: str):
|
||||
self.param_name = param_name
|
||||
super().__init__(f"{param_name}: {message}")
|
||||
|
||||
|
||||
class CommunicationError(LaserControlError):
|
||||
"""Base exception for communication errors."""
|
||||
pass
|
||||
|
||||
|
||||
class PortNotFoundError(CommunicationError):
|
||||
"""Exception raised when serial port cannot be found."""
|
||||
|
||||
def __init__(self, port: str | None = None):
|
||||
if port:
|
||||
message = f"Serial port '{port}' not found"
|
||||
else:
|
||||
message = "No suitable serial port found for device connection"
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class PortBusyError(CommunicationError):
|
||||
"""Raised when the serial port is already held by another program.
|
||||
|
||||
@@ -86,28 +39,6 @@ class PortBusyError(CommunicationError):
|
||||
)
|
||||
|
||||
|
||||
class DeviceNotRespondingError(CommunicationError):
|
||||
"""Exception raised when device doesn't respond to commands."""
|
||||
|
||||
def __init__(self, timeout: float | None = None):
|
||||
if timeout:
|
||||
message = f"Device did not respond within {timeout} seconds"
|
||||
else:
|
||||
message = "Device is not responding to commands"
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class CRCError(CommunicationError):
|
||||
"""Exception raised when CRC check fails."""
|
||||
|
||||
def __init__(self, expected: int | None = None, received: int | None = None):
|
||||
if expected is not None and received is not None:
|
||||
message = f"CRC check failed. Expected: 0x{expected:04X}, Received: 0x{received:04X}"
|
||||
else:
|
||||
message = "CRC check failed on received data"
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class ProtocolError(CommunicationError):
|
||||
"""Exception raised for protocol-level errors."""
|
||||
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
"""
|
||||
Data models for laser control module.
|
||||
|
||||
Provides dataclasses and enums for structured data representation
|
||||
throughout the laser control system.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class VariationType(IntEnum):
|
||||
"""Types of parameter variation modes."""
|
||||
MANUAL = 0x00
|
||||
CHANGE_CURRENT_LD1 = 0x01
|
||||
CHANGE_CURRENT_LD2 = 0x02
|
||||
CHANGE_TEMPERATURE_LD1 = 0x03
|
||||
CHANGE_TEMPERATURE_LD2 = 0x04
|
||||
|
||||
|
||||
@dataclass
|
||||
class Measurements:
|
||||
"""Real-time measurements from the device."""
|
||||
# Photodiode currents
|
||||
current1: float # Photodiode current for laser 1 (mA)
|
||||
current2: float # Photodiode current for laser 2 (mA)
|
||||
# Temperatures
|
||||
temp1: float # Temperature of laser 1 (°C)
|
||||
temp2: float # Temperature of laser 2 (°C)
|
||||
temp_ext1: Optional[float] = None # External thermistor 1 temperature (°C)
|
||||
temp_ext2: Optional[float] = None # External thermistor 2 temperature (°C)
|
||||
# Power supply voltages
|
||||
voltage_3v3: float = 0.0 # 3.3V rail voltage
|
||||
voltage_5v1: float = 0.0 # 5V rail 1 voltage
|
||||
voltage_5v2: float = 0.0 # 5V rail 2 voltage
|
||||
voltage_7v0: float = 0.0 # 7V rail voltage
|
||||
# Metadata
|
||||
timestamp: Optional[datetime] = None
|
||||
message_id: Optional[int] = None
|
||||
to6_counter_lsb: Optional[int] = None
|
||||
to6_counter_msb: Optional[int] = None
|
||||
|
||||
def __post_init__(self):
|
||||
"""Set timestamp if not provided."""
|
||||
if self.timestamp is None:
|
||||
self.timestamp = datetime.now()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary representation."""
|
||||
return {
|
||||
'current1': self.current1,
|
||||
'current2': self.current2,
|
||||
'temp1': self.temp1,
|
||||
'temp2': self.temp2,
|
||||
'temp_ext1': self.temp_ext1,
|
||||
'temp_ext2': self.temp_ext2,
|
||||
'voltage_3v3': self.voltage_3v3,
|
||||
'voltage_5v1': self.voltage_5v1,
|
||||
'voltage_5v2': self.voltage_5v2,
|
||||
'voltage_7v0': self.voltage_7v0,
|
||||
'timestamp': self.timestamp.isoformat() if self.timestamp else None,
|
||||
'message_id': self.message_id
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
# Контроль температуры при вариации тока лазера
|
||||
|
||||
Набор из трёх развязанных компонентов для автоматизации измерений в режиме
|
||||
**вариации тока лазера 1** (`CHANGE_CURRENT_LD1`). Пока плата гоняет свип тока,
|
||||
температуры лазеров должны оставаться на заданных статичных уставках. Эти модули
|
||||
раз в свип считывают реальную температуру и предупреждают, если она разошлась с
|
||||
целью.
|
||||
|
||||
## Зачем это нужно
|
||||
|
||||
При запуске вариации тока из GUI изменённые значения температуры могут фактически
|
||||
не дойти до цели — реальная температура остаётся прежней, и измерение становится
|
||||
некорректным. Плата после старта задачи гоняет свип **автономно** и никак не
|
||||
сигнализирует, что уставка не достигнута. Эти модули закрывают пробел: независимо
|
||||
опрашивают плату и валидируют температуру относительно уставок, зафиксированных
|
||||
**в момент старта вариации**.
|
||||
|
||||
> ⚠️ В прошивке реализована только **вариация тока** (`CHANGE_CURRENT_LD1`).
|
||||
> Вариация температуры не поддерживается и в этот API не заложена.
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
[starter] ── TASK_ENABLE ──► плата запускает вариацию
|
||||
│ пишет session.json (target temp1/2, tolerance, variation_type)
|
||||
▼
|
||||
[monitor] ── TRANS_ENABLE ──► плата владеет портом всё время работы
|
||||
│ раз в свип: get_measurements()
|
||||
│ дописывает строку в readings.jsonl (seq, temp1, temp2, temp_ext, I1, I2)
|
||||
▼
|
||||
[checker] читает session.json + tail readings.jsonl
|
||||
сверяет temp1↔target_temp1 и temp2↔target_temp2, |Δ|>tol ─► WARNING в консоль
|
||||
```
|
||||
|
||||
- **Порт лазера эксклюзивен.** При `--start` запуск и мониторинг используют одно
|
||||
соединение. Штатный пайплайн закрывает порт после запуска вариации, и его
|
||||
может открыть отдельный монитор. `checker` читает только файлы.
|
||||
- **Связь через файлы** (JSONL + JSON), а не сокеты, — процессы стартуют,
|
||||
останавливаются и перезапускаются независимо, без рукопожатия.
|
||||
- **Сверяются оба лазера** по внутренним `temp1`/`temp2` (не по внешним
|
||||
термисторам `temp_ext*`), с общим допуском (по умолчанию `0.03 °C`).
|
||||
|
||||
## Быстрый старт (CLI, два процесса)
|
||||
|
||||
Команды запускаются из корня проекта. Терминал 1 — стартовать вариацию и
|
||||
мониторить температуру:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m python_app.scripts.laser_temp_monitor \
|
||||
--config run_config.json \
|
||||
--start
|
||||
```
|
||||
|
||||
Терминал 2 — валидировать температуру и печатать предупреждения:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m python_app.scripts.laser_temp_checker
|
||||
```
|
||||
|
||||
Пример вывода чекера при расхождении и возврате в допуск:
|
||||
|
||||
```
|
||||
WARNING python_app.hardware_full.laser_control.monitoring.checker: Laser 1 temperature off target: measured 28.100 °C,
|
||||
target 28.000 °C, Δ=+0.100 °C exceeds tolerance ±0.030 °C [seq=1]
|
||||
INFO python_app.hardware_full.laser_control.monitoring.checker: Laser 1 temperature back within tolerance:
|
||||
28.000 °C (target 28.000, |Δ|=0.000 ≤ 0.030) [seq=2]
|
||||
```
|
||||
|
||||
Остановка — `Ctrl+C` (SIGINT) в любом из процессов.
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Параметры берутся из `run_config.json`, секция `radar.laser_control`. Мониторинг
|
||||
использует блок `variation`, включая `temp_tolerance_c`:
|
||||
|
||||
```json
|
||||
{
|
||||
"radar": {
|
||||
"model": "kamil_adc",
|
||||
"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,
|
||||
"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,
|
||||
"temp_tolerance_c": 0.03
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ключевые поля для мониторинга:
|
||||
|
||||
| Поле | Смысл |
|
||||
|---|---|
|
||||
| `port` | Серийный порт лазерной платы; CLI-монитор допускает пустое значение для автоопределения, пайплайн требует явный порт |
|
||||
| `static_temp1` / `static_temp2` | Целевые статичные температуры лазеров 1/2, °C |
|
||||
| `min_value` / `max_value` / `step` | Диапазон и шаг свипа тока, мА — из них считается период свипа |
|
||||
| `time_step` / `delay_time` | Тайминги точки (мкс / мс) — тоже входят в период свипа |
|
||||
| `temp_tolerance_c` | Допуск сверки, °C (по умолчанию `0.03`) |
|
||||
|
||||
## Опции CLI
|
||||
|
||||
### `laser_temp_monitor`
|
||||
|
||||
| Аргумент | По умолчанию | Назначение |
|
||||
|---|---|---|
|
||||
| `--config` | — (обязателен) | Путь к `run_config.json` |
|
||||
| `--start` | выкл. | Послать `CHANGE_CURRENT_LD1` перед мониторингом и записать сессию |
|
||||
| `--strategy` | `computed` | `computed` (раз в свип) или `interval:<ms>` (фикс. период) |
|
||||
| `--readings` | `<tmp>/laser_temp_readings.jsonl` | Куда дописывать показания |
|
||||
| `--session` | `<tmp>/laser_variation_session.json` | Куда писать снимок сессии (с `--start`) |
|
||||
|
||||
Мониторить уже запущенную из GUI/пайплайна вариацию (без повторного старта):
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m python_app.scripts.laser_temp_monitor --config run_config.json
|
||||
```
|
||||
|
||||
Фиксированный период вместо расчётного (напр. раз в 500 мс):
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m python_app.scripts.laser_temp_monitor \
|
||||
--config run_config.json --strategy interval:500
|
||||
```
|
||||
|
||||
### `laser_temp_checker`
|
||||
|
||||
| Аргумент | По умолчанию | Назначение |
|
||||
|---|---|---|
|
||||
| `--session` | `<tmp>/laser_variation_session.json` | Снимок с целями и допуском |
|
||||
| `--readings` | `<tmp>/laser_temp_readings.jsonl` | Какой канал показаний тайлить |
|
||||
| `--tolerance` | из сессии | Переопределить допуск, °C |
|
||||
| `--reminder-every` | `0` (выкл.) | Повторять предупреждение каждые N показаний, пока вне допуска |
|
||||
| `--from-start` | выкл. | Проверить весь файл показаний, а не только новые строки |
|
||||
|
||||
Разные пути для нескольких одновременных прогонов:
|
||||
|
||||
```bash
|
||||
# монитор
|
||||
.venv/bin/python -m python_app.scripts.laser_temp_monitor --config cfg.json --start \
|
||||
--readings /tmp/run7.jsonl --session /tmp/run7.session.json
|
||||
# чекер
|
||||
.venv/bin/python -m python_app.scripts.laser_temp_checker \
|
||||
--readings /tmp/run7.jsonl --session /tmp/run7.session.json --reminder-every 20
|
||||
```
|
||||
|
||||
## Интеграция с пайплайном Kamil ADC
|
||||
|
||||
Когда вариацию стартует штатный пайплайн
|
||||
([`apply_kamil_adc_laser_control`](../../kamil_adc/laser.py)), снимок сессии
|
||||
`<tmp>/laser_variation_session.json` пишется автоматически. Для предупреждений
|
||||
во время захвата нужны монитор без `--start`, который создаёт показания, и чекер.
|
||||
Если монитор уже работает, достаточно добавить чекер. Он загружает сессию
|
||||
один раз при запуске; после запуска новой вариации чекер нужно перезапустить.
|
||||
|
||||
## Встраивание в свой код (без CLI)
|
||||
|
||||
```python
|
||||
import threading
|
||||
from python_app.hardware_full.laser_control.controller import LaserController
|
||||
from python_app.hardware_full.laser_control.monitoring import (
|
||||
LaserTemperatureMonitor, LaserTemperatureChecker, LaserVariationSession,
|
||||
ReadingWriter, ReadingReader, resolve_period_s,
|
||||
)
|
||||
|
||||
# 1. Зафиксировать цели при старте вариации
|
||||
session = LaserVariationSession(
|
||||
variation_type="CHANGE_CURRENT_LD1",
|
||||
target_temp1=28.0, target_temp2=28.9, tolerance_c=0.03,
|
||||
)
|
||||
|
||||
# 2. Монитор (в проде controller — реальный LaserController)
|
||||
period = resolve_period_s("computed", min_value=33.0, max_value=60.0, step=0.05,
|
||||
time_step_us=50, delay_time_ms=10)
|
||||
stop = threading.Event()
|
||||
with LaserController(port="/dev/ttyUSB0") as ctrl, ReadingWriter("readings.jsonl") as w:
|
||||
monitor = LaserTemperatureMonitor(ctrl, w, period_s=period)
|
||||
worker = threading.Thread(target=monitor.run, args=(stop,))
|
||||
worker.start()
|
||||
|
||||
# 3. Чекер: тайлить показания и валидировать оба лазера
|
||||
checker = LaserTemperatureChecker.from_session(session)
|
||||
reader = ReadingReader("readings.jsonl")
|
||||
try:
|
||||
while not stop.is_set():
|
||||
for reading in reader.poll():
|
||||
checker.process(reading) # печатает WARNING при |Δ| > tolerance
|
||||
stop.wait(0.2)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
stop.set()
|
||||
worker.join() # завершить опрос до закрытия порта и файла
|
||||
```
|
||||
|
||||
`LaserTemperatureChecker.evaluate(reading)` возвращает список
|
||||
`LaserDeviation` (по лазеру: измеренное, цель, Δ, в допуске ли) без логирования —
|
||||
удобно для собственной обработки/накопления статистики.
|
||||
|
||||
## Формат IPC-файлов
|
||||
|
||||
`session.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"variation_type": "CHANGE_CURRENT_LD1",
|
||||
"target_temp1": 28.0,
|
||||
"target_temp2": 28.9,
|
||||
"tolerance_c": 0.03,
|
||||
"started_at_iso": "2026-07-27T12:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
`readings.jsonl` (по одной строке-объекту на успешный опрос):
|
||||
|
||||
```json
|
||||
{"seq":0,"mono_ns":123456789,"temp1":28.0,"temp2":28.9,"temp_ext1":22.0,"temp_ext2":23.0,"current1":33.0,"current2":35.0}
|
||||
```
|
||||
|
||||
## Как определяется «раз в свип»
|
||||
|
||||
Плата не отдаёт явную границу свипа, поэтому период оценивается из параметров:
|
||||
|
||||
```
|
||||
num_steps = round(|max_value - min_value| / step) + 1
|
||||
per_point_s = delay_time / 1000 + time_step / 1_000_000
|
||||
sweep_period = num_steps × per_point_s
|
||||
```
|
||||
|
||||
Монитор опрашивает плату сразу после запуска, затем ждёт такой период после
|
||||
каждого опроса; это оценка, без синхронизации с границами аппаратных свипов.
|
||||
Если нужен другой темп —
|
||||
`--strategy interval:<ms>`. (Внутренний счётчик `TO6` платы существует, но его
|
||||
семантика не гарантирована, поэтому для тайминга он не используется.)
|
||||
|
||||
## Тесты
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m unittest python_app.tests.test_laser_temp_monitoring
|
||||
```
|
||||
|
||||
Покрыто: round-trip сессии, tail JSONL (включая усечённую последнюю строку),
|
||||
расчёт периода свипа, маппинг измерений монитором, и валидация чекера по каждому
|
||||
лазеру отдельно (порог, граница допуска, повторные предупреждения, восстановление).
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Laser current-variation temperature monitoring and validation.
|
||||
|
||||
Three decoupled pieces connected via IPC files:
|
||||
- :class:`LaserVariationSession` — target setpoints + tolerance frozen at start.
|
||||
- :class:`LaserTemperatureMonitor` — polls the board once per sweep, publishes.
|
||||
- :class:`LaserTemperatureChecker` — validates published readings, warns.
|
||||
"""
|
||||
|
||||
from .checker import LaserDeviation, LaserTemperatureChecker
|
||||
from .monitor import (
|
||||
LaserTemperatureMonitor,
|
||||
compute_sweep_period_s,
|
||||
resolve_period_s,
|
||||
)
|
||||
from .readings_channel import ReadingReader, ReadingWriter, TemperatureReading
|
||||
from .session import (
|
||||
DEFAULT_READINGS_PATH,
|
||||
DEFAULT_SESSION_PATH,
|
||||
DEFAULT_TOLERANCE_C,
|
||||
LaserVariationSession,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"LaserDeviation",
|
||||
"LaserTemperatureChecker",
|
||||
"LaserTemperatureMonitor",
|
||||
"compute_sweep_period_s",
|
||||
"resolve_period_s",
|
||||
"ReadingReader",
|
||||
"ReadingWriter",
|
||||
"TemperatureReading",
|
||||
"DEFAULT_READINGS_PATH",
|
||||
"DEFAULT_SESSION_PATH",
|
||||
"DEFAULT_TOLERANCE_C",
|
||||
"LaserVariationSession",
|
||||
]
|
||||
@@ -1,139 +0,0 @@
|
||||
"""Independent laser temperature checker.
|
||||
|
||||
Reads the target setpoints frozen at variation start (:class:`LaserVariationSession`)
|
||||
and validates each published :class:`TemperatureReading` against them. Both lasers
|
||||
are checked independently: ``temp1`` against ``target_temp1`` and ``temp2`` against
|
||||
``target_temp2``. When a laser's measured temperature deviates from its target by
|
||||
more than the tolerance (default 0.03 °C), a warning is printed to the console.
|
||||
|
||||
Runs as its own process (see ``scripts/laser_temp_checker.py``), reading the JSONL
|
||||
readings channel — it never touches the serial port, so it is fully independent of
|
||||
the monitor and can be started, stopped, or restarted at any time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
from .readings_channel import TemperatureReading
|
||||
from .session import DEFAULT_TOLERANCE_C, LaserVariationSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Only a deviation strictly greater than the tolerance warns; this epsilon keeps a
|
||||
# value the user intends to be exactly at the tolerance from tripping on float error
|
||||
# (e.g. 28.03 - 28.00 == 0.030000000000001 in IEEE-754).
|
||||
_FLOAT_EPS = 1e-9
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LaserDeviation:
|
||||
"""Result of comparing one laser's measured temperature to its target."""
|
||||
|
||||
laser: int # 1 or 2
|
||||
seq: int
|
||||
measured: float
|
||||
target: float
|
||||
delta: float # measured - target, °C
|
||||
within_tolerance: bool
|
||||
|
||||
|
||||
class LaserTemperatureChecker:
|
||||
"""Validates readings against per-laser targets and warns on mismatch.
|
||||
|
||||
Anti-spam: a laser's ok↔mismatch transitions are logged once; while a laser
|
||||
stays out of tolerance, a reminder is emitted only every ``reminder_every``
|
||||
readings (0 disables reminders). State is tracked independently per laser, so
|
||||
a persistent laser-1 fault never suppresses a fresh laser-2 warning.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
target_temp1: float,
|
||||
target_temp2: float,
|
||||
tolerance_c: float = DEFAULT_TOLERANCE_C,
|
||||
reminder_every: int = 0,
|
||||
) -> None:
|
||||
self.target_temp1 = float(target_temp1)
|
||||
self.target_temp2 = float(target_temp2)
|
||||
self.tolerance_c = float(tolerance_c)
|
||||
self.reminder_every = int(reminder_every)
|
||||
# Per-laser state: mismatch flag + readings seen since the last log.
|
||||
self._mismatch = {1: False, 2: False}
|
||||
self._since_log = {1: 0, 2: 0}
|
||||
|
||||
@classmethod
|
||||
def from_session(
|
||||
cls, session: LaserVariationSession, reminder_every: int = 0
|
||||
) -> "LaserTemperatureChecker":
|
||||
return cls(
|
||||
target_temp1=session.target_temp1,
|
||||
target_temp2=session.target_temp2,
|
||||
tolerance_c=session.tolerance_c,
|
||||
reminder_every=reminder_every,
|
||||
)
|
||||
|
||||
def evaluate(self, reading: TemperatureReading) -> List[LaserDeviation]:
|
||||
"""Compute per-laser deviations without logging (pure)."""
|
||||
return [
|
||||
self._deviation(1, reading.seq, reading.temp1, self.target_temp1),
|
||||
self._deviation(2, reading.seq, reading.temp2, self.target_temp2),
|
||||
]
|
||||
|
||||
def process(self, reading: TemperatureReading) -> List[LaserDeviation]:
|
||||
"""Evaluate a reading and emit console warnings, honouring anti-spam.
|
||||
|
||||
Returns the deviations for which a warning/reminder was emitted this call
|
||||
(empty when both lasers are within tolerance and unchanged).
|
||||
"""
|
||||
warned: List[LaserDeviation] = []
|
||||
for dev in self.evaluate(reading):
|
||||
if self._should_warn(dev):
|
||||
self._warn(dev)
|
||||
warned.append(dev)
|
||||
return warned
|
||||
|
||||
def _deviation(self, laser: int, seq: int, measured: float, target: float) -> LaserDeviation:
|
||||
delta = measured - target
|
||||
return LaserDeviation(
|
||||
laser=laser,
|
||||
seq=seq,
|
||||
measured=measured,
|
||||
target=target,
|
||||
delta=delta,
|
||||
within_tolerance=abs(delta) <= self.tolerance_c + _FLOAT_EPS,
|
||||
)
|
||||
|
||||
def _should_warn(self, dev: LaserDeviation) -> bool:
|
||||
laser = dev.laser
|
||||
if not dev.within_tolerance:
|
||||
if not self._mismatch[laser]:
|
||||
# Fresh ok -> mismatch transition: always warn.
|
||||
self._mismatch[laser] = True
|
||||
self._since_log[laser] = 0
|
||||
return True
|
||||
# Still out of tolerance: warn again only every reminder_every readings.
|
||||
self._since_log[laser] += 1
|
||||
if self.reminder_every > 0 and self._since_log[laser] >= self.reminder_every:
|
||||
self._since_log[laser] = 0
|
||||
return True
|
||||
return False
|
||||
# Within tolerance: log a recovery once, then stay quiet.
|
||||
if self._mismatch[laser]:
|
||||
self._mismatch[laser] = False
|
||||
self._since_log[laser] = 0
|
||||
logger.info(
|
||||
"Laser %d temperature back within tolerance: %.3f °C "
|
||||
"(target %.3f, |Δ|=%.3f ≤ %.3f) [seq=%d]",
|
||||
laser, dev.measured, dev.target, abs(dev.delta), self.tolerance_c, dev.seq,
|
||||
)
|
||||
return False
|
||||
|
||||
def _warn(self, dev: LaserDeviation) -> None:
|
||||
logger.warning(
|
||||
"Laser %d temperature off target: measured %.3f °C, target %.3f °C, "
|
||||
"Δ=%+.3f °C exceeds tolerance ±%.3f °C [seq=%d]",
|
||||
dev.laser, dev.measured, dev.target, dev.delta, self.tolerance_c, dev.seq,
|
||||
)
|
||||
@@ -1,147 +0,0 @@
|
||||
"""Independent laser temperature monitor.
|
||||
|
||||
Owns a :class:`LaserController` connection and, once per current-variation sweep,
|
||||
polls the board for a measurement and publishes it to a JSONL readings channel.
|
||||
The board runs the current sweep autonomously after ``TASK_ENABLE``; the monitor
|
||||
only reads the "last data point" via ``TRANS_ENABLE`` — exactly like the original
|
||||
RadioPhotonic PC software's polling loop, but decoupled and headless.
|
||||
|
||||
Runs as its own process (see ``scripts/laser_temp_monitor.py``) so it is fully
|
||||
independent of both the acquisition pipeline and the temperature checker.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Protocol
|
||||
|
||||
from .readings_channel import ReadingWriter, TemperatureReading
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _Temperatures(Protocol):
|
||||
temp1: float
|
||||
temp2: float
|
||||
|
||||
|
||||
class _MeasurementSource(Protocol):
|
||||
"""Minimal controller surface the monitor depends on (eases testing)."""
|
||||
|
||||
def get_measurements(self) -> _Temperatures | None: ...
|
||||
|
||||
|
||||
def compute_sweep_period_s(
|
||||
min_value: float,
|
||||
max_value: float,
|
||||
step: float,
|
||||
time_step_us: float,
|
||||
delay_time_ms: float,
|
||||
) -> float:
|
||||
"""Estimate the duration of one min→max current sweep, in seconds.
|
||||
|
||||
``num_steps = round(|max - min| / step) + 1`` points, each taking roughly the
|
||||
inter-point delay plus the discretisation time. The board gives no explicit
|
||||
end-of-sweep marker, so this computed period is how "once per sweep" is timed
|
||||
by default.
|
||||
"""
|
||||
if step <= 0:
|
||||
raise ValueError(f"step must be > 0, got {step}")
|
||||
span = abs(max_value - min_value)
|
||||
num_steps = round(span / step) + 1
|
||||
per_point_s = delay_time_ms / 1000.0 + time_step_us / 1_000_000.0
|
||||
return num_steps * per_point_s
|
||||
|
||||
|
||||
def resolve_period_s(
|
||||
strategy: str,
|
||||
*,
|
||||
min_value: float,
|
||||
max_value: float,
|
||||
step: float,
|
||||
time_step_us: float,
|
||||
delay_time_ms: float,
|
||||
) -> float:
|
||||
"""Turn a strategy string into a concrete per-reading period in seconds.
|
||||
|
||||
Supported strategies:
|
||||
- ``"computed"`` — one reading per estimated sweep duration (default).
|
||||
- ``"interval:<ms>"`` — a fixed period of ``<ms>`` milliseconds.
|
||||
"""
|
||||
if strategy == "computed":
|
||||
return compute_sweep_period_s(min_value, max_value, step, time_step_us, delay_time_ms)
|
||||
if strategy.startswith("interval:"):
|
||||
try:
|
||||
ms = float(strategy.split(":", 1)[1])
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Invalid interval strategy {strategy!r}") from exc
|
||||
if ms <= 0:
|
||||
raise ValueError(f"interval must be > 0 ms, got {ms}")
|
||||
return ms / 1000.0
|
||||
raise ValueError(
|
||||
f"Unknown strategy {strategy!r}; expected 'computed' or 'interval:<ms>'"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LaserTemperatureMonitor:
|
||||
"""Polls a laser board once per sweep and publishes temperature readings.
|
||||
|
||||
Args:
|
||||
controller: object exposing ``get_measurements()`` (a real
|
||||
:class:`LaserController` in production, a fake in tests).
|
||||
writer: destination channel implementing ``write(TemperatureReading)``.
|
||||
period_s: seconds between readings (see :func:`resolve_period_s`).
|
||||
"""
|
||||
|
||||
controller: _MeasurementSource
|
||||
writer: ReadingWriter
|
||||
period_s: float
|
||||
|
||||
def read_once(self, seq: int) -> Optional[TemperatureReading]:
|
||||
"""Poll one measurement and turn it into a reading, or None if no data."""
|
||||
measurements = self.controller.get_measurements()
|
||||
if measurements is None:
|
||||
logger.warning("No measurement returned from laser board (seq=%d)", seq)
|
||||
return None
|
||||
return TemperatureReading(
|
||||
seq=seq,
|
||||
mono_ns=time.monotonic_ns(),
|
||||
temp1=float(measurements.temp1),
|
||||
temp2=float(measurements.temp2),
|
||||
temp_ext1=_opt(getattr(measurements, "temp_ext1", None)),
|
||||
temp_ext2=_opt(getattr(measurements, "temp_ext2", None)),
|
||||
current1=_opt(getattr(measurements, "current1", None)),
|
||||
current2=_opt(getattr(measurements, "current2", None)),
|
||||
)
|
||||
|
||||
def run(self, stop_event: Optional[threading.Event] = None) -> None:
|
||||
"""Poll-and-publish until ``stop_event`` is set (runs forever if None).
|
||||
|
||||
Each iteration reads once, publishes, then waits one period. The wait is
|
||||
interruptible via ``stop_event`` for a prompt clean shutdown.
|
||||
"""
|
||||
stop = stop_event or threading.Event()
|
||||
seq = 0
|
||||
logger.info("Temperature monitor started: period=%.3fs", self.period_s)
|
||||
while not stop.is_set():
|
||||
try:
|
||||
reading = self.read_once(seq)
|
||||
except Exception: # noqa: BLE001 — a transient read error must not kill the monitor
|
||||
logger.warning("Measurement read failed; continuing", exc_info=True)
|
||||
reading = None
|
||||
if reading is not None:
|
||||
self.writer.write(reading)
|
||||
logger.debug(
|
||||
"Published reading seq=%d T1=%.3f T2=%.3f", seq, reading.temp1, reading.temp2
|
||||
)
|
||||
seq += 1
|
||||
stop.wait(self.period_s)
|
||||
logger.info("Temperature monitor stopped after %d readings", seq)
|
||||
|
||||
|
||||
def _opt(value: float | None) -> Optional[float]:
|
||||
return None if value is None else float(value)
|
||||
@@ -1,131 +0,0 @@
|
||||
"""JSONL append/tail channel carrying per-sweep temperature readings.
|
||||
|
||||
The monitor process appends one JSON object per line; the checker process tails
|
||||
the file from its end and parses each newly-appended line. A newline-delimited
|
||||
file is used (rather than a socket) so the monitor and checker can start, stop,
|
||||
and restart on independent lifecycles without a handshake — the checker simply
|
||||
resumes tailing wherever the file currently ends.
|
||||
|
||||
A reader only ever consumes lines terminated by ``\\n``; a partially-written last
|
||||
line is left buffered until its newline arrives, so a reading is never parsed
|
||||
half-written.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterator, Optional, Union
|
||||
|
||||
_PathLike = Union[str, os.PathLike[str]]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TemperatureReading:
|
||||
"""One temperature/current snapshot published once per sweep.
|
||||
|
||||
``temp1``/``temp2`` are the internal laser temperatures (the values validated
|
||||
against the setpoints); ``temp_ext1``/``temp_ext2`` are the external
|
||||
thermistors, carried for diagnostics only.
|
||||
"""
|
||||
|
||||
seq: int
|
||||
mono_ns: int
|
||||
temp1: float
|
||||
temp2: float
|
||||
temp_ext1: Optional[float] = None
|
||||
temp_ext2: Optional[float] = None
|
||||
current1: Optional[float] = None
|
||||
current2: Optional[float] = None
|
||||
|
||||
def to_json_line(self) -> str:
|
||||
return json.dumps(asdict(self), separators=(",", ":"))
|
||||
|
||||
@classmethod
|
||||
def from_json_line(cls, line: str) -> "TemperatureReading":
|
||||
payload = json.loads(line)
|
||||
return cls(
|
||||
seq=int(payload["seq"]),
|
||||
mono_ns=int(payload["mono_ns"]),
|
||||
temp1=float(payload["temp1"]),
|
||||
temp2=float(payload["temp2"]),
|
||||
temp_ext1=_opt_float(payload.get("temp_ext1")),
|
||||
temp_ext2=_opt_float(payload.get("temp_ext2")),
|
||||
current1=_opt_float(payload.get("current1")),
|
||||
current2=_opt_float(payload.get("current2")),
|
||||
)
|
||||
|
||||
|
||||
def _opt_float(value: str | float | None) -> Optional[float]:
|
||||
return None if value is None else float(value)
|
||||
|
||||
|
||||
class ReadingWriter:
|
||||
"""Appends :class:`TemperatureReading` objects to a JSONL file.
|
||||
|
||||
Each write is a single line flushed to the OS so a tailing reader sees it
|
||||
promptly. Use as a context manager or call :meth:`close` explicitly.
|
||||
"""
|
||||
|
||||
def __init__(self, path: _PathLike) -> None:
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Line-buffered append; each reading is one line.
|
||||
self._fh = self.path.open("a", encoding="utf-8", buffering=1)
|
||||
|
||||
def write(self, reading: TemperatureReading) -> None:
|
||||
self._fh.write(reading.to_json_line() + "\n")
|
||||
self._fh.flush()
|
||||
|
||||
def close(self) -> None:
|
||||
if not self._fh.closed:
|
||||
self._fh.close()
|
||||
|
||||
def __enter__(self) -> "ReadingWriter":
|
||||
return self
|
||||
|
||||
def __exit__(self, *_exc: object) -> None:
|
||||
self.close()
|
||||
|
||||
|
||||
class ReadingReader:
|
||||
"""Tails a JSONL readings file, yielding complete lines as they appear.
|
||||
|
||||
``from_start=False`` (default) begins at the current end of file, so the
|
||||
checker validates readings produced from the moment it starts. Partial
|
||||
trailing lines are buffered until their newline arrives.
|
||||
"""
|
||||
|
||||
def __init__(self, path: _PathLike, *, from_start: bool = False) -> None:
|
||||
self.path = Path(path)
|
||||
self._buffer = ""
|
||||
self._pos = 0
|
||||
if not from_start and self.path.exists():
|
||||
self._pos = self.path.stat().st_size
|
||||
|
||||
def poll(self) -> Iterator[TemperatureReading]:
|
||||
"""Yield every complete reading appended since the last poll.
|
||||
|
||||
Malformed lines are skipped silently (a truncated/legacy line must not
|
||||
crash a long-running checker); callers that care can validate seq gaps.
|
||||
"""
|
||||
if not self.path.exists():
|
||||
return
|
||||
with self.path.open("r", encoding="utf-8") as fh:
|
||||
fh.seek(self._pos)
|
||||
chunk = fh.read()
|
||||
self._pos = fh.tell()
|
||||
if not chunk:
|
||||
return
|
||||
self._buffer += chunk
|
||||
*complete, self._buffer = self._buffer.split("\n")
|
||||
for line in complete:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
yield TemperatureReading.from_json_line(line)
|
||||
except (ValueError, KeyError, TypeError):
|
||||
continue
|
||||
@@ -1,72 +0,0 @@
|
||||
"""Variation-session snapshot shared between the temperature monitor and checker.
|
||||
|
||||
When a current-variation task is started, the target static laser temperatures
|
||||
(``static_temp1``/``static_temp2``) and the acceptable tolerance are frozen into a
|
||||
small JSON file. The temperature checker reads this file to know what "correct"
|
||||
means for the run, so it validates against the setpoints that were in effect *at
|
||||
variation start* — independent of any later edits to the run config.
|
||||
|
||||
Only current variation of laser 1 (``CHANGE_CURRENT_LD1``) is supported by the
|
||||
firmware today; the session still records both laser targets because both
|
||||
temperatures are held static during that task and both are validated.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
from python_app.file_io import atomic_write_json
|
||||
|
||||
_PathLike = Union[str, os.PathLike[str]]
|
||||
|
||||
# Default IPC locations. Both are overridable via CLI/API so several runs can use
|
||||
# distinct files. Kept in the system temp dir so no project state is polluted.
|
||||
DEFAULT_SESSION_PATH = Path(tempfile.gettempdir()) / "laser_variation_session.json"
|
||||
DEFAULT_READINGS_PATH = Path(tempfile.gettempdir()) / "laser_temp_readings.jsonl"
|
||||
|
||||
DEFAULT_TOLERANCE_C = 0.03
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LaserVariationSession:
|
||||
"""Target setpoints and tolerance frozen at variation start."""
|
||||
|
||||
variation_type: str
|
||||
target_temp1: float
|
||||
target_temp2: float
|
||||
tolerance_c: float = DEFAULT_TOLERANCE_C
|
||||
started_at_iso: str = ""
|
||||
|
||||
def save(self, path: _PathLike = DEFAULT_SESSION_PATH) -> Path:
|
||||
"""Atomically write the session snapshot to ``path`` and return it.
|
||||
|
||||
Writes to a temp file in the same directory then renames, so a concurrent
|
||||
checker never observes a half-written file.
|
||||
"""
|
||||
return atomic_write_json(Path(path), asdict(self))
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: _PathLike = DEFAULT_SESSION_PATH) -> "LaserVariationSession":
|
||||
"""Load a session snapshot from ``path``.
|
||||
|
||||
Raises FileNotFoundError if the file is absent and ValueError if it is not
|
||||
a valid session object.
|
||||
"""
|
||||
payload = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError(f"Session file must be a JSON object: {path}")
|
||||
try:
|
||||
return cls(
|
||||
variation_type=str(payload["variation_type"]),
|
||||
target_temp1=float(payload["target_temp1"]),
|
||||
target_temp2=float(payload["target_temp2"]),
|
||||
tolerance_c=float(payload.get("tolerance_c", DEFAULT_TOLERANCE_C)),
|
||||
started_at_iso=str(payload.get("started_at_iso", "")),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise ValueError(f"Malformed session file {path}: {exc}") from exc
|
||||
@@ -1,156 +1,39 @@
|
||||
"""Communication protocol for the laser control module.
|
||||
|
||||
Encodes commands to wire bytes, decodes device responses, and manages the
|
||||
serial port connection to the laser control board.
|
||||
"""
|
||||
"""Serial transport and little-endian commands for the dual-laser board."""
|
||||
|
||||
import errno
|
||||
import logging
|
||||
from typing import Optional
|
||||
from enum import IntEnum
|
||||
from datetime import datetime
|
||||
import struct
|
||||
from functools import reduce
|
||||
from operator import xor
|
||||
|
||||
import serial
|
||||
import serial.tools.list_ports
|
||||
|
||||
from .constants import (
|
||||
BAUDRATE, SERIAL_TIMEOUT_SEC,
|
||||
GET_DATA_TOTAL_LENGTH,
|
||||
SEND_PARAMS_TOTAL_LENGTH,
|
||||
TASK_ENABLE_COMMAND_LENGTH,
|
||||
CMD_DECODE_ENABLE, CMD_DEFAULT_ENABLE,
|
||||
CMD_TRANS_ENABLE, CMD_REMOVE_FILE,
|
||||
CMD_STATE, CMD_TASK_ENABLE,
|
||||
STATE_DESCRIPTIONS, STATE_OK,
|
||||
)
|
||||
from .conversions import (
|
||||
temp_c_to_n, temp_n_to_c,
|
||||
temp_ext_n_to_c,
|
||||
current_ma_to_n, current_n_to_ma,
|
||||
voltage_3v3_n_to_v, voltage_5v_n_to_v, voltage_7v_n_to_v,
|
||||
)
|
||||
from .models import Measurements
|
||||
from .exceptions import (
|
||||
CommunicationError,
|
||||
PortNotFoundError,
|
||||
PortBusyError,
|
||||
CRCError,
|
||||
ProtocolError,
|
||||
)
|
||||
from .constants import BAUDRATE, SERIAL_TIMEOUT_SEC
|
||||
from .conversions import current_ma_to_n, temp_c_to_n
|
||||
from .validators import validate_manual, validate_variation
|
||||
from .exceptions import CommunicationError, PortBusyError, ProtocolError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TaskType(IntEnum):
|
||||
MANUAL = 0x00
|
||||
CHANGE_CURRENT_LD1 = 0x01
|
||||
CHANGE_CURRENT_LD2 = 0x02
|
||||
CHANGE_TEMPERATURE_LD1 = 0x03
|
||||
CHANGE_TEMPERATURE_LD2 = 0x04
|
||||
def _command(header: int, *words: int) -> bytes:
|
||||
"""Append XOR checksum; header is excluded, every word is uint16 LE."""
|
||||
if any(not isinstance(word, int) or not 0 <= word <= 65535 for word in words):
|
||||
raise ValueError("Laser command words must be uint16 integers")
|
||||
return struct.pack(f"<{len(words) + 2}H", header, *words, reduce(xor, words, 0))
|
||||
|
||||
|
||||
# ---- Low-level helpers --------------------------------------------------
|
||||
|
||||
def _int_to_hex4(value: int) -> str:
|
||||
"""Return 4-character lowercase hex string (0–65535)."""
|
||||
if value < 0 or value > 65535:
|
||||
raise ValueError(f"Value {value} out of uint16 range [0, 65535]")
|
||||
return f"{value:04x}"
|
||||
|
||||
|
||||
def _flipfour(hex_word: str) -> str:
|
||||
"""Swap the two byte-pairs of a 4-char hex word: 'aabb' -> 'bbaa' (little-endian)."""
|
||||
if len(hex_word) != 4:
|
||||
raise ValueError(f"Expected 4-char hex string, got '{hex_word}'")
|
||||
return hex_word[2:4] + hex_word[0:2]
|
||||
|
||||
|
||||
def _xor_crc(words: list) -> str:
|
||||
"""XOR all 16-bit hex words and return 4-char hex CRC."""
|
||||
result = int(words[0], 16)
|
||||
for w in words[1:]:
|
||||
result ^= int(w, 16)
|
||||
return _int_to_hex4(result)
|
||||
|
||||
|
||||
def _build_crc(data_hex: str) -> str:
|
||||
"""Calculate XOR CRC over words 1..N of a hex string (skip word 0)."""
|
||||
words = [data_hex[i:i+4] for i in range(0, len(data_hex), 4)]
|
||||
return _xor_crc(words[1:])
|
||||
|
||||
|
||||
def _encode_setup() -> str:
|
||||
"""Build the 16-bit setup word (all subsystems enabled, SD save off)."""
|
||||
bits = ['0'] * 16
|
||||
bits[15] = '1' # enable work
|
||||
bits[14] = '1' # enable 5v1
|
||||
bits[13] = '1' # enable 5v2
|
||||
bits[12] = '1' # enable LD1
|
||||
bits[11] = '1' # enable LD2
|
||||
bits[10] = '1' # enable REF1
|
||||
bits[9] = '1' # enable REF2
|
||||
bits[8] = '1' # enable TEC1
|
||||
bits[7] = '1' # enable TEC2
|
||||
bits[6] = '1' # enable temp stab 1
|
||||
bits[5] = '1' # enable temp stab 2
|
||||
bits[4] = '0' # enable sd save (disabled)
|
||||
bits[3] = '1' # enable PI1 coef read
|
||||
bits[2] = '1' # enable PI2 coef read
|
||||
bits[1] = '0' # reserved
|
||||
bits[0] = '0' # reserved
|
||||
return f"{int(''.join(bits), 2):04x}"
|
||||
|
||||
|
||||
# ---- Response dataclass --------------------------------------------------
|
||||
|
||||
class Response:
|
||||
"""Decoded device DATA response."""
|
||||
__slots__ = [
|
||||
'current1', 'current2',
|
||||
'temp1', 'temp2',
|
||||
'temp_ext1', 'temp_ext2',
|
||||
'voltage_3v3', 'voltage_5v1', 'voltage_5v2', 'voltage_7v0',
|
||||
'to6_lsb', 'to6_msb',
|
||||
'message_id',
|
||||
'header',
|
||||
]
|
||||
|
||||
def to_measurements(self) -> Measurements:
|
||||
return Measurements(
|
||||
current1=self.current1,
|
||||
current2=self.current2,
|
||||
temp1=self.temp1,
|
||||
temp2=self.temp2,
|
||||
temp_ext1=self.temp_ext1,
|
||||
temp_ext2=self.temp_ext2,
|
||||
voltage_3v3=self.voltage_3v3,
|
||||
voltage_5v1=self.voltage_5v1,
|
||||
voltage_5v2=self.voltage_5v2,
|
||||
voltage_7v0=self.voltage_7v0,
|
||||
timestamp=datetime.now(),
|
||||
message_id=self.message_id,
|
||||
to6_counter_lsb=self.to6_lsb,
|
||||
to6_counter_msb=self.to6_msb,
|
||||
)
|
||||
|
||||
|
||||
# ---- Protocol class --------------------------------------------------
|
||||
|
||||
class Protocol:
|
||||
"""
|
||||
Encodes commands and decodes responses for the laser control board.
|
||||
"""Exclusive serial connection and the commands used by the application."""
|
||||
|
||||
Can also manage a serial port connection when port is provided.
|
||||
"""
|
||||
|
||||
def __init__(self, port: Optional[str] = None):
|
||||
self._port_name = port
|
||||
self._serial: Optional[serial.Serial] = None
|
||||
def __init__(self, port: str):
|
||||
self.port = port
|
||||
self._serial: serial.Serial | None = None
|
||||
|
||||
# ---- Connection management
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Open the serial port. Auto-detects the device path when port is None.
|
||||
"""Open the serial port.
|
||||
|
||||
The port is opened with ``exclusive=True`` so only one program can own the
|
||||
laser controller at a time. This is required because the board is a single
|
||||
@@ -162,7 +45,7 @@ class Protocol:
|
||||
"""
|
||||
if self.is_connected:
|
||||
return
|
||||
port = self._port_name or self._detect_port()
|
||||
port = self.port
|
||||
try:
|
||||
self._serial = serial.Serial(
|
||||
port=port,
|
||||
@@ -194,13 +77,6 @@ class Protocol:
|
||||
def is_connected(self) -> bool:
|
||||
return self._serial is not None and self._serial.is_open
|
||||
|
||||
def _detect_port(self) -> str:
|
||||
"""Return first available serial port device path."""
|
||||
ports = list(serial.tools.list_ports.comports())
|
||||
if not ports:
|
||||
raise PortNotFoundError()
|
||||
return ports[0].device
|
||||
|
||||
# ---- Raw I/O
|
||||
|
||||
def send_raw(self, data: bytes) -> None:
|
||||
@@ -213,199 +89,45 @@ class Protocol:
|
||||
raise CommunicationError("Serial port is not connected")
|
||||
return self._serial.read(length)
|
||||
|
||||
# ---- Command encoders -----------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def encode_decode_enable(
|
||||
temp1: float,
|
||||
temp2: float,
|
||||
current1: float,
|
||||
current2: float,
|
||||
pi_coeff1_p: int,
|
||||
pi_coeff1_i: int,
|
||||
pi_coeff2_p: int,
|
||||
pi_coeff2_i: int,
|
||||
temp1: float, temp2: float, current1: float, current2: float,
|
||||
pi_coeff1_p: int, pi_coeff1_i: int, pi_coeff2_p: int, pi_coeff2_i: int,
|
||||
message_id: int,
|
||||
) -> bytes:
|
||||
"""
|
||||
Build DECODE_ENABLE command (0x1111).
|
||||
|
||||
Sets temperature and current setpoints for both lasers.
|
||||
Returns 30-byte bytearray.
|
||||
"""
|
||||
if current1 < 0 or current2 < 0:
|
||||
raise ValueError("Current values must not be negative")
|
||||
|
||||
data = _flipfour(_int_to_hex4(CMD_DECODE_ENABLE)) # Word 0
|
||||
data += _flipfour(_encode_setup()) # Word 1
|
||||
data += _flipfour(_int_to_hex4(temp_c_to_n(temp1))) # Word 2
|
||||
data += _flipfour(_int_to_hex4(temp_c_to_n(temp2))) # Word 3
|
||||
data += _flipfour('0000') * 3 # Words 4-6
|
||||
data += _flipfour(_int_to_hex4(pi_coeff1_p)) # Word 7
|
||||
data += _flipfour(_int_to_hex4(pi_coeff1_i)) # Word 8
|
||||
data += _flipfour(_int_to_hex4(pi_coeff2_p)) # Word 9
|
||||
data += _flipfour(_int_to_hex4(pi_coeff2_i)) # Word 10
|
||||
data += _flipfour(_int_to_hex4(message_id & 0xFFFF)) # Word 11
|
||||
data += _flipfour(_int_to_hex4(current_ma_to_n(current1))) # Word 12
|
||||
data += _flipfour(_int_to_hex4(current_ma_to_n(current2))) # Word 13
|
||||
data += _build_crc(data) # Word 14
|
||||
|
||||
result = bytearray.fromhex(data)
|
||||
assert len(result) == SEND_PARAMS_TOTAL_LENGTH, \
|
||||
f"DECODE_ENABLE length mismatch: {len(result)}"
|
||||
return bytes(result)
|
||||
"""DECODE_ENABLE: constant currents, temperatures and PI coefficients."""
|
||||
validate_manual(temp1, temp2, current1, current2)
|
||||
# 0x37ff enables both channels and their regulators; SD recording is off.
|
||||
return _command(
|
||||
0x1111, 0x37ff, temp_c_to_n(temp1), temp_c_to_n(temp2), 0, 0, 0,
|
||||
pi_coeff1_p, pi_coeff1_i, pi_coeff2_p, pi_coeff2_i, message_id & 0xffff,
|
||||
current_ma_to_n(current1), current_ma_to_n(current2),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def encode_task_enable(
|
||||
task_type: TaskType,
|
||||
static_temp1: float,
|
||||
static_temp2: float,
|
||||
static_current1: float,
|
||||
static_current2: float,
|
||||
min_value: float,
|
||||
max_value: float,
|
||||
step: float,
|
||||
time_step: int,
|
||||
delay_time: int,
|
||||
message_id: int,
|
||||
pi_coeff1_p: int = 1,
|
||||
pi_coeff1_i: int = 1,
|
||||
pi_coeff2_p: int = 1,
|
||||
pi_coeff2_i: int = 1,
|
||||
static_temp1: float, static_temp2: float, static_current1: float, static_current2: float,
|
||||
min_value: float, max_value: float, step: float, time_step: int, delay_time: int,
|
||||
pi_coeff1_p: int = 1, pi_coeff1_i: int = 1, pi_coeff2_p: int = 1, pi_coeff2_i: int = 1,
|
||||
variation_type: str = "CHANGE_CURRENT_LD1",
|
||||
) -> bytes:
|
||||
"""
|
||||
Build TASK_ENABLE command (0x7777).
|
||||
|
||||
Starts a measurement task (current or temperature variation).
|
||||
Returns 32-byte bytearray.
|
||||
"""
|
||||
if not isinstance(task_type, TaskType):
|
||||
try:
|
||||
task_type = TaskType(task_type)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid task_type: {task_type}")
|
||||
|
||||
data = _flipfour(_int_to_hex4(CMD_TASK_ENABLE)) # Word 0
|
||||
data += _flipfour(_encode_setup()) # Word 1
|
||||
data += _flipfour(_int_to_hex4(task_type.value)) # Word 2
|
||||
|
||||
match task_type:
|
||||
case TaskType.CHANGE_CURRENT_LD1:
|
||||
data += _flipfour(_int_to_hex4(current_ma_to_n(min_value))) # Word 3
|
||||
data += _flipfour(_int_to_hex4(current_ma_to_n(max_value))) # Word 4
|
||||
data += _flipfour(_int_to_hex4(current_ma_to_n(step))) # Word 5
|
||||
data += _flipfour(_int_to_hex4(int(time_step * 100))) # Word 6: Delta_Time_µs × 100
|
||||
data += _flipfour(_int_to_hex4(temp_c_to_n(static_temp1))) # Word 7
|
||||
data += _flipfour(_int_to_hex4(current_ma_to_n(static_current2)))# Word 8
|
||||
data += _flipfour(_int_to_hex4(temp_c_to_n(static_temp2))) # Word 9
|
||||
case TaskType.CHANGE_CURRENT_LD2:
|
||||
data += _flipfour(_int_to_hex4(current_ma_to_n(min_value))) # Word 3
|
||||
data += _flipfour(_int_to_hex4(current_ma_to_n(max_value))) # Word 4
|
||||
# Word 5: current step uses the same current_ma_to_n scaling as
|
||||
# min/max (and as LD1) so equal physical steps map to equal wire values.
|
||||
data += _flipfour(_int_to_hex4(current_ma_to_n(step))) # Word 5
|
||||
data += _flipfour(_int_to_hex4(int(time_step * 100))) # Word 6: Delta_Time_µs × 100
|
||||
data += _flipfour(_int_to_hex4(temp_c_to_n(static_temp2))) # Word 7
|
||||
data += _flipfour(_int_to_hex4(current_ma_to_n(static_current1)))# Word 8
|
||||
data += _flipfour(_int_to_hex4(temp_c_to_n(static_temp1))) # Word 9
|
||||
case TaskType.CHANGE_TEMPERATURE_LD1 | TaskType.CHANGE_TEMPERATURE_LD2:
|
||||
raise NotImplementedError("Temperature variation is not yet implemented in firmware")
|
||||
case _:
|
||||
raise ValueError(f"Unsupported task type: {task_type}")
|
||||
|
||||
data += _flipfour(_int_to_hex4(int(delay_time))) # Word 10: Tau in ms (3-10)
|
||||
data += _flipfour(_int_to_hex4(pi_coeff1_p)) # Word 11
|
||||
data += _flipfour(_int_to_hex4(pi_coeff1_i)) # Word 12
|
||||
data += _flipfour(_int_to_hex4(pi_coeff2_p)) # Word 13
|
||||
data += _flipfour(_int_to_hex4(pi_coeff2_i)) # Word 14
|
||||
data += _build_crc(data) # Word 15
|
||||
|
||||
result = bytearray.fromhex(data)
|
||||
assert len(result) == TASK_ENABLE_COMMAND_LENGTH, \
|
||||
f"TASK_ENABLE length mismatch: {len(result)}"
|
||||
return bytes(result)
|
||||
|
||||
@staticmethod
|
||||
def encode_trans_enable(message_id: int = 0) -> bytes:
|
||||
"""Build TRANS_ENABLE command (0x4444) — request last data."""
|
||||
return bytes.fromhex(_flipfour(_int_to_hex4(CMD_TRANS_ENABLE)))
|
||||
|
||||
@staticmethod
|
||||
def encode_state(message_id: int = 0) -> bytes:
|
||||
"""Build STATE command (0x6666) — request device state."""
|
||||
return bytes.fromhex(_flipfour(_int_to_hex4(CMD_STATE)))
|
||||
|
||||
@staticmethod
|
||||
def encode_default_enable(message_id: int = 0) -> bytes:
|
||||
"""Build DEFAULT_ENABLE command (0x2222) — reset device."""
|
||||
return bytes.fromhex(_flipfour(_int_to_hex4(CMD_DEFAULT_ENABLE)))
|
||||
|
||||
# ---- Response decoders -----------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def decode_response(data: bytes) -> Response:
|
||||
"""
|
||||
Decode a 30-byte DATA response from the device.
|
||||
|
||||
Raises:
|
||||
ProtocolError: If data length is wrong.
|
||||
CRCError: If CRC check fails.
|
||||
"""
|
||||
if len(data) != GET_DATA_TOTAL_LENGTH:
|
||||
raise ProtocolError(
|
||||
f"Expected {GET_DATA_TOTAL_LENGTH} bytes, got {len(data)} bytes"
|
||||
)
|
||||
|
||||
hex_str = data.hex()
|
||||
|
||||
def get_word(num: int) -> str:
|
||||
return _flipfour(hex_str[num*4: num*4+4])
|
||||
|
||||
def get_int_word(num: int) -> int:
|
||||
return int(get_word(num), 16)
|
||||
|
||||
# CRC check: XOR over words 1..13 (wire order), compare with word 14 (wire order)
|
||||
crc_words = [hex_str[i:i+4] for i in range(4, len(hex_str)-4, 4)]
|
||||
computed = int(crc_words[0], 16)
|
||||
for w in crc_words[1:]:
|
||||
computed ^= int(w, 16)
|
||||
stored = int(hex_str[56:60], 16)
|
||||
if computed != stored:
|
||||
raise CRCError(expected=computed, received=stored)
|
||||
|
||||
resp = Response()
|
||||
resp.header = get_word(0)
|
||||
resp.current1 = current_n_to_ma(get_int_word(1))
|
||||
resp.current2 = current_n_to_ma(get_int_word(2))
|
||||
resp.to6_lsb = get_int_word(3)
|
||||
resp.to6_msb = get_int_word(4)
|
||||
resp.temp1 = temp_n_to_c(get_int_word(5))
|
||||
resp.temp2 = temp_n_to_c(get_int_word(6))
|
||||
resp.temp_ext1 = temp_ext_n_to_c(get_int_word(7))
|
||||
resp.temp_ext2 = temp_ext_n_to_c(get_int_word(8))
|
||||
resp.voltage_3v3 = voltage_3v3_n_to_v(get_int_word(9))
|
||||
resp.voltage_5v1 = voltage_5v_n_to_v(get_int_word(10))
|
||||
resp.voltage_5v2 = voltage_5v_n_to_v(get_int_word(11))
|
||||
resp.voltage_7v0 = voltage_7v_n_to_v(get_int_word(12))
|
||||
resp.message_id = get_int_word(13)
|
||||
|
||||
return resp
|
||||
"""TASK_ENABLE: sweep LD1 current while LD2 and both temperatures stay fixed."""
|
||||
if variation_type != "CHANGE_CURRENT_LD1":
|
||||
raise ValueError("radar.laser_control.variation.variation_type must be CHANGE_CURRENT_LD1")
|
||||
validate_manual(static_temp1, static_temp2, static_current1, static_current2)
|
||||
validate_variation(min_value, max_value, step, time_step, delay_time)
|
||||
low, high, delta = (current_ma_to_n(value) for value in (min_value, max_value, step))
|
||||
if delta == 0 or low >= high:
|
||||
raise ValueError("Current variation range and step must be representable by the DAC")
|
||||
return _command(
|
||||
0x7777, 0x37ff, 1, low, high, delta, int(time_step * 100),
|
||||
temp_c_to_n(static_temp1), current_ma_to_n(static_current2), temp_c_to_n(static_temp2),
|
||||
int(delay_time), pi_coeff1_p, pi_coeff1_i, pi_coeff2_p, pi_coeff2_i,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def decode_state(data: bytes) -> int:
|
||||
"""
|
||||
Decode a 2-byte STATE response from the device.
|
||||
|
||||
Returns:
|
||||
Integer state code; see STATE_* constants.
|
||||
"""
|
||||
if len(data) < 2:
|
||||
raise ProtocolError(f"STATE response too short: {len(data)} bytes")
|
||||
hex_str = data.hex()
|
||||
state_hex = _flipfour(hex_str[0:4])
|
||||
return int(state_hex, 16)
|
||||
|
||||
@staticmethod
|
||||
def state_to_description(state_hex_str: str) -> str:
|
||||
"""Return human-readable description for a state hex string."""
|
||||
return STATE_DESCRIPTIONS.get(state_hex_str, "Unknown or reserved error.")
|
||||
"""STATE is a two-byte error bitmask; zero acknowledges the command."""
|
||||
if len(data) != 2:
|
||||
raise ProtocolError(f"Expected 2 STATE bytes, got {len(data)}")
|
||||
return int.from_bytes(data, "little")
|
||||
|
||||
@@ -1,257 +1,33 @@
|
||||
"""
|
||||
Parameter validation for laser control module.
|
||||
|
||||
Validates all input parameters against physical constraints
|
||||
and protocol limits before sending to device.
|
||||
"""
|
||||
"""Physical limits checked before command encoding or any board reset."""
|
||||
|
||||
import math
|
||||
from typing import Dict, Any, Tuple
|
||||
|
||||
from .constants import (
|
||||
TEMP_MIN_C, TEMP_MAX_C,
|
||||
CURRENT_MIN_MA, CURRENT_MAX_MA,
|
||||
TEMP_MIN_C, TEMP_MAX_C, CURRENT_MIN_MA, CURRENT_MAX_MA,
|
||||
CURRENT_STEP_MIN_MA, CURRENT_STEP_MAX_MA,
|
||||
TEMP_STEP_MIN_C, TEMP_STEP_MAX_C,
|
||||
TIME_STEP_MIN_US, TIME_STEP_MAX_US,
|
||||
DELAY_TIME_MIN_MS, DELAY_TIME_MAX_MS,
|
||||
TIME_STEP_MIN_US, TIME_STEP_MAX_US, DELAY_TIME_MIN_MS, DELAY_TIME_MAX_MS,
|
||||
)
|
||||
from .exceptions import (
|
||||
ValidationError,
|
||||
TemperatureOutOfRangeError,
|
||||
CurrentOutOfRangeError,
|
||||
InvalidParameterError,
|
||||
)
|
||||
from .models import VariationType
|
||||
from .exceptions import ValidationError
|
||||
|
||||
|
||||
class ParameterValidator:
|
||||
"""Validates all input parameters for the laser controller."""
|
||||
def _range(value: float, name: str, low: float, high: float) -> None:
|
||||
if not isinstance(value, (int, float)) or not math.isfinite(value) or not low <= value <= high:
|
||||
raise ValidationError(f"{name} must be a finite number in [{low}, {high}], got {value!r}")
|
||||
|
||||
@staticmethod
|
||||
def _check_numeric(value: Any, param_name: str) -> float:
|
||||
"""Check that value is a valid finite number. Returns float."""
|
||||
if value is None:
|
||||
raise InvalidParameterError(param_name, "Value must not be None")
|
||||
if not isinstance(value, (int, float)):
|
||||
raise InvalidParameterError(param_name, "Value must be a number")
|
||||
if math.isnan(value):
|
||||
raise InvalidParameterError(param_name, "Value must not be NaN")
|
||||
if math.isinf(value):
|
||||
raise InvalidParameterError(param_name, "Value must not be infinite")
|
||||
return float(value)
|
||||
|
||||
@staticmethod
|
||||
def validate_temperature(value: Any, param_name: str) -> float:
|
||||
"""
|
||||
Validate a laser temperature value.
|
||||
def validate_manual(temp1: float, temp2: float, current1: float, current2: float) -> None:
|
||||
for name, value in (("temp1", temp1), ("temp2", temp2)):
|
||||
_range(value, name, TEMP_MIN_C, TEMP_MAX_C)
|
||||
for name, value in (("current1", current1), ("current2", current2)):
|
||||
_range(value, name, CURRENT_MIN_MA, CURRENT_MAX_MA)
|
||||
|
||||
Args:
|
||||
value: Temperature in °C.
|
||||
param_name: Parameter name for error messages.
|
||||
|
||||
Returns:
|
||||
Validated temperature as float.
|
||||
|
||||
Raises:
|
||||
InvalidParameterError: If value is not a valid number.
|
||||
TemperatureOutOfRangeError: If value is outside [TEMP_MIN_C, TEMP_MAX_C].
|
||||
"""
|
||||
value = ParameterValidator._check_numeric(value, param_name)
|
||||
if value < TEMP_MIN_C or value > TEMP_MAX_C:
|
||||
raise TemperatureOutOfRangeError(
|
||||
param_name, value, TEMP_MIN_C, TEMP_MAX_C
|
||||
)
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def validate_current(value: Any, param_name: str) -> float:
|
||||
"""
|
||||
Validate a laser drive current value.
|
||||
|
||||
Args:
|
||||
value: Current in mA.
|
||||
param_name: Parameter name for error messages.
|
||||
|
||||
Returns:
|
||||
Validated current as float.
|
||||
|
||||
Raises:
|
||||
InvalidParameterError: If value is not a valid number.
|
||||
CurrentOutOfRangeError: If value is outside [CURRENT_MIN_MA, CURRENT_MAX_MA].
|
||||
"""
|
||||
value = ParameterValidator._check_numeric(value, param_name)
|
||||
if value < CURRENT_MIN_MA or value > CURRENT_MAX_MA:
|
||||
raise CurrentOutOfRangeError(
|
||||
param_name, value, CURRENT_MIN_MA, CURRENT_MAX_MA
|
||||
)
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def validate_time_params(time_step: Any, delay_time: Any) -> Tuple[int, int]:
|
||||
"""
|
||||
Validate time parameters for variation mode.
|
||||
|
||||
Args:
|
||||
time_step: Discretisation time step in microseconds.
|
||||
delay_time: Delay between pulses in milliseconds.
|
||||
|
||||
Returns:
|
||||
Tuple (time_step, delay_time) as integers.
|
||||
|
||||
Raises:
|
||||
InvalidParameterError: If values are not numeric.
|
||||
ValidationError: If values are outside allowed ranges.
|
||||
"""
|
||||
if not isinstance(time_step, (int, float)):
|
||||
raise InvalidParameterError("time_step", "Value must be a number")
|
||||
if not isinstance(delay_time, (int, float)):
|
||||
raise InvalidParameterError("delay_time", "Value must be a number")
|
||||
|
||||
time_step_int = int(time_step)
|
||||
delay_time_int = int(delay_time)
|
||||
|
||||
if time_step_int < TIME_STEP_MIN_US or time_step_int > TIME_STEP_MAX_US:
|
||||
raise ValidationError(
|
||||
f"time step {time_step_int} µs is out of range "
|
||||
f"[{TIME_STEP_MIN_US} - {TIME_STEP_MAX_US}] µs"
|
||||
)
|
||||
if delay_time_int < DELAY_TIME_MIN_MS or delay_time_int > DELAY_TIME_MAX_MS:
|
||||
raise ValidationError(
|
||||
f"delay time {delay_time_int} ms is out of range "
|
||||
f"[{DELAY_TIME_MIN_MS} - {DELAY_TIME_MAX_MS}] ms"
|
||||
)
|
||||
return time_step_int, delay_time_int
|
||||
|
||||
@staticmethod
|
||||
def validate_variation_params(
|
||||
params: Dict[str, Any],
|
||||
variation_type: Any
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Validate parameters for variation mode.
|
||||
|
||||
Args:
|
||||
params: Dictionary with keys:
|
||||
min_value, max_value, step, time_step, delay_time.
|
||||
variation_type: A VariationType enum value.
|
||||
|
||||
Returns:
|
||||
Dictionary with validated and type-coerced values.
|
||||
|
||||
Raises:
|
||||
ValidationError: For any constraint violation.
|
||||
InvalidParameterError: For wrong types.
|
||||
"""
|
||||
# Validate variation type
|
||||
if not isinstance(variation_type, VariationType):
|
||||
try:
|
||||
variation_type = VariationType(variation_type)
|
||||
except (ValueError, KeyError):
|
||||
raise ValidationError(
|
||||
f"Invalid variation type '{variation_type}'. "
|
||||
f"Must be one of {[e.name for e in VariationType]}"
|
||||
)
|
||||
|
||||
# Check required keys
|
||||
required_keys = {'min_value', 'max_value', 'step', 'time_step', 'delay_time'}
|
||||
missing = required_keys - params.keys()
|
||||
if missing:
|
||||
raise ValidationError(
|
||||
f"Missing required parameters: {sorted(missing)}"
|
||||
)
|
||||
|
||||
# Validate min/max
|
||||
min_val = ParameterValidator._check_numeric(params['min_value'], 'min_value')
|
||||
max_val = ParameterValidator._check_numeric(params['max_value'], 'max_value')
|
||||
|
||||
if min_val >= max_val:
|
||||
raise ValidationError(
|
||||
f"min_value ({min_val}) must be less than max_value ({max_val})"
|
||||
)
|
||||
|
||||
# Validate step based on variation type
|
||||
step = ParameterValidator._check_numeric(params['step'], 'step')
|
||||
|
||||
is_current_variation = variation_type in (
|
||||
VariationType.CHANGE_CURRENT_LD1,
|
||||
VariationType.CHANGE_CURRENT_LD2
|
||||
)
|
||||
is_temp_variation = variation_type in (
|
||||
VariationType.CHANGE_TEMPERATURE_LD1,
|
||||
VariationType.CHANGE_TEMPERATURE_LD2
|
||||
)
|
||||
|
||||
if is_current_variation:
|
||||
step_min, step_max = CURRENT_STEP_MIN_MA, CURRENT_STEP_MAX_MA
|
||||
unit = "mA"
|
||||
# Also validate range against current limits
|
||||
ParameterValidator.validate_current(min_val, 'min_value')
|
||||
ParameterValidator.validate_current(max_val, 'max_value')
|
||||
elif is_temp_variation:
|
||||
step_min, step_max = TEMP_STEP_MIN_C, TEMP_STEP_MAX_C
|
||||
unit = "°C"
|
||||
# Also validate range against temperature limits
|
||||
ParameterValidator.validate_temperature(min_val, 'min_value')
|
||||
ParameterValidator.validate_temperature(max_val, 'max_value')
|
||||
else:
|
||||
raise ValidationError(
|
||||
f"Variation type {variation_type.name} cannot be used in variation mode"
|
||||
)
|
||||
|
||||
if step <= 0:
|
||||
raise ValidationError(
|
||||
f"step must be positive, got {step} {unit}"
|
||||
)
|
||||
if step < step_min:
|
||||
raise ValidationError(
|
||||
f"step {step} {unit} is too small (minimum {step_min} {unit})"
|
||||
)
|
||||
if step > step_max:
|
||||
raise ValidationError(
|
||||
f"step {step} {unit} is too large (maximum {step_max} {unit})"
|
||||
)
|
||||
|
||||
# Validate time parameters
|
||||
time_step, delay_time = ParameterValidator.validate_time_params(
|
||||
params['time_step'], params['delay_time']
|
||||
)
|
||||
|
||||
return {
|
||||
'variation_type': variation_type,
|
||||
'min_value': min_val,
|
||||
'max_value': max_val,
|
||||
'step': step,
|
||||
'time_step': time_step,
|
||||
'delay_time': delay_time,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def validate_manual_mode_params(
|
||||
temp1: Any,
|
||||
temp2: Any,
|
||||
current1: Any,
|
||||
current2: Any,
|
||||
) -> Dict[str, float]:
|
||||
"""
|
||||
Validate all four manual mode parameters.
|
||||
|
||||
Args:
|
||||
temp1: Laser 1 temperature, °C.
|
||||
temp2: Laser 2 temperature, °C.
|
||||
current1: Laser 1 current, mA.
|
||||
current2: Laser 2 current, mA.
|
||||
|
||||
Returns:
|
||||
Dict with validated floats: temp1, temp2, current1, current2.
|
||||
|
||||
Raises:
|
||||
ValidationError: For any out-of-range value.
|
||||
InvalidParameterError: For wrong types.
|
||||
"""
|
||||
return {
|
||||
'temp1': ParameterValidator.validate_temperature(temp1, 'temp1'),
|
||||
'temp2': ParameterValidator.validate_temperature(temp2, 'temp2'),
|
||||
'current1': ParameterValidator.validate_current(current1, 'current1'),
|
||||
'current2': ParameterValidator.validate_current(current2, 'current2'),
|
||||
}
|
||||
def validate_variation(min_value: float, max_value: float, step: float,
|
||||
time_step: int, delay_time: int) -> None:
|
||||
for name, value in (("min_value", min_value), ("max_value", max_value)):
|
||||
_range(value, name, CURRENT_MIN_MA, CURRENT_MAX_MA)
|
||||
if min_value >= max_value:
|
||||
raise ValidationError("min_value must be less than max_value")
|
||||
_range(step, "step", CURRENT_STEP_MIN_MA, CURRENT_STEP_MAX_MA)
|
||||
_range(time_step, "time_step (us)", TIME_STEP_MIN_US, TIME_STEP_MAX_US)
|
||||
_range(delay_time, "delay_time (ms)", DELAY_TIME_MIN_MS, DELAY_TIME_MAX_MS)
|
||||
|
||||
@@ -36,7 +36,7 @@ class MultiDeviceVnaController:
|
||||
self,
|
||||
master_serial_number: str,
|
||||
slave_serial_numbers: Sequence[str] = (),
|
||||
force_external_reference: bool = True,
|
||||
force_external_reference: bool = False,
|
||||
switch_substage_count: int = 1,
|
||||
) -> None:
|
||||
"""Open all configured devices and initialize controller state.
|
||||
|
||||
@@ -51,7 +51,7 @@ class MultiDeviceLibreVnaService:
|
||||
|
||||
master_serial: str
|
||||
slave_serials: list[str]
|
||||
force_external_reference: bool = True
|
||||
force_external_reference: bool = False
|
||||
recovery_attempts: int = 3
|
||||
# Stimulus switch positions stepped through WITHIN one frequency point. 1 keeps the
|
||||
# stock 2-output matrix; 2 doubles the outputs (4) without a second sweep, at the
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""L-Card acquisition, reference-phase processing and laser configuration."""
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Apply constant laser settings or LD1 current variation before ADC acquisition."""
|
||||
|
||||
from dataclasses import asdict
|
||||
|
||||
from python_app.hardware_full.laser_control.controller import DEVICE_MAIN_MESSAGE_ID, LaserController
|
||||
from python_app.hardware_full.laser_control.protocol import Protocol
|
||||
from python_app.models.run_config_schema import RunConfigModel
|
||||
|
||||
|
||||
def apply_rph_radar_laser_control(config: RunConfigModel) -> bool:
|
||||
"""Validate every command before connecting/resetting; leave a started sweep running."""
|
||||
laser = config.radar.laser_control
|
||||
if not laser.enabled:
|
||||
return False
|
||||
if not laser.port:
|
||||
raise ValueError("radar.laser_control.port is required when laser_control is enabled")
|
||||
mode = laser.mode.strip().lower()
|
||||
if mode not in {"manual", "variation"}:
|
||||
raise ValueError("radar.laser_control.mode must be 'manual' or 'variation'")
|
||||
pi = {name: getattr(laser, name) for name in ("pi_coeff1_p", "pi_coeff1_i", "pi_coeff2_p", "pi_coeff2_i")}
|
||||
manual = (asdict(laser.manual) if mode == "manual" else
|
||||
{name: getattr(laser.variation, "static_" + name) for name in ("temp1", "temp2", "current1", "current2")})
|
||||
variation = asdict(laser.variation)
|
||||
Protocol.encode_decode_enable(**manual, **pi, message_id=DEVICE_MAIN_MESSAGE_ID)
|
||||
if mode == "variation":
|
||||
Protocol.encode_task_enable(**variation, **pi)
|
||||
|
||||
controller = LaserController(port=laser.port, **pi)
|
||||
try:
|
||||
controller.connect()
|
||||
controller.reset()
|
||||
controller.set_manual_mode(**manual, message_id=DEVICE_MAIN_MESSAGE_ID)
|
||||
if mode == "variation":
|
||||
controller.start_variation(params=variation)
|
||||
return True
|
||||
finally:
|
||||
controller.disconnect()
|
||||
+20
-85
@@ -1,8 +1,8 @@
|
||||
"""Signal processing for the Kamil ADC reference-channel acquisition.
|
||||
"""Signal processing for the RadioPhotonic radar (RPH) reference-channel acquisition.
|
||||
|
||||
Each sweep arrives as two aligned complex arrays — the *main* signal and a
|
||||
*reference* signal — sampled at the same step indices (see
|
||||
:mod:`python_app.hardware_full.kamil_adc.protocol`). Turning that into a stable,
|
||||
:mod:`python_app.hardware_full.rph_radar.protocol`). Turning that into a stable,
|
||||
comparable S21 trace is a fixed three-stage pipeline:
|
||||
|
||||
1. **Frequency axis from the reference phase.** The reference arm has a constant
|
||||
@@ -41,12 +41,13 @@ Everything here is pure and free of I/O so it can be unit-tested in isolation.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import replace
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from python_app.models.run_config_schema import KamilAdcModel
|
||||
from python_app.models.run_config_schema import RphRadarModel
|
||||
from python_app.models.run_config_validation import validate_rph_radar_model
|
||||
|
||||
# A reference sample whose magnitude falls to (effectively) zero carries no usable
|
||||
# amplitude or phase; such points are dropped before normalization rather than
|
||||
@@ -61,94 +62,28 @@ _MIN_USABLE_POINTS = 2
|
||||
# One full turn of reference phase. ``np.unwrap`` anchors each sweep's phase ramp
|
||||
# to the raw angle of the first sample on the (-pi, pi] branch, so a stray sweep
|
||||
# whose anchor crossed the +/-pi cut is offset by exactly this; the cross-sweep
|
||||
# anchor tracking snaps it back (see ``KamilAdcSweepProcessor._align_phase_branch``).
|
||||
# anchor tracking snaps it back (see ``RphRadarSweepProcessor._align_phase_branch``).
|
||||
_PHASE_BRANCH_PERIOD_RAD = 2.0 * np.pi
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class KamilAdcProcessingParams:
|
||||
"""Immutable phase→frequency calibration and output-grid definition.
|
||||
|
||||
Built from :class:`~python_app.models.run_config_schema.KamilAdcModel` via
|
||||
:meth:`from_kamil_model`, but kept decoupled from the config types so the
|
||||
processing can be exercised with plain numbers in tests.
|
||||
"""
|
||||
|
||||
phase0_rad: float
|
||||
freq0_hz: float
|
||||
phase1_rad: float
|
||||
freq1_hz: float
|
||||
band_start_hz: float
|
||||
band_stop_hz: float
|
||||
band_points: int
|
||||
reference_amplitude_floor: float = _REFERENCE_AMPLITUDE_FLOOR
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not np.isfinite([self.phase0_rad, self.phase1_rad, self.freq0_hz, self.freq1_hz]).all():
|
||||
raise ValueError("Kamil ADC phase calibration anchors must be finite")
|
||||
if self.phase0_rad == self.phase1_rad:
|
||||
raise ValueError("Kamil ADC phase calibration anchors must use distinct phases")
|
||||
if self.freq0_hz == self.freq1_hz:
|
||||
raise ValueError("Kamil ADC phase calibration anchors must map to distinct frequencies")
|
||||
if not (np.isfinite(self.band_start_hz) and np.isfinite(self.band_stop_hz)):
|
||||
raise ValueError("Kamil ADC band edges must be finite")
|
||||
if self.band_stop_hz <= self.band_start_hz:
|
||||
raise ValueError("Kamil ADC band stop_hz must be greater than start_hz")
|
||||
if self.band_points < 2:
|
||||
raise ValueError("Kamil ADC band points must be >= 2")
|
||||
if self.reference_amplitude_floor <= 0.0:
|
||||
raise ValueError("Kamil ADC reference amplitude floor must be > 0")
|
||||
|
||||
@classmethod
|
||||
def from_kamil_model(cls, kamil_adc: KamilAdcModel) -> KamilAdcProcessingParams:
|
||||
"""Build parameters from the ``radar.kamil_adc`` config section."""
|
||||
calibration = kamil_adc.phase_calibration
|
||||
band = kamil_adc.band
|
||||
return cls(
|
||||
phase0_rad=float(calibration.phase0_rad),
|
||||
freq0_hz=float(calibration.freq0_hz),
|
||||
phase1_rad=float(calibration.phase1_rad),
|
||||
freq1_hz=float(calibration.freq1_hz),
|
||||
band_start_hz=float(band.start_hz),
|
||||
band_stop_hz=float(band.stop_hz),
|
||||
band_points=int(band.points),
|
||||
)
|
||||
|
||||
@property
|
||||
def hz_per_rad(self) -> float:
|
||||
"""Frequency change per radian of reference phase (the calibration slope)."""
|
||||
return (self.freq1_hz - self.freq0_hz) / (self.phase1_rad - self.phase0_rad)
|
||||
|
||||
|
||||
class KamilAdcSweepProcessor:
|
||||
class RphRadarSweepProcessor:
|
||||
"""Turns aligned (main, reference) sweeps into S21 traces on a fixed grid.
|
||||
|
||||
The output grid is computed once from the parameters and reused for every
|
||||
sweep, so all traces this processor emits share one identical frequency axis.
|
||||
"""
|
||||
|
||||
__slots__ = ("_params", "_grid_hz", "_previous_anchor_rad")
|
||||
|
||||
def __init__(self, params: KamilAdcProcessingParams) -> None:
|
||||
self._params = params
|
||||
self._grid_hz = np.linspace(
|
||||
params.band_start_hz, params.band_stop_hz, params.band_points, dtype=np.float64
|
||||
)
|
||||
# Absolute (branch-tracked) reference phase of the last ACCEPTED sweep's
|
||||
# first sample, carried across sweeps so a +/-pi anchor wrap can be undone.
|
||||
# ``None`` until the first sweep is accepted; reset by building a new
|
||||
# processor (i.e. on reconfigure).
|
||||
def __init__(self, config: RphRadarModel) -> None:
|
||||
validate_rph_radar_model(config)
|
||||
self._calibration = replace(config.phase_calibration)
|
||||
self._band = replace(config.band)
|
||||
self._hz_per_rad = ((self._calibration.freq1_hz - self._calibration.freq0_hz)
|
||||
/ (self._calibration.phase1_rad - self._calibration.phase0_rad))
|
||||
self._grid_hz = np.linspace(self._band.start_hz, self._band.stop_hz, self._band.points)
|
||||
self.grid_hz = self._grid_hz.astype(np.float32)
|
||||
self.grid_hz.setflags(write=False)
|
||||
self._previous_anchor_rad: float | None = None
|
||||
|
||||
@property
|
||||
def params(self) -> KamilAdcProcessingParams:
|
||||
return self._params
|
||||
|
||||
@property
|
||||
def grid_hz(self) -> np.ndarray:
|
||||
"""The fixed output frequency axis (float32), identical for every sweep."""
|
||||
return self._grid_hz.astype(np.float32)
|
||||
|
||||
def reference_frequency_axis(self, reference: np.ndarray) -> np.ndarray:
|
||||
"""Map a reference signal's absolute unwrapped phase to frequency (Hz).
|
||||
|
||||
@@ -161,7 +96,7 @@ class KamilAdcSweepProcessor:
|
||||
|
||||
def _phase_to_frequency(self, phase: np.ndarray) -> np.ndarray:
|
||||
"""Apply the affine ``phase -> frequency`` calibration law."""
|
||||
return self._params.freq0_hz + (phase - self._params.phase0_rad) * self._params.hz_per_rad
|
||||
return self._calibration.freq0_hz + (phase - self._calibration.phase0_rad) * self._hz_per_rad
|
||||
|
||||
def _align_phase_branch(self, phase: np.ndarray) -> tuple[np.ndarray, float]:
|
||||
"""Undo a stray +/-pi anchor wrap by unwrapping the anchor across sweeps.
|
||||
@@ -182,7 +117,7 @@ class KamilAdcSweepProcessor:
|
||||
reference_anchor = (
|
||||
self._previous_anchor_rad
|
||||
if self._previous_anchor_rad is not None
|
||||
else self._params.phase0_rad
|
||||
else self._calibration.phase0_rad
|
||||
)
|
||||
turns = round((reference_anchor - anchor) / _PHASE_BRANCH_PERIOD_RAD)
|
||||
if turns:
|
||||
@@ -213,7 +148,7 @@ class KamilAdcSweepProcessor:
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
s21 = main / reference_amplitude
|
||||
usable = (
|
||||
(reference_amplitude > self._params.reference_amplitude_floor)
|
||||
(reference_amplitude > _REFERENCE_AMPLITUDE_FLOOR)
|
||||
& np.isfinite(freqs)
|
||||
& np.isfinite(s21.real)
|
||||
& np.isfinite(s21.imag)
|
||||
@@ -232,7 +167,7 @@ class KamilAdcSweepProcessor:
|
||||
|
||||
# Reject sweeps that do not span the whole band: interpolating past the
|
||||
# measured edge would inject flat, non-physical points.
|
||||
if freqs[0] > self._params.band_start_hz or freqs[-1] < self._params.band_stop_hz:
|
||||
if freqs[0] > self._band.start_hz or freqs[-1] < self._band.stop_hz:
|
||||
return None
|
||||
|
||||
# The sweep is accepted: commit its branch-aligned anchor so the next
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Whole-sweep RPH1 packets from the collector's PTY (little endian).
|
||||
|
||||
The 48-byte header carries payload size, source sweep/cycle IDs, monotonic capture
|
||||
window, input/output (65535 for untagged) and dirty flag. Payload points are
|
||||
8-byte ``marker, step, I, Q`` frames: A for main and A8 for reference.
|
||||
Only complete queued packets may be dropped; a packet already being written is
|
||||
always finished. Thus overflow cannot splice two physical sweeps together.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
import struct
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
MAIN_MARKER = 0x000A
|
||||
REFERENCE_MARKER = 0x00A8
|
||||
_HEADER = struct.Struct("<4sIQQQQHHI")
|
||||
_POINT = struct.Struct("<HHhh")
|
||||
_MAX_PAYLOAD_BYTES = 65534 * 2 * _POINT.size
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RawSweep:
|
||||
steps: NDArray[np.int32]
|
||||
main: NDArray[np.complex64]
|
||||
reference: NDArray[np.complex64]
|
||||
source_id: int
|
||||
cycle_id: int
|
||||
capture_start_ns: int
|
||||
capture_end_ns: int
|
||||
combo: tuple[int, int] | None = None
|
||||
dirty: bool = False
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return int(self.steps.size)
|
||||
|
||||
|
||||
class RphRadarStreamParser:
|
||||
"""Incremental packet parser; corrupt headers or point markers fail closed."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._buffer = bytearray()
|
||||
self._aligned = False
|
||||
|
||||
def reset(self) -> None:
|
||||
self._buffer.clear()
|
||||
self._aligned = False
|
||||
|
||||
def feed(self, data: bytes) -> list[RawSweep]:
|
||||
self._buffer.extend(data)
|
||||
if not self._aligned:
|
||||
index = self._buffer.find(b"RPH1")
|
||||
if index < 0:
|
||||
del self._buffer[:-3]
|
||||
return []
|
||||
del self._buffer[:index]
|
||||
self._aligned = True
|
||||
sweeps = []
|
||||
while len(self._buffer) >= _HEADER.size:
|
||||
magic, size, source_id, cycle_id, start, end, inp, out, flags = _HEADER.unpack_from(self._buffer)
|
||||
if (magic != b"RPH1" or size > _MAX_PAYLOAD_BYTES or size % _POINT.size
|
||||
or not source_id or not cycle_id or not 0 < start <= end
|
||||
or flags & ~1 or (inp == 65535) != (out == 65535)):
|
||||
raise ValueError("RadioPhotonic radar (RPH) protocol violation: invalid sweep header")
|
||||
packet_size = _HEADER.size + size
|
||||
if len(self._buffer) < packet_size:
|
||||
break
|
||||
main: dict[int, complex] = {}
|
||||
reference: dict[int, complex] = {}
|
||||
for marker, step, real, imag in _POINT.iter_unpack(self._buffer[_HEADER.size:packet_size]):
|
||||
if marker not in (MAIN_MARKER, REFERENCE_MARKER) or not 0 < step < 65535:
|
||||
raise ValueError("RadioPhotonic radar (RPH) protocol violation: invalid point frame")
|
||||
target = main if marker == MAIN_MARKER else reference
|
||||
if step in target:
|
||||
raise ValueError("RadioPhotonic radar (RPH) protocol violation: duplicate point")
|
||||
target[step] = complex(real, imag)
|
||||
del self._buffer[:packet_size]
|
||||
shared = sorted(main.keys() & reference.keys())
|
||||
if shared:
|
||||
sweeps.append(RawSweep(
|
||||
np.asarray(shared, dtype=np.int32),
|
||||
np.asarray([main[step] for step in shared], dtype=np.complex64),
|
||||
np.asarray([reference[step] for step in shared], dtype=np.complex64),
|
||||
source_id, cycle_id, start, end,
|
||||
None if inp == 65535 else (inp, out), bool(flags & 1),
|
||||
))
|
||||
return sweeps
|
||||
+109
-119
@@ -1,14 +1,15 @@
|
||||
"""Service that launches the Kamil ADC collector and serves processed sweeps.
|
||||
"""Service that launches the RadioPhotonic radar (RPH) collector and serves processed sweeps.
|
||||
|
||||
Owns the lifecycle of the external collector process and its TTY reader, and maps
|
||||
each raw (main, reference) sweep to an :class:`SweepResult` on the fixed
|
||||
processing grid via :class:`KamilAdcSweepProcessor`.
|
||||
processing grid via :class:`RphRadarSweepProcessor`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import asdict, dataclass, field
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -20,23 +21,21 @@ import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.hardware_full.kamil_adc.processing import (
|
||||
KamilAdcProcessingParams,
|
||||
KamilAdcSweepProcessor,
|
||||
)
|
||||
from python_app.hardware_full.kamil_adc.protocol import RawSweep
|
||||
from python_app.hardware_full.kamil_adc.tty_reader import (
|
||||
KamilAdcTtyReader,
|
||||
from python_app.hardware_full.rph_radar.processing import RphRadarSweepProcessor
|
||||
from python_app.hardware_full.rph_radar.protocol import RawSweep
|
||||
from python_app.hardware_full.rph_radar.tty_reader import (
|
||||
RphRadarTtyReader,
|
||||
raise_if_process_exited,
|
||||
)
|
||||
from python_app.hardware_full.librevna_driver.models import SweepResult
|
||||
from python_app.models.dataset_model import ComboKey
|
||||
from python_app.models.run_config_schema import RadarSweepModel, RunConfigModel
|
||||
from python_app.models.run_config_validation import validate_rph_radar_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Project root, used to resolve relative collector paths (e.g.
|
||||
# ``build/bin/kamil_adc_collector``) independent of the launching CWD.
|
||||
# ``build/bin/rph_radar_collector``) independent of the launching CWD.
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
# Default home of the proprietary L-Card runtime libraries the collector loads
|
||||
# via dlopen. Prepended to LD_LIBRARY_PATH unless the config pins it explicitly.
|
||||
@@ -48,41 +47,36 @@ _REJECT_LOG_EVERY = 50
|
||||
# brief window to release the device cleanly before escalating to SIGKILL. Caps
|
||||
# the configured stop_timeout_s so a stop can never hang.
|
||||
_STOP_KILL_GRACE_S = 0.5
|
||||
# Sweeps to skip after a Python-driven switch change before trusting a capture: one
|
||||
# for the pre-switch sweep still in the mailbox, one for a possible transition
|
||||
# straddler in flight. Calibration speed is not critical, so we err on safety.
|
||||
_SWITCH_DRAIN_SWEEPS = 2
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class KamilAdcService:
|
||||
"""Launch the external Kamil ADC collector and serve its processed sweeps."""
|
||||
class RphRadarService:
|
||||
"""Launch the external RadioPhotonic radar (RPH) collector and serve its processed sweeps."""
|
||||
|
||||
config: RunConfigModel
|
||||
# When set, the collector is launched with ``config:<path>`` so it drives the RF
|
||||
# switches itself (switch-aware mode) from this run_config.json. ``None`` keeps
|
||||
# the standalone collector that streams a single channel (calibration / mock).
|
||||
switch_config_path: str | None = None
|
||||
collector_driven: bool = False
|
||||
_process: subprocess.Popen[bytes] | None = field(init=False, default=None, repr=False)
|
||||
_reader: KamilAdcTtyReader | None = field(init=False, default=None, repr=False)
|
||||
_processor: KamilAdcSweepProcessor | None = field(init=False, default=None, repr=False)
|
||||
_reader: RphRadarTtyReader | None = field(init=False, default=None, repr=False)
|
||||
_processor: RphRadarSweepProcessor | None = field(init=False, default=None, repr=False)
|
||||
_rejected_count: int = field(init=False, default=0, repr=False)
|
||||
_prepared_combo: ComboKey | None = field(init=False, default=None, repr=False)
|
||||
last_cycle_id: int = field(init=False, default=0)
|
||||
_output_thread: threading.Thread | None = field(init=False, default=None, repr=False)
|
||||
_stop_event: threading.Event | None = field(init=False, default=None, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self._validate_config()
|
||||
|
||||
@property
|
||||
def command(self) -> list[str]:
|
||||
"""External collector command, including the generated ``tty:`` argument.
|
||||
|
||||
In switch-aware mode (``switch_config_path`` set) the collector also gets
|
||||
``config:<path>`` so it reads the switch/combo configuration and drives the
|
||||
switches in lock-step with the sweeps.
|
||||
"""
|
||||
adc = self.config.radar.kamil_adc
|
||||
"""Pass the current typed settings once; the collector never rereads the profile."""
|
||||
adc = self.config.radar.rph_radar
|
||||
cmd = [str(self._resolve_executable()), *adc.args]
|
||||
if self.switch_config_path is not None:
|
||||
cmd.append(f"config:{self.switch_config_path}")
|
||||
if self.collector_driven:
|
||||
switching = {"input": asdict(self.config.input_switch), "output": asdict(self.config.output_switch),
|
||||
"combos": [[combo.input, combo.output] for combo in self.config.combos],
|
||||
"settling_ms": self.config.runtime.settling_ms}
|
||||
cmd.append("switching:" + json.dumps(switching, separators=(",", ":")))
|
||||
cmd.append(f"tty:{adc.tty_path}")
|
||||
return cmd
|
||||
|
||||
@@ -94,51 +88,57 @@ class KamilAdcService:
|
||||
"""
|
||||
if self._reader is not None:
|
||||
return
|
||||
previous_tty_identity = _prepare_tty_path_for_collector(self.config.radar.kamil_adc.tty_path)
|
||||
if self._process is not None and self._process.poll() is None:
|
||||
raise RuntimeError("Previous RPH collector did not stop; cannot start a fresh capture")
|
||||
self._stop_event = stop_event
|
||||
previous_tty_identity = _prepare_tty_path_for_collector(self.config.radar.rph_radar.tty_path)
|
||||
try:
|
||||
self._start_process()
|
||||
self._wait_for_tty(previous_tty_identity, stop_event=stop_event)
|
||||
reader = KamilAdcTtyReader(self.config.radar.kamil_adc.tty_path)
|
||||
reader = RphRadarTtyReader(self.config.radar.rph_radar.tty_path)
|
||||
reader.open()
|
||||
self._reader = reader
|
||||
logger.info("Kamil ADC service opened")
|
||||
logger.info("RadioPhotonic radar (RPH) service opened")
|
||||
except Exception:
|
||||
logger.exception("Kamil ADC service failed to open; cleaning up")
|
||||
logger.exception("RadioPhotonic radar (RPH) service failed to open; cleaning up")
|
||||
self.close()
|
||||
raise
|
||||
|
||||
def close(self) -> None:
|
||||
"""Stop the TTY reader and the external collector process."""
|
||||
logger.debug("Closing Kamil ADC service")
|
||||
logger.debug("Closing RadioPhotonic radar (RPH) service")
|
||||
self._prepared_combo = None
|
||||
if self._reader is not None:
|
||||
with suppress(Exception):
|
||||
self._reader.close()
|
||||
self._reader = None
|
||||
self._stop_process()
|
||||
if self._output_thread is not None:
|
||||
self._output_thread.join(timeout=1.0)
|
||||
self._output_thread = None
|
||||
|
||||
def configure(self, sweep: RadarSweepModel) -> None:
|
||||
"""Build the sweep processor from the ``radar.kamil_adc`` calibration/band.
|
||||
"""Build the sweep processor from the ``radar.rph_radar`` calibration/band.
|
||||
|
||||
The generic ``sweep`` argument is accepted for interface parity with the
|
||||
other radar services but is not used: the Kamil ADC frequency axis comes
|
||||
other radar services but is not used: the RadioPhotonic radar (RPH) frequency axis comes
|
||||
from the reference-phase calibration, and the output grid from
|
||||
``radar.kamil_adc.band`` — never from the nominal sweep bounds.
|
||||
``radar.rph_radar.band`` — never from the nominal sweep bounds.
|
||||
"""
|
||||
self._processor = KamilAdcSweepProcessor(
|
||||
KamilAdcProcessingParams.from_kamil_model(self.config.radar.kamil_adc)
|
||||
)
|
||||
adc = self.config.radar.rph_radar
|
||||
self._processor = RphRadarSweepProcessor(adc)
|
||||
self._rejected_count = 0
|
||||
params = self._processor.params
|
||||
phase, band = adc.phase_calibration, adc.band
|
||||
logger.debug(
|
||||
"Kamil ADC configured: band %.6g-%.6g Hz, %d points; calibration "
|
||||
"RadioPhotonic radar (RPH) configured: band %.6g-%.6g Hz, %d points; calibration "
|
||||
"(%.6g rad -> %.6g Hz, %.6g rad -> %.6g Hz)",
|
||||
params.band_start_hz, params.band_stop_hz, params.band_points,
|
||||
params.phase0_rad, params.freq0_hz, params.phase1_rad, params.freq1_hz,
|
||||
band.start_hz, band.stop_hz, band.points,
|
||||
phase.phase0_rad, phase.freq0_hz, phase.phase1_rad, phase.freq1_hz,
|
||||
)
|
||||
|
||||
def read_device_limits(self) -> dict[str, float | int]:
|
||||
"""Kamil ADC has no runtime-readable sweep-limit API."""
|
||||
raise RuntimeError("Kamil ADC device limits are not available")
|
||||
"""RadioPhotonic radar (RPH) has no runtime-readable sweep-limit API."""
|
||||
raise RuntimeError("RadioPhotonic radar (RPH) device limits are not available")
|
||||
|
||||
def acquire(self, combo: tuple[int, int] | None = None) -> SweepResult:
|
||||
"""Return the next sweep that covers the band, as S21 on the fixed grid.
|
||||
@@ -153,65 +153,45 @@ class KamilAdcService:
|
||||
single newest sweep is returned regardless of combination.
|
||||
"""
|
||||
if self._processor is None:
|
||||
raise RuntimeError("Kamil ADC service is not configured")
|
||||
raise RuntimeError("RadioPhotonic radar (RPH) service is not configured")
|
||||
if self._reader is None:
|
||||
raise RuntimeError("Kamil ADC service is not open")
|
||||
raise RuntimeError("RadioPhotonic radar (RPH) service is not open")
|
||||
process = self._process
|
||||
if process is None or process.poll() is not None:
|
||||
return_code = None if process is None else process.poll()
|
||||
raise RuntimeError(f"Kamil ADC collector is not running (code={return_code})")
|
||||
raise RuntimeError(f"RadioPhotonic radar (RPH) collector is not running (code={return_code})")
|
||||
|
||||
grid = self._processor.grid_hz
|
||||
points = int(grid.size)
|
||||
deadline = time.monotonic() + self.config.radar.kamil_adc.sweep_timeout_s
|
||||
deadline = time.monotonic() + self.config.radar.rph_radar.sweep_timeout_s
|
||||
while True:
|
||||
remaining_s = deadline - time.monotonic()
|
||||
if remaining_s <= 0.0:
|
||||
raise TimeoutError(
|
||||
"Timed out waiting for a Kamil ADC sweep covering the configured band"
|
||||
"Timed out waiting for a RadioPhotonic radar (RPH) sweep covering the configured band"
|
||||
)
|
||||
if combo is None:
|
||||
raw = self._reader.read_sweep(timeout_s=remaining_s, process=process)
|
||||
else:
|
||||
raw = self._reader.read_sweep_for(combo, timeout_s=remaining_s, process=process)
|
||||
raw = self._reader.read_sweep(timeout_s=remaining_s, combo=combo, process=process)
|
||||
s21 = self._processor.process(raw.main, raw.reference)
|
||||
if s21 is not None:
|
||||
self.last_cycle_id = raw.cycle_id
|
||||
return SweepResult(
|
||||
x=grid.copy(),
|
||||
x=grid,
|
||||
traces={
|
||||
"s21": s21,
|
||||
},
|
||||
capture_start_ns=raw.capture_start_ns,
|
||||
capture_end_ns=raw.capture_end_ns,
|
||||
)
|
||||
self._log_rejected_sweep(raw)
|
||||
|
||||
def prepare_combo(self, combo: ComboKey) -> None:
|
||||
"""Discard buffered sweeps that predate or straddle the physical switch."""
|
||||
self.drain_after_switch()
|
||||
|
||||
def drain_after_switch(self, sweeps: int = _SWITCH_DRAIN_SWEEPS) -> None:
|
||||
"""Discard sweeps captured before / across a just-applied switch change.
|
||||
|
||||
The collector free-runs, so right after the RF switches move the reader
|
||||
still holds a sweep captured in the *previous* combination, and a sweep that
|
||||
straddles the transition may still be in flight. Without this, the next
|
||||
:meth:`acquire` would return that stale data and the capture would be
|
||||
attributed to the wrong combination (an off-by-one across the sequence).
|
||||
|
||||
Block until ``sweeps`` freshly-published sweeps have gone by, so the next
|
||||
:meth:`acquire` returns a sweep captured entirely in the new switch state.
|
||||
Used by the Python-driven calibration capture, where the collector does not
|
||||
tag sweeps; the switch-aware collector path handles this with combo tags
|
||||
instead. No-op when the service is not open.
|
||||
"""
|
||||
if self._reader is None:
|
||||
"""Start a fresh ADC session after switching; retain phase branch tracking."""
|
||||
if self._prepared_combo == combo:
|
||||
return
|
||||
target = self._reader.published_count + max(1, int(sweeps))
|
||||
deadline = time.monotonic() + self.config.radar.kamil_adc.sweep_timeout_s
|
||||
while self._reader.published_count < target:
|
||||
if time.monotonic() > deadline:
|
||||
raise TimeoutError("Timed out draining Kamil ADC sweeps after a switch change")
|
||||
raise_if_process_exited(self._process)
|
||||
time.sleep(0.005)
|
||||
self.close()
|
||||
if self.config.runtime.settling_ms < 1:
|
||||
time.sleep(.001) # The caller already waited any configured settling.
|
||||
self.open(stop_event=self._stop_event)
|
||||
self._prepared_combo = combo
|
||||
|
||||
def read_raw_sweep(self) -> RawSweep:
|
||||
"""Return the next raw (main, reference) sweep without any processing.
|
||||
@@ -221,9 +201,9 @@ class KamilAdcService:
|
||||
unprocessed reference samples. Raises if the service is not open.
|
||||
"""
|
||||
if self._reader is None:
|
||||
raise RuntimeError("Kamil ADC service is not open")
|
||||
raise RuntimeError("RadioPhotonic radar (RPH) service is not open")
|
||||
return self._reader.read_sweep(
|
||||
timeout_s=self.config.radar.kamil_adc.sweep_timeout_s,
|
||||
timeout_s=self.config.radar.rph_radar.sweep_timeout_s,
|
||||
process=self._process,
|
||||
)
|
||||
|
||||
@@ -234,34 +214,45 @@ class KamilAdcService:
|
||||
def _start_process(self) -> None:
|
||||
if self._process is not None and self._process.poll() is None:
|
||||
return
|
||||
logger.info("Starting Kamil ADC collector: %s", " ".join(self.command))
|
||||
logger.info("Starting RadioPhotonic radar (RPH) collector: %s", " ".join(self.command))
|
||||
self._process = subprocess.Popen(
|
||||
self.command,
|
||||
cwd=str(self._resolve_project_dir()),
|
||||
env=self._build_env(),
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
self._output_thread = threading.Thread(target=self._log_output, args=(self._process,),
|
||||
name="rph-radar-log", daemon=True)
|
||||
self._output_thread.start()
|
||||
|
||||
@staticmethod
|
||||
def _log_output(process: subprocess.Popen[bytes]) -> None:
|
||||
if process.stdout is not None:
|
||||
with process.stdout:
|
||||
for line in process.stdout:
|
||||
logger.info("Collector: %s", line.decode("utf-8", errors="replace").rstrip())
|
||||
|
||||
def _stop_process(self) -> None:
|
||||
process = self._process
|
||||
self._process = None
|
||||
if process is None or process.poll() is not None:
|
||||
self._process = None
|
||||
return
|
||||
logger.info("Stopping Kamil ADC collector (pid=%d)", process.pid)
|
||||
logger.info("Stopping RadioPhotonic radar (RPH) collector (pid=%d)", process.pid)
|
||||
# The collector's graceful X502 teardown can block, so give it only a brief
|
||||
# window to release the device cleanly, then SIGKILL the whole group hard.
|
||||
grace_s = min(self.config.radar.kamil_adc.stop_timeout_s, _STOP_KILL_GRACE_S)
|
||||
grace_s = min(self.config.radar.rph_radar.stop_timeout_s, _STOP_KILL_GRACE_S)
|
||||
with suppress(ProcessLookupError):
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
try:
|
||||
process.wait(timeout=grace_s)
|
||||
self._process = None
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
logger.warning("Kamil ADC collector (pid=%d) did not stop in %.1fs; sending SIGKILL", process.pid, grace_s)
|
||||
logger.warning("RadioPhotonic radar (RPH) collector (pid=%d) did not stop in %.1fs; sending SIGKILL", process.pid, grace_s)
|
||||
with suppress(ProcessLookupError):
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
# close() must never raise: a collector wedged in uninterruptible I/O
|
||||
@@ -269,6 +260,9 @@ class KamilAdcService:
|
||||
# window even after SIGKILL. Best-effort wait; the OS reaps it eventually.
|
||||
with suppress(subprocess.TimeoutExpired):
|
||||
process.wait(timeout=1.0)
|
||||
# Retain ownership in D-state so retries cannot launch a second collector.
|
||||
if process.poll() is not None:
|
||||
self._process = None
|
||||
|
||||
def _wait_for_tty(
|
||||
self,
|
||||
@@ -276,22 +270,22 @@ class KamilAdcService:
|
||||
*,
|
||||
stop_event: threading.Event | None = None,
|
||||
) -> None:
|
||||
adc = self.config.radar.kamil_adc
|
||||
adc = self.config.radar.rph_radar
|
||||
deadline = time.monotonic() + adc.startup_timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
if stop_event is not None and stop_event.is_set():
|
||||
raise RuntimeError("Kamil ADC startup aborted before TTY became available")
|
||||
raise RuntimeError("RadioPhotonic radar (RPH) startup aborted before TTY became available")
|
||||
raise_if_process_exited(self._process)
|
||||
identity = _tty_identity(adc.tty_path)
|
||||
if identity is not None and identity != previous_identity:
|
||||
return
|
||||
if stop_event is not None:
|
||||
if stop_event.wait(0.05):
|
||||
raise RuntimeError("Kamil ADC startup aborted before TTY became available")
|
||||
raise RuntimeError("RadioPhotonic radar (RPH) startup aborted before TTY became available")
|
||||
else:
|
||||
time.sleep(0.05)
|
||||
raise TimeoutError(
|
||||
f"Timed out waiting for Kamil ADC TTY `{adc.tty_path}` to be created by the collector"
|
||||
f"Timed out waiting for RadioPhotonic radar (RPH) TTY `{adc.tty_path}` to be created by the collector"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -299,10 +293,10 @@ class KamilAdcService:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_executable(self) -> Path:
|
||||
return self._resolve_path(self.config.radar.kamil_adc.executable_path)
|
||||
return self._resolve_path(self.config.radar.rph_radar.executable_path)
|
||||
|
||||
def _resolve_project_dir(self) -> Path:
|
||||
project_dir = self.config.radar.kamil_adc.project_dir
|
||||
project_dir = self.config.radar.rph_radar.project_dir
|
||||
return self._resolve_path(project_dir) if project_dir else _REPO_ROOT
|
||||
|
||||
@staticmethod
|
||||
@@ -318,7 +312,7 @@ class KamilAdcService:
|
||||
``LD_LIBRARY_PATH`` itself, the default L-Card library directory is
|
||||
prepended so the collector's dlopen of libx502api/libe502api succeeds.
|
||||
"""
|
||||
adc = self.config.radar.kamil_adc
|
||||
adc = self.config.radar.rph_radar
|
||||
env = os.environ.copy()
|
||||
for key, value in adc.env.items():
|
||||
env[key] = os.path.expandvars(os.path.expanduser(value))
|
||||
@@ -333,33 +327,29 @@ class KamilAdcService:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _validate_config(self) -> None:
|
||||
if not self.config.is_kamil_adc:
|
||||
raise RuntimeError("KamilAdcService requires radar.model='kamil_adc'")
|
||||
if not self.config.is_rph_radar:
|
||||
raise RuntimeError("RphRadarService requires radar.model='rph_radar'")
|
||||
if self.config.radar.driver_mode != "native":
|
||||
raise RuntimeError("Kamil ADC requires radar.driver_mode='native'")
|
||||
raise RuntimeError("RadioPhotonic radar (RPH) requires radar.driver_mode='native'")
|
||||
|
||||
adc = self.config.radar.kamil_adc
|
||||
adc = self.config.radar.rph_radar
|
||||
validate_rph_radar_model(adc)
|
||||
if not adc.executable_path:
|
||||
raise ValueError("radar.kamil_adc.executable_path is required")
|
||||
raise ValueError("radar.rph_radar.executable_path is required")
|
||||
if not adc.tty_path:
|
||||
raise ValueError("radar.kamil_adc.tty_path is required")
|
||||
raise ValueError("radar.rph_radar.tty_path is required")
|
||||
if any(arg.startswith("tty:") for arg in adc.args):
|
||||
raise ValueError("radar.kamil_adc.args must not contain tty:<path>; use tty_path instead")
|
||||
for name in ("startup_timeout_s", "sweep_timeout_s", "stop_timeout_s"):
|
||||
if getattr(adc, name) <= 0.0:
|
||||
raise ValueError(f"radar.kamil_adc.{name} must be > 0")
|
||||
raise ValueError("radar.rph_radar.args must not contain tty:<path>; use tty_path instead")
|
||||
|
||||
project_dir = self._resolve_project_dir()
|
||||
if not project_dir.is_dir():
|
||||
raise RuntimeError(f"radar.kamil_adc.project_dir is not a directory: {project_dir}")
|
||||
raise RuntimeError(f"radar.rph_radar.project_dir is not a directory: {project_dir}")
|
||||
executable_path = self._resolve_executable()
|
||||
if not executable_path.is_file():
|
||||
raise RuntimeError(f"radar.kamil_adc.executable_path is not a file: {executable_path}")
|
||||
raise RuntimeError(f"radar.rph_radar.executable_path is not a file: {executable_path}")
|
||||
if not os.access(executable_path, os.X_OK):
|
||||
raise RuntimeError(f"radar.kamil_adc.executable_path is not executable: {executable_path}")
|
||||
raise RuntimeError(f"radar.rph_radar.executable_path is not executable: {executable_path}")
|
||||
|
||||
# Surface a malformed calibration/band at open time, not mid-acquisition.
|
||||
KamilAdcProcessingParams.from_kamil_model(adc)
|
||||
|
||||
def _log_rejected_sweep(self, raw) -> None:
|
||||
"""Log a band-coverage rejection (throttled) with the measured span."""
|
||||
@@ -367,16 +357,16 @@ class KamilAdcService:
|
||||
if self._rejected_count != 1 and self._rejected_count % _REJECT_LOG_EVERY != 0:
|
||||
return
|
||||
assert self._processor is not None
|
||||
params = self._processor.params
|
||||
band = self.config.radar.rph_radar.band
|
||||
try:
|
||||
freqs = self._processor.reference_frequency_axis(raw.reference)
|
||||
covered = f"[{float(np.min(freqs)):.6g}, {float(np.max(freqs)):.6g}]"
|
||||
except Exception: # noqa: BLE001 — diagnostics must never raise
|
||||
covered = "<unavailable>"
|
||||
logger.warning(
|
||||
"Kamil ADC sweep rejected (count=%d): covered %s Hz does not span band "
|
||||
"RadioPhotonic radar (RPH) sweep rejected (count=%d): covered %s Hz does not span band "
|
||||
"[%.6g, %.6g] Hz (usable points=%d). Check phase_calibration/band.",
|
||||
self._rejected_count, covered, params.band_start_hz, params.band_stop_hz, raw.size,
|
||||
self._rejected_count, covered, band.start_hz, band.stop_hz, raw.size,
|
||||
)
|
||||
|
||||
|
||||
+22
-59
@@ -1,8 +1,8 @@
|
||||
"""Background TTY reader publishing the latest completed Kamil ADC sweep.
|
||||
"""Background TTY reader publishing the latest completed RadioPhotonic radar (RPH) sweep.
|
||||
|
||||
The collector emits sweeps continuously, faster than callers invoke
|
||||
:meth:`KamilAdcService.acquire`. A daemon thread drains the device end of the
|
||||
TTY non-stop, feeds the bytes to a :class:`KamilAdcStreamParser`, and stores the
|
||||
:meth:`RphRadarService.acquire`. A daemon thread drains the device end of the
|
||||
TTY non-stop, feeds the bytes to a :class:`RphRadarStreamParser`, and stores the
|
||||
most recent :class:`RawSweep` in a single-slot mailbox. :meth:`read_sweep`
|
||||
returns the freshest sweep; if a newer one arrives before the consumer reads, it
|
||||
overwrites the previous unread value — by design, since consumers always want the
|
||||
@@ -21,7 +21,7 @@ import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
from python_app.hardware_full.kamil_adc.protocol import KamilAdcStreamParser, RawSweep
|
||||
from python_app.hardware_full.rph_radar.protocol import RphRadarStreamParser, RawSweep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,11 +39,11 @@ def raise_if_process_exited(process: subprocess.Popen[bytes] | None) -> None:
|
||||
return
|
||||
return_code = process.poll()
|
||||
if return_code is not None:
|
||||
raise RuntimeError(f"Kamil ADC collector exited with code {return_code}")
|
||||
raise RuntimeError(f"RadioPhotonic radar (RPH) collector exited with code {return_code}")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class KamilAdcTtyReader:
|
||||
class RphRadarTtyReader:
|
||||
"""Daemon-thread TTY reader publishing the latest completed sweep."""
|
||||
|
||||
tty_path: str
|
||||
@@ -54,7 +54,7 @@ class KamilAdcTtyReader:
|
||||
_latest_sweep: RawSweep | None = field(init=False, default=None, repr=False)
|
||||
# Latest clean sweep per switch combination, for the switch-aware collector.
|
||||
# read_sweep() ignores this and serves the single newest sweep (calibration /
|
||||
# non-switch mode); read_sweep_for() serves a specific combination.
|
||||
# non-switch mode); an optional combo selects its own slot.
|
||||
_combo_slots: dict[tuple[int, int], RawSweep] = field(init=False, default_factory=dict, repr=False)
|
||||
_reader_error: Exception | None = field(init=False, default=None, repr=False)
|
||||
_published_count: int = field(init=False, default=0, repr=False)
|
||||
@@ -71,7 +71,7 @@ class KamilAdcTtyReader:
|
||||
self._published_count = 0
|
||||
self._thread = threading.Thread(
|
||||
target=self._reader_loop,
|
||||
name=f"kamil-adc-tty-reader[{self.tty_path}]",
|
||||
name=f"rph-radar-tty-reader[{self.tty_path}]",
|
||||
daemon=True,
|
||||
)
|
||||
try:
|
||||
@@ -81,18 +81,18 @@ class KamilAdcTtyReader:
|
||||
fd, self._fd = self._fd, None
|
||||
os.close(fd)
|
||||
raise
|
||||
logger.info("Kamil ADC TTY reader started on %s", self.tty_path)
|
||||
logger.info("RadioPhotonic radar (RPH) TTY reader started on %s", self.tty_path)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Stop the reader thread and close the TTY descriptor."""
|
||||
logger.debug("Stopping Kamil ADC TTY reader on %s", self.tty_path)
|
||||
logger.debug("Stopping RadioPhotonic radar (RPH) TTY reader on %s", self.tty_path)
|
||||
self._stop_event.set()
|
||||
with self._mailbox_cv:
|
||||
self._mailbox_cv.notify_all()
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=1.0)
|
||||
if self._thread.is_alive():
|
||||
logger.warning("Kamil ADC reader thread did not stop within 1.0s")
|
||||
logger.warning("RadioPhotonic radar (RPH) reader thread did not stop within 1.0s")
|
||||
self._thread = None
|
||||
if self._fd is not None:
|
||||
try:
|
||||
@@ -113,6 +113,7 @@ class KamilAdcTtyReader:
|
||||
self,
|
||||
*,
|
||||
timeout_s: float,
|
||||
combo: tuple[int, int] | None = None,
|
||||
process: subprocess.Popen[bytes] | None = None,
|
||||
) -> RawSweep:
|
||||
"""Wait for and return the next published sweep.
|
||||
@@ -122,52 +123,19 @@ class KamilAdcTtyReader:
|
||||
error caught by the reader thread.
|
||||
"""
|
||||
if self._thread is None:
|
||||
raise RuntimeError("Kamil ADC TTY reader is not open")
|
||||
raise RuntimeError("RadioPhotonic radar (RPH) TTY reader is not open")
|
||||
deadline = time.monotonic() + float(timeout_s)
|
||||
with self._mailbox_cv:
|
||||
while True:
|
||||
if self._stop_event.is_set():
|
||||
raise RuntimeError("Kamil ADC TTY reader is closed")
|
||||
raise RuntimeError("RadioPhotonic radar (RPH) TTY reader is closed")
|
||||
# Deliver a pending sweep first: if the reader both published a
|
||||
# sweep and then died, the consumer still sees the good data and
|
||||
# only meets the error on the next call.
|
||||
if self._latest_sweep is not None:
|
||||
sweep = self._latest_sweep
|
||||
self._latest_sweep = None
|
||||
return sweep
|
||||
if self._reader_error is not None:
|
||||
raise self._reader_error
|
||||
raise_if_process_exited(process)
|
||||
remaining_s = deadline - time.monotonic()
|
||||
if remaining_s <= 0.0:
|
||||
raise TimeoutError(
|
||||
f"Timed out waiting for Kamil ADC sweep after {float(timeout_s):.3f}s"
|
||||
)
|
||||
self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s))
|
||||
|
||||
def read_sweep_for(
|
||||
self,
|
||||
combo: tuple[int, int],
|
||||
*,
|
||||
timeout_s: float,
|
||||
process: subprocess.Popen[bytes] | None = None,
|
||||
) -> RawSweep:
|
||||
"""Wait for and return the latest clean sweep for ``combo``.
|
||||
|
||||
Used in switch-aware mode, where the collector drives the switches and tags
|
||||
each sweep with its combination. Only clean sweeps are delivered (the reader
|
||||
thread drops the dirty ones); the slot is consumed on read so each caller
|
||||
gets a fresh capture. Raises like :meth:`read_sweep`.
|
||||
"""
|
||||
if self._thread is None:
|
||||
raise RuntimeError("Kamil ADC TTY reader is not open")
|
||||
deadline = time.monotonic() + float(timeout_s)
|
||||
with self._mailbox_cv:
|
||||
while True:
|
||||
if self._stop_event.is_set():
|
||||
raise RuntimeError("Kamil ADC TTY reader is closed")
|
||||
sweep = self._combo_slots.pop(combo, None)
|
||||
sweep = self._latest_sweep if combo is None else self._combo_slots.pop(combo, None)
|
||||
if sweep is not None:
|
||||
if combo is None:
|
||||
self._latest_sweep = None
|
||||
return sweep
|
||||
if self._reader_error is not None:
|
||||
raise self._reader_error
|
||||
@@ -175,18 +143,13 @@ class KamilAdcTtyReader:
|
||||
remaining_s = deadline - time.monotonic()
|
||||
if remaining_s <= 0.0:
|
||||
raise TimeoutError(
|
||||
f"Timed out waiting for Kamil ADC sweep for combo {combo} "
|
||||
f"after {float(timeout_s):.3f}s"
|
||||
f"Timed out waiting for RadioPhotonic radar (RPH) sweep after {float(timeout_s):.3f}s"
|
||||
)
|
||||
self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reader-thread internals
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _reader_loop(self) -> None:
|
||||
"""Drain the TTY, parse sweeps, and publish each completed one until stop."""
|
||||
parser = KamilAdcStreamParser()
|
||||
parser = RphRadarStreamParser()
|
||||
try:
|
||||
while not self._stop_event.is_set():
|
||||
chunk = self._read_available()
|
||||
@@ -195,7 +158,7 @@ class KamilAdcTtyReader:
|
||||
for sweep in parser.feed(chunk):
|
||||
self._publish_sweep(sweep)
|
||||
except Exception as exc: # noqa: BLE001 — surfaced to the consumer via read_sweep
|
||||
logger.exception("Kamil ADC reader thread failed on %s", self.tty_path)
|
||||
logger.exception("RadioPhotonic radar (RPH) reader thread failed on %s", self.tty_path)
|
||||
self._publish_error(exc)
|
||||
|
||||
def _read_available(self) -> bytes:
|
||||
@@ -220,9 +183,9 @@ class KamilAdcTtyReader:
|
||||
except OSError as exc:
|
||||
if exc.errno in {errno.EAGAIN, errno.EWOULDBLOCK}:
|
||||
return b""
|
||||
raise RuntimeError(f"Failed to read Kamil ADC TTY `{self.tty_path}`: {exc}") from exc
|
||||
raise RuntimeError(f"Failed to read RadioPhotonic radar (RPH) TTY `{self.tty_path}`: {exc}") from exc
|
||||
if not chunk:
|
||||
raise RuntimeError(f"Kamil ADC TTY `{self.tty_path}` closed while reading")
|
||||
raise RuntimeError(f"RadioPhotonic radar (RPH) TTY `{self.tty_path}` closed while reading")
|
||||
return chunk
|
||||
|
||||
def _publish_sweep(self, sweep: RawSweep) -> None:
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Protocol
|
||||
|
||||
from python_app.hardware_full.kamil_adc import KamilAdcService
|
||||
from python_app.hardware_full.rph_radar.service import RphRadarService
|
||||
from python_app.hardware_full.librevna_driver.models import SweepResult
|
||||
from python_app.hardware_full.librevna_service import LibreVnaService
|
||||
from python_app.models.dataset_model import ComboKey
|
||||
@@ -55,9 +55,9 @@ def create_single_radar_service(config: RunConfigModel) -> SingleRadarService:
|
||||
return PointTargetMockService(config)
|
||||
return LibreVnaService(serial=config.radar.serial or None)
|
||||
|
||||
if model == RunConfigModel.KAMIL_ADC_MODEL:
|
||||
if model == RunConfigModel.RPH_RADAR_MODEL:
|
||||
if config.radar.driver_mode != "native":
|
||||
raise RuntimeError("Kamil ADC requires radar.driver_mode='native'")
|
||||
return KamilAdcService(config)
|
||||
raise RuntimeError("RadioPhotonic radar (RPH) requires radar.driver_mode='native'")
|
||||
return RphRadarService(config)
|
||||
|
||||
raise RuntimeError(f"Unsupported single-radar model: {model}")
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -105,8 +106,10 @@ def run_config_from_dict(payload: object) -> RunConfigModel:
|
||||
run_payload.get("locator_server", run_payload.get("locator")),
|
||||
"run.locator_server",
|
||||
)
|
||||
if radar_payload.get("model") == "kamil_adc":
|
||||
raise ValueError("radar.model kamil_adc was renamed: use rph_radar and move radar.kamil_adc to radar.rph_radar")
|
||||
multi_device_payload = _as_dict(radar_payload.get("multi_device"), "radar.multi_device")
|
||||
kamil_adc_payload = _as_dict(radar_payload.get("kamil_adc"), "radar.kamil_adc")
|
||||
rph_radar_payload = _as_dict(radar_payload.get("rph_radar"), "radar.rph_radar")
|
||||
laser_control_payload = _as_dict(radar_payload.get("laser_control"), "radar.laser_control")
|
||||
|
||||
model.radar.model = _read_str(radar_payload, "model", model.radar.model)
|
||||
@@ -162,36 +165,36 @@ def run_config_from_dict(payload: object) -> RunConfigModel:
|
||||
"stimulus_switch_positions",
|
||||
model.radar.multi_device.stimulus_switch_positions,
|
||||
)
|
||||
model.radar.kamil_adc.project_dir = _read_str(
|
||||
kamil_adc_payload, "project_dir", model.radar.kamil_adc.project_dir
|
||||
model.radar.rph_radar.project_dir = _read_str(
|
||||
rph_radar_payload, "project_dir", model.radar.rph_radar.project_dir
|
||||
)
|
||||
model.radar.kamil_adc.executable_path = _read_str(
|
||||
kamil_adc_payload, "executable_path", model.radar.kamil_adc.executable_path
|
||||
model.radar.rph_radar.executable_path = _read_str(
|
||||
rph_radar_payload, "executable_path", model.radar.rph_radar.executable_path
|
||||
)
|
||||
model.radar.kamil_adc.tty_path = _read_str(
|
||||
kamil_adc_payload, "tty_path", model.radar.kamil_adc.tty_path
|
||||
model.radar.rph_radar.tty_path = _read_str(
|
||||
rph_radar_payload, "tty_path", model.radar.rph_radar.tty_path
|
||||
)
|
||||
model.radar.kamil_adc.args = _load_string_list(kamil_adc_payload, "args", "radar.kamil_adc")
|
||||
model.radar.kamil_adc.env = _load_string_dict(kamil_adc_payload, "env", "radar.kamil_adc")
|
||||
model.radar.kamil_adc.startup_timeout_s = _read_float(
|
||||
kamil_adc_payload, "startup_timeout_s", model.radar.kamil_adc.startup_timeout_s
|
||||
model.radar.rph_radar.args = _load_string_list(rph_radar_payload, "args", "radar.rph_radar")
|
||||
model.radar.rph_radar.env = _load_string_dict(rph_radar_payload, "env", "radar.rph_radar")
|
||||
model.radar.rph_radar.startup_timeout_s = _read_float(
|
||||
rph_radar_payload, "startup_timeout_s", model.radar.rph_radar.startup_timeout_s
|
||||
)
|
||||
model.radar.kamil_adc.sweep_timeout_s = _read_float(
|
||||
kamil_adc_payload, "sweep_timeout_s", model.radar.kamil_adc.sweep_timeout_s
|
||||
model.radar.rph_radar.sweep_timeout_s = _read_float(
|
||||
rph_radar_payload, "sweep_timeout_s", model.radar.rph_radar.sweep_timeout_s
|
||||
)
|
||||
model.radar.kamil_adc.stop_timeout_s = _read_float(
|
||||
kamil_adc_payload, "stop_timeout_s", model.radar.kamil_adc.stop_timeout_s
|
||||
model.radar.rph_radar.stop_timeout_s = _read_float(
|
||||
rph_radar_payload, "stop_timeout_s", model.radar.rph_radar.stop_timeout_s
|
||||
)
|
||||
phase_calibration_payload = _as_dict(
|
||||
kamil_adc_payload.get("phase_calibration"), "radar.kamil_adc.phase_calibration"
|
||||
rph_radar_payload.get("phase_calibration"), "radar.rph_radar.phase_calibration"
|
||||
)
|
||||
calibration = model.radar.kamil_adc.phase_calibration
|
||||
calibration = model.radar.rph_radar.phase_calibration
|
||||
calibration.phase0_rad = _read_float(phase_calibration_payload, "phase0_rad", calibration.phase0_rad)
|
||||
calibration.freq0_hz = _read_float(phase_calibration_payload, "freq0_hz", calibration.freq0_hz)
|
||||
calibration.phase1_rad = _read_float(phase_calibration_payload, "phase1_rad", calibration.phase1_rad)
|
||||
calibration.freq1_hz = _read_float(phase_calibration_payload, "freq1_hz", calibration.freq1_hz)
|
||||
band_payload = _as_dict(kamil_adc_payload.get("band"), "radar.kamil_adc.band")
|
||||
band = model.radar.kamil_adc.band
|
||||
band_payload = _as_dict(rph_radar_payload.get("band"), "radar.rph_radar.band")
|
||||
band = model.radar.rph_radar.band
|
||||
band.start_hz = _read_float(band_payload, "start_hz", band.start_hz)
|
||||
band.stop_hz = _read_float(band_payload, "stop_hz", band.stop_hz)
|
||||
band.points = _read_int(band_payload, "points", band.points)
|
||||
@@ -274,11 +277,6 @@ def run_config_from_dict(payload: object) -> RunConfigModel:
|
||||
model.radar.laser_control.variation.delay_time = _read_int(
|
||||
laser_variation_payload, "delay_time", model.radar.laser_control.variation.delay_time
|
||||
)
|
||||
model.radar.laser_control.variation.temp_tolerance_c = _read_float(
|
||||
laser_variation_payload,
|
||||
"temp_tolerance_c",
|
||||
model.radar.laser_control.variation.temp_tolerance_c,
|
||||
)
|
||||
|
||||
load_switch_payload(port1_payload, model.output_switch)
|
||||
load_switch_payload(port2_payload, model.input_switch)
|
||||
@@ -410,200 +408,22 @@ def run_config_from_dict(payload: object) -> RunConfigModel:
|
||||
|
||||
|
||||
def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
|
||||
"""Encode :class:`RunConfigModel` to C++ pipeline-compatible JSON structure."""
|
||||
"""Encode the model, spelling only fields whose wire names differ."""
|
||||
model.ensure_combos()
|
||||
sweep_payload = {
|
||||
"start_hz": model.radar.sweep.start_hz,
|
||||
"stop_hz": model.radar.sweep.stop_hz,
|
||||
"if_bandwidth_hz": model.radar.sweep.if_bandwidth_hz,
|
||||
"stimulus_power_dbm": model.radar.sweep.power_dbm,
|
||||
"dwell_us": model.radar.sweep.dwell_us,
|
||||
}
|
||||
if not model.is_kamil_adc:
|
||||
sweep_payload["points"] = model.radar.sweep.points
|
||||
|
||||
radar = asdict(model.radar)
|
||||
sweep = radar["sweep"]
|
||||
sweep["stimulus_power_dbm"] = sweep.pop("power_dbm")
|
||||
if model.is_rph_radar:
|
||||
sweep.pop("points")
|
||||
preprocess = asdict(model.preprocess)
|
||||
preprocess["notch"]["bands_hz"] = [list(band) for band in model.preprocess.notch.bands_hz]
|
||||
return {
|
||||
"radar": {
|
||||
"model": model.radar.model,
|
||||
"serial": model.radar.serial,
|
||||
"driver_mode": model.radar.driver_mode,
|
||||
"multi_device": {
|
||||
"slave_serials": list(model.radar.multi_device.slave_serials),
|
||||
"force_external_reference": model.radar.multi_device.force_external_reference,
|
||||
"recovery_attempts": model.radar.multi_device.recovery_attempts,
|
||||
"output_switch_positions": model.radar.multi_device.output_switch_positions,
|
||||
"input_switch_positions": model.radar.multi_device.input_switch_positions,
|
||||
"stimulus_switch_positions": model.radar.multi_device.stimulus_switch_positions
|
||||
},
|
||||
"kamil_adc": {
|
||||
"project_dir": model.radar.kamil_adc.project_dir,
|
||||
"executable_path": model.radar.kamil_adc.executable_path,
|
||||
"tty_path": model.radar.kamil_adc.tty_path,
|
||||
"args": list(model.radar.kamil_adc.args),
|
||||
"env": dict(model.radar.kamil_adc.env),
|
||||
"startup_timeout_s": model.radar.kamil_adc.startup_timeout_s,
|
||||
"sweep_timeout_s": model.radar.kamil_adc.sweep_timeout_s,
|
||||
"stop_timeout_s": model.radar.kamil_adc.stop_timeout_s,
|
||||
"phase_calibration": {
|
||||
"phase0_rad": model.radar.kamil_adc.phase_calibration.phase0_rad,
|
||||
"freq0_hz": model.radar.kamil_adc.phase_calibration.freq0_hz,
|
||||
"phase1_rad": model.radar.kamil_adc.phase_calibration.phase1_rad,
|
||||
"freq1_hz": model.radar.kamil_adc.phase_calibration.freq1_hz,
|
||||
},
|
||||
"band": {
|
||||
"start_hz": model.radar.kamil_adc.band.start_hz,
|
||||
"stop_hz": model.radar.kamil_adc.band.stop_hz,
|
||||
"points": model.radar.kamil_adc.band.points,
|
||||
},
|
||||
},
|
||||
"laser_control": {
|
||||
"enabled": model.radar.laser_control.enabled,
|
||||
"port": model.radar.laser_control.port,
|
||||
"mode": model.radar.laser_control.mode,
|
||||
"pi_coeff1_p": model.radar.laser_control.pi_coeff1_p,
|
||||
"pi_coeff1_i": model.radar.laser_control.pi_coeff1_i,
|
||||
"pi_coeff2_p": model.radar.laser_control.pi_coeff2_p,
|
||||
"pi_coeff2_i": model.radar.laser_control.pi_coeff2_i,
|
||||
"manual": {
|
||||
"temp1": model.radar.laser_control.manual.temp1,
|
||||
"temp2": model.radar.laser_control.manual.temp2,
|
||||
"current1": model.radar.laser_control.manual.current1,
|
||||
"current2": model.radar.laser_control.manual.current2,
|
||||
},
|
||||
"variation": {
|
||||
"variation_type": model.radar.laser_control.variation.variation_type,
|
||||
"static_temp1": model.radar.laser_control.variation.static_temp1,
|
||||
"static_temp2": model.radar.laser_control.variation.static_temp2,
|
||||
"static_current1": model.radar.laser_control.variation.static_current1,
|
||||
"static_current2": model.radar.laser_control.variation.static_current2,
|
||||
"min_value": model.radar.laser_control.variation.min_value,
|
||||
"max_value": model.radar.laser_control.variation.max_value,
|
||||
"step": model.radar.laser_control.variation.step,
|
||||
"time_step": model.radar.laser_control.variation.time_step,
|
||||
"delay_time": model.radar.laser_control.variation.delay_time,
|
||||
"temp_tolerance_c": model.radar.laser_control.variation.temp_tolerance_c,
|
||||
},
|
||||
},
|
||||
"sweep": sweep_payload,
|
||||
},
|
||||
"switches": {
|
||||
"port1": {
|
||||
"name": model.output_switch.name,
|
||||
"driver_mode": model.output_switch.driver_mode,
|
||||
"radar_port": model.output_switch.radar_port,
|
||||
"positions": model.output_switch.positions,
|
||||
"default_position": model.output_switch.default_position,
|
||||
"gpio_chip": model.output_switch.gpio_chip,
|
||||
"pin_a": model.output_switch.pin_a,
|
||||
"pin_b": model.output_switch.pin_b,
|
||||
},
|
||||
"port2": {
|
||||
"name": model.input_switch.name,
|
||||
"driver_mode": model.input_switch.driver_mode,
|
||||
"radar_port": model.input_switch.radar_port,
|
||||
"positions": model.input_switch.positions,
|
||||
"default_position": model.input_switch.default_position,
|
||||
"gpio_chip": model.input_switch.gpio_chip,
|
||||
"pin_a": model.input_switch.pin_a,
|
||||
"pin_b": model.input_switch.pin_b,
|
||||
},
|
||||
},
|
||||
"control_button": {
|
||||
"enabled": model.control_button.enabled,
|
||||
"gpio_chip": model.control_button.gpio_chip,
|
||||
"pin": model.control_button.pin,
|
||||
"active_low": model.control_button.active_low,
|
||||
"bias": model.control_button.bias,
|
||||
"debounce_ms": model.control_button.debounce_ms,
|
||||
"action": model.control_button.action,
|
||||
},
|
||||
"logging": {
|
||||
"level": model.logging.level,
|
||||
},
|
||||
"run": {
|
||||
"settling_ms": model.runtime.settling_ms,
|
||||
"idle_sleep_ms": model.runtime.idle_sleep_ms,
|
||||
"continuous": model.runtime.continuous,
|
||||
"processing_live_config_path": model.runtime.processing_live_config_path,
|
||||
"locator_server": {
|
||||
"device_id": model.runtime.locator_server.device_id,
|
||||
"protocol_version": model.runtime.locator_server.protocol_version,
|
||||
"host": model.runtime.locator_server.host,
|
||||
"port": model.runtime.locator_server.port,
|
||||
"max_payload_bytes": model.runtime.locator_server.max_payload_bytes,
|
||||
"client_queue_size": model.runtime.locator_server.client_queue_size,
|
||||
"logger_name": model.runtime.locator_server.logger_name,
|
||||
},
|
||||
"combos": [{"input": combo.input, "output": combo.output} for combo in model.combos],
|
||||
},
|
||||
"preprocess": {
|
||||
"s21": {
|
||||
"calibration": {
|
||||
"set_name": model.preprocess.s21.calibration.set_name,
|
||||
"bundle_path": model.preprocess.s21.calibration.bundle_path,
|
||||
},
|
||||
"reference": {
|
||||
"set_name": model.preprocess.s21.reference.set_name,
|
||||
"bundle_path": model.preprocess.s21.reference.bundle_path,
|
||||
},
|
||||
"object_calibration": {
|
||||
"set_name": model.preprocess.s21.object_calibration.set_name,
|
||||
"bundle_path": model.preprocess.s21.object_calibration.bundle_path,
|
||||
},
|
||||
},
|
||||
"notch": {
|
||||
"enabled": model.preprocess.notch.enabled,
|
||||
"bands_hz": [[low_hz, high_hz] for low_hz, high_hz in model.preprocess.notch.bands_hz],
|
||||
"taper_width_hz": model.preprocess.notch.taper_width_hz,
|
||||
"taper_type": model.preprocess.notch.taper_type,
|
||||
},
|
||||
},
|
||||
"gpr": {
|
||||
"relative_permittivity": model.gpr.relative_permittivity,
|
||||
"tx_geometry": [
|
||||
{
|
||||
"output_pos": entry.output_pos,
|
||||
"x_m": entry.x_m,
|
||||
"y_m": entry.y_m,
|
||||
"z_m": entry.z_m,
|
||||
}
|
||||
for entry in model.gpr.tx_geometry
|
||||
],
|
||||
"rx_geometry": [
|
||||
{
|
||||
"input_pos": entry.input_pos,
|
||||
"x_m": entry.x_m,
|
||||
"y_m": entry.y_m,
|
||||
"z_m": entry.z_m,
|
||||
}
|
||||
for entry in model.gpr.rx_geometry
|
||||
],
|
||||
},
|
||||
"rings": {
|
||||
"raw": {
|
||||
"name": model.rings.raw.name,
|
||||
"capacity": model.rings.raw.capacity,
|
||||
"slot_size_bytes": model.rings.raw.slot_size_bytes,
|
||||
},
|
||||
"raw_tap": {
|
||||
"name": model.rings.raw_tap.name,
|
||||
"capacity": model.rings.raw_tap.capacity,
|
||||
"slot_size_bytes": model.rings.raw_tap.slot_size_bytes,
|
||||
},
|
||||
"preprocessed": {
|
||||
"name": model.rings.preprocessed.name,
|
||||
"capacity": model.rings.preprocessed.capacity,
|
||||
"slot_size_bytes": model.rings.preprocessed.slot_size_bytes,
|
||||
},
|
||||
"preprocessed_tap": {
|
||||
"name": model.rings.preprocessed_tap.name,
|
||||
"capacity": model.rings.preprocessed_tap.capacity,
|
||||
"slot_size_bytes": model.rings.preprocessed_tap.slot_size_bytes,
|
||||
},
|
||||
"results": {
|
||||
"name": model.rings.results.name,
|
||||
"capacity": model.rings.results.capacity,
|
||||
"slot_size_bytes": model.rings.results.slot_size_bytes,
|
||||
},
|
||||
},
|
||||
"radar": radar,
|
||||
"switches": {"port1": asdict(model.output_switch), "port2": asdict(model.input_switch)},
|
||||
"control_button": asdict(model.control_button),
|
||||
"logging": asdict(model.logging),
|
||||
"run": {**asdict(model.runtime), "combos": [asdict(combo) for combo in model.combos]},
|
||||
"preprocess": preprocess,
|
||||
"gpr": asdict(model.gpr),
|
||||
"rings": asdict(model.rings),
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import asdict, dataclass, field
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
@@ -45,7 +45,7 @@ class RadarMultiDeviceModel:
|
||||
"""Multi-device LibreVNA topology settings."""
|
||||
|
||||
slave_serials: list[str] = field(default_factory=list)
|
||||
force_external_reference: bool = True
|
||||
force_external_reference: bool = False
|
||||
recovery_attempts: int = 3
|
||||
output_switch_positions: int = 1 # 1 = свитча нет
|
||||
input_switch_positions: int = 1 # 1 = свитча нет
|
||||
@@ -56,7 +56,7 @@ class RadarMultiDeviceModel:
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class KamilAdcPhaseCalibrationModel:
|
||||
class RphRadarPhaseCalibrationModel:
|
||||
"""Affine law mapping the reference signal's unwrapped phase to frequency.
|
||||
|
||||
Two fixed anchor points ``(phase0_rad, freq0_hz)`` and ``(phase1_rad, freq1_hz)``
|
||||
@@ -73,7 +73,7 @@ class KamilAdcPhaseCalibrationModel:
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class KamilAdcBandModel:
|
||||
class RphRadarBandModel:
|
||||
"""Fixed frequency window every sweep is cropped to and resampled onto.
|
||||
|
||||
Each sweep is resampled onto ``linspace(start_hz, stop_hz, points)`` so all
|
||||
@@ -88,8 +88,8 @@ class KamilAdcBandModel:
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class KamilAdcModel:
|
||||
"""External Kamil ADC acquisition process settings."""
|
||||
class RphRadarModel:
|
||||
"""External RadioPhotonic radar (RPH) acquisition process settings."""
|
||||
|
||||
project_dir: str = ""
|
||||
executable_path: str = ""
|
||||
@@ -99,10 +99,10 @@ class KamilAdcModel:
|
||||
startup_timeout_s: float = 5.0
|
||||
sweep_timeout_s: float = 5.0
|
||||
stop_timeout_s: float = 2.0
|
||||
phase_calibration: KamilAdcPhaseCalibrationModel = field(
|
||||
default_factory=KamilAdcPhaseCalibrationModel
|
||||
phase_calibration: RphRadarPhaseCalibrationModel = field(
|
||||
default_factory=RphRadarPhaseCalibrationModel
|
||||
)
|
||||
band: KamilAdcBandModel = field(default_factory=KamilAdcBandModel)
|
||||
band: RphRadarBandModel = field(default_factory=RphRadarBandModel)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -129,14 +129,11 @@ class LaserVariationModeModel:
|
||||
step: float = 0.1
|
||||
time_step: int = 20
|
||||
delay_time: int = 3
|
||||
# Max allowed |measured - target| laser temperature before the temperature
|
||||
# checker warns, °C. Applied independently to both lasers (temp1/temp2).
|
||||
temp_tolerance_c: float = 0.03
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LaserControlModel:
|
||||
"""Laser-control board settings applied before Kamil ADC acquisition."""
|
||||
"""Laser-control board settings applied before RadioPhotonic radar (RPH) acquisition."""
|
||||
|
||||
enabled: bool = False
|
||||
port: str = ""
|
||||
@@ -158,7 +155,7 @@ class RadarModel:
|
||||
driver_mode: str = "mock"
|
||||
sweep: RadarSweepModel = field(default_factory=RadarSweepModel)
|
||||
multi_device: RadarMultiDeviceModel = field(default_factory=RadarMultiDeviceModel)
|
||||
kamil_adc: KamilAdcModel = field(default_factory=KamilAdcModel)
|
||||
rph_radar: RphRadarModel = field(default_factory=RphRadarModel)
|
||||
laser_control: LaserControlModel = field(default_factory=LaserControlModel)
|
||||
|
||||
|
||||
@@ -337,7 +334,7 @@ class RunConfigModel:
|
||||
|
||||
LIBREVNA_MODEL = "librevna"
|
||||
LIBREVNA_MULTI_MODEL = "librevna_multi"
|
||||
KAMIL_ADC_MODEL = "kamil_adc"
|
||||
RPH_RADAR_MODEL = "rph_radar"
|
||||
MULTI_DEVICE_INPUT_POSITIONS = 4
|
||||
MULTI_DEVICE_OUTPUT_POSITIONS = 2
|
||||
# Stimulus ports the master itself owns. The virtual output axis is this times the
|
||||
@@ -402,70 +399,38 @@ class RunConfigModel:
|
||||
return self.build_full_combos(self.input_switch.positions, self.output_switch.positions)
|
||||
|
||||
@property
|
||||
def is_kamil_adc(self) -> bool:
|
||||
"""Return whether this config targets the external Kamil ADC acquisition path."""
|
||||
return self.radar.model == self.KAMIL_ADC_MODEL
|
||||
def is_rph_radar(self) -> bool:
|
||||
"""Return whether this config targets the external RadioPhotonic radar (RPH) acquisition path."""
|
||||
return self.radar.model == self.RPH_RADAR_MODEL
|
||||
|
||||
def radar_key(self) -> str:
|
||||
"""Stable hardware/sweep identity used to find saved preprocess sets."""
|
||||
radar, sweep = self.radar, self.radar.sweep
|
||||
if self.is_rph_radar:
|
||||
laser = radar.laser_control
|
||||
mode = laser.mode.strip().lower()
|
||||
optical = asdict(laser.variation if mode == "variation" else laser.manual)
|
||||
physical = (asdict(radar.rph_radar.band), asdict(radar.rph_radar.phase_calibration),
|
||||
radar.rph_radar.args, (self.input_switch.positions, self.output_switch.positions),
|
||||
(mode, optical, laser.pi_coeff1_p, laser.pi_coeff1_i,
|
||||
laser.pi_coeff2_p, laser.pi_coeff2_i) if laser.enabled else None)
|
||||
digest = hashlib.sha256(json.dumps(physical, sort_keys=True).encode()).hexdigest()[:16]
|
||||
return f"{radar.model}_{digest}"
|
||||
serials = [radar.serial or "no_serial"]
|
||||
serials.extend(value.strip() or "no_serial" for value in self._radar_key_extra_parts())
|
||||
if self.is_multi_device:
|
||||
serials.extend(value.strip() or "no_serial" for value in radar.multi_device.slave_serials)
|
||||
serial = "_".join(sanitize_path_component(value) for value in serials)
|
||||
points = "adc" if self.is_kamil_adc else str(int(sweep.points))
|
||||
points = str(int(sweep.points))
|
||||
# Switch positions decide what each combo index means, so sets from another matrix never match.
|
||||
matrix = (f"_out{self.matrix_output_switch_positions}_in{self.matrix_input_switch_positions}"
|
||||
f"_stim{self.matrix_stimulus_switch_positions}") if self.is_multi_device else ""
|
||||
return (
|
||||
f"{radar.model}_{serial}"
|
||||
f"_st{_format_float_for_key(sweep.start_hz)}_sp{_format_float_for_key(sweep.stop_hz)}"
|
||||
f"_p{points}_if{_format_float_for_key(sweep.if_bandwidth_hz)}"
|
||||
f"_pw{_format_float_for_key(sweep.power_dbm)}"
|
||||
f"_pw{_format_float_for_key(sweep.power_dbm)}{matrix}"
|
||||
)
|
||||
|
||||
def _radar_key_extra_parts(self) -> list[str]:
|
||||
"""Return model-specific identity parts that affect captured data."""
|
||||
if self.is_multi_device:
|
||||
return list(self.radar.multi_device.slave_serials)
|
||||
if self.is_kamil_adc:
|
||||
payload = {
|
||||
"kamil_adc": {
|
||||
"project_dir": self.radar.kamil_adc.project_dir,
|
||||
"executable_path": self.radar.kamil_adc.executable_path,
|
||||
"tty_path": self.radar.kamil_adc.tty_path,
|
||||
"args": list(self.radar.kamil_adc.args),
|
||||
"env": dict(sorted(self.radar.kamil_adc.env.items())),
|
||||
},
|
||||
"laser_control": {
|
||||
"enabled": self.radar.laser_control.enabled,
|
||||
"port": self.radar.laser_control.port,
|
||||
"mode": self.radar.laser_control.mode,
|
||||
"pi_coeff1_p": self.radar.laser_control.pi_coeff1_p,
|
||||
"pi_coeff1_i": self.radar.laser_control.pi_coeff1_i,
|
||||
"pi_coeff2_p": self.radar.laser_control.pi_coeff2_p,
|
||||
"pi_coeff2_i": self.radar.laser_control.pi_coeff2_i,
|
||||
"manual": {
|
||||
"temp1": self.radar.laser_control.manual.temp1,
|
||||
"temp2": self.radar.laser_control.manual.temp2,
|
||||
"current1": self.radar.laser_control.manual.current1,
|
||||
"current2": self.radar.laser_control.manual.current2,
|
||||
},
|
||||
"variation": {
|
||||
"variation_type": self.radar.laser_control.variation.variation_type,
|
||||
"static_temp1": self.radar.laser_control.variation.static_temp1,
|
||||
"static_temp2": self.radar.laser_control.variation.static_temp2,
|
||||
"static_current1": self.radar.laser_control.variation.static_current1,
|
||||
"static_current2": self.radar.laser_control.variation.static_current2,
|
||||
"min_value": self.radar.laser_control.variation.min_value,
|
||||
"max_value": self.radar.laser_control.variation.max_value,
|
||||
"step": self.radar.laser_control.variation.step,
|
||||
"time_step": self.radar.laser_control.variation.time_step,
|
||||
"delay_time": self.radar.laser_control.variation.delay_time,
|
||||
},
|
||||
},
|
||||
}
|
||||
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||
digest = hashlib.sha256(encoded.encode("utf-8")).hexdigest()[:16]
|
||||
return [f"kamil_{digest}"]
|
||||
return []
|
||||
|
||||
def apply_device_model_constraints(self) -> None:
|
||||
"""Apply only required wire-format constraints for the selected device model."""
|
||||
if not self.is_multi_device:
|
||||
|
||||
@@ -20,6 +20,7 @@ from python_app.models.run_config_schema import (
|
||||
GprModel,
|
||||
GprRxGeometryModel,
|
||||
GprTxGeometryModel,
|
||||
RphRadarModel,
|
||||
RadarSweepModel,
|
||||
RingEndpointModel,
|
||||
RunConfigModel,
|
||||
@@ -129,12 +130,15 @@ def validate_acquisition_config(config: RunConfigModel) -> None:
|
||||
if mode not in ("mock", "native"):
|
||||
raise ValueError("radar.driver_mode must be mock or native")
|
||||
if config.radar.model not in (
|
||||
config.LIBREVNA_MODEL, config.LIBREVNA_MULTI_MODEL, config.KAMIL_ADC_MODEL,
|
||||
config.LIBREVNA_MODEL, config.LIBREVNA_MULTI_MODEL, config.RPH_RADAR_MODEL,
|
||||
):
|
||||
raise ValueError(f"Unsupported radar model: {config.radar.model}")
|
||||
if mode != "native" and config.is_kamil_adc:
|
||||
if mode != "native" and config.is_rph_radar:
|
||||
raise ValueError(f"{config.radar.model} requires native driver mode")
|
||||
|
||||
if config.is_rph_radar:
|
||||
validate_rph_radar_model(config.radar.rph_radar)
|
||||
|
||||
for switch, physical_positions in (
|
||||
(config.input_switch, config.matrix_input_switch_positions),
|
||||
(config.output_switch, config.matrix_output_switch_positions),
|
||||
@@ -155,7 +159,7 @@ def validate_acquisition_config(config: RunConfigModel) -> None:
|
||||
if not switch.gpio_chip or switch.pin_a < 0 or switch.pin_b < 0 or switch.pin_b == switch.pin_a:
|
||||
raise ValueError(f"{context}: GPIO chip and distinct nonnegative control pins are required")
|
||||
|
||||
points = config.radar.kamil_adc.band.points if config.is_kamil_adc else config.radar.sweep.points
|
||||
points = config.radar.rph_radar.band.points if config.is_rph_radar else config.radar.sweep.points
|
||||
# RAW3/PRP3 header, frequency/S21 arrays and capture windows.
|
||||
minimum = 44 + len(config.combos) * (28 + 12 * points)
|
||||
for name in ("raw", "raw_tap", "preprocessed", "preprocessed_tap"):
|
||||
@@ -166,6 +170,24 @@ def validate_acquisition_config(config: RunConfigModel) -> None:
|
||||
raise ValueError(f"rings.{name}.slot_size_bytes must be at least {minimum} for this sweep")
|
||||
|
||||
|
||||
def validate_rph_radar_model(adc: RphRadarModel) -> None:
|
||||
"""Reject phase/grid errors before stopping a running pipeline."""
|
||||
phase, band = adc.phase_calibration, adc.band
|
||||
for name, value in {**asdict(phase), **asdict(band)}.items():
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
||||
raise ValueError(f"radar.rph_radar.{name} must be finite")
|
||||
if phase.phase0_rad == phase.phase1_rad or phase.freq0_hz == phase.freq1_hz:
|
||||
raise ValueError("radar.rph_radar.phase_calibration requires distinct phases and frequencies")
|
||||
if not 0 <= band.start_hz < band.stop_hz <= 3.4e38:
|
||||
raise ValueError("radar.rph_radar.band requires 0 <= start_hz < stop_hz within float32")
|
||||
if not isinstance(band.points, int) or not 2 <= band.points <= _UINT32_MAX:
|
||||
raise ValueError("radar.rph_radar.band.points must be an integer in [2, uint32 max]")
|
||||
for name in ("startup_timeout_s", "sweep_timeout_s", "stop_timeout_s"):
|
||||
value = getattr(adc, name)
|
||||
if not math.isfinite(value) or value <= 0:
|
||||
raise ValueError(f"radar.rph_radar.{name} must be finite and positive")
|
||||
|
||||
|
||||
def validate_sweep_model(sweep: RadarSweepModel) -> None:
|
||||
"""Validate radar sweep bounds (point count and frequency span).
|
||||
|
||||
|
||||
@@ -24,10 +24,7 @@ logger = logging.getLogger(__name__)
|
||||
_LOG_MAX_BYTES = 8 * 1024 * 1024
|
||||
# Per-process force-kill deadline used on stop; each child gets its own window.
|
||||
_STOP_GRACE_SECONDS = 2.0
|
||||
# Cmdline marker for the Kamil ADC collector binary. The collector runs in its own
|
||||
# session, so it can outlive a force-killed acquisition producer while still holding
|
||||
# the L-Card E-502; we hunt it by name as a guaranteed device-release backstop.
|
||||
_KAMIL_COLLECTOR_MARKER = b"kamil_adc_collector"
|
||||
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -143,9 +140,6 @@ class ProcessSupervisor:
|
||||
"""Start required pipeline binaries and wait until they are ready."""
|
||||
if self.is_running():
|
||||
raise RuntimeError("Acquisition processes are already running")
|
||||
# Clean slate: an ADC collector orphaned by a prior crash/force-kill can
|
||||
# still hold the E-502 and make a fresh acquisition fail to open.
|
||||
self._kill_kamil_collectors()
|
||||
|
||||
logger.info(
|
||||
"Starting pipeline from config %s (allow_clean_acquisition_exit=%s)",
|
||||
@@ -197,7 +191,6 @@ class ProcessSupervisor:
|
||||
def stop(self) -> None:
|
||||
"""Stop acquisition-side processes, keep processor process intact."""
|
||||
self._stop_processes(["acquisition", "data_preprocessor"])
|
||||
self._kill_kamil_collectors()
|
||||
|
||||
def stop_acquisition(self) -> None:
|
||||
"""Stop acquisition process only."""
|
||||
@@ -210,7 +203,6 @@ class ProcessSupervisor:
|
||||
def stop_all(self) -> None:
|
||||
"""Stop all managed processes."""
|
||||
self._stop_processes(["acquisition", "data_preprocessor", "data_processor"])
|
||||
self._kill_kamil_collectors()
|
||||
|
||||
def _spawn(self, name: str, command: list[str], *, allow_clean_exit: bool) -> None:
|
||||
"""Spawn one process unless same process is already alive.
|
||||
@@ -339,34 +331,6 @@ class ProcessSupervisor:
|
||||
except (ProcessLookupError, PermissionError):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _kill_kamil_collectors() -> None:
|
||||
"""SIGKILL any Kamil ADC collector found by name — a guaranteed device backstop.
|
||||
|
||||
The collector survives a force-killed producer (own session) and can hang
|
||||
the E-502, so it is hunted by cmdline and its group killed hard, regardless
|
||||
of who its parent is. Best-effort and never raises.
|
||||
"""
|
||||
own_pid = os.getpid()
|
||||
try:
|
||||
pids = [int(entry.name) for entry in Path("/proc").iterdir() if entry.name.isdigit()]
|
||||
except OSError:
|
||||
return
|
||||
killed = 0
|
||||
for pid in pids:
|
||||
if pid <= 1 or pid == own_pid:
|
||||
continue
|
||||
try:
|
||||
cmdline = (Path("/proc") / str(pid) / "cmdline").read_bytes()
|
||||
except OSError:
|
||||
continue
|
||||
executable = cmdline.split(b"\x00", 1)[0].rsplit(b"/", 1)[-1]
|
||||
if executable == _KAMIL_COLLECTOR_MARKER:
|
||||
ProcessSupervisor._signal_group(pid, signal.SIGKILL)
|
||||
killed += 1
|
||||
if killed:
|
||||
logger.warning("Force-killed %d orphaned Kamil ADC collector process(es)", killed)
|
||||
|
||||
@staticmethod
|
||||
def _log_abnormal_stop_exit(process: ManagedProcess) -> None:
|
||||
"""Log a process that self-exited abnormally around stop time.
|
||||
@@ -542,9 +506,6 @@ class ProcessSupervisor:
|
||||
atomic_write_text(self._pidfile_path, "")
|
||||
except OSError:
|
||||
pass
|
||||
# The ADC collector is a grandchild (spawned by the producer), not in the
|
||||
# pidfile, so reap it separately by name.
|
||||
self._kill_kamil_collectors()
|
||||
|
||||
def _is_stale_pipeline_pid(self, pid: int) -> bool:
|
||||
"""Return whether `pid` still runs one of our pipeline binaries/scripts.
|
||||
@@ -555,7 +516,7 @@ class ProcessSupervisor:
|
||||
native_names = {"data_processor", "data_preprocessor"}
|
||||
producer_modules = {
|
||||
"python_app.scripts.matrix_raw_producer",
|
||||
"python_app.scripts.kamil_adc_raw_producer",
|
||||
"python_app.scripts.rph_radar_raw_producer",
|
||||
}
|
||||
try:
|
||||
raw = (Path("/proc") / str(pid) / "cmdline").read_bytes()
|
||||
|
||||
@@ -18,7 +18,7 @@ def acquisition_command(config_path: Path) -> list[str]:
|
||||
producers = {
|
||||
"librevna": "matrix_raw_producer",
|
||||
"librevna_multi": "matrix_raw_producer",
|
||||
"kamil_adc": "kamil_adc_raw_producer",
|
||||
"rph_radar": "rph_radar_raw_producer",
|
||||
}
|
||||
if model not in producers:
|
||||
raise ValueError(f"Unsupported radar model: {model}")
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
"""Standalone laser temperature checker process.
|
||||
|
||||
Reads the target setpoints frozen at variation start and tails the JSONL readings
|
||||
channel produced by the monitor. For every reading it validates both lasers and
|
||||
prints a console warning whenever a measured temperature drifts from its target by
|
||||
more than the tolerance (default 0.03 °C). Never touches the serial port, so it is
|
||||
fully independent of the monitor and can be started/stopped at any time.
|
||||
|
||||
Example::
|
||||
|
||||
python -m python_app.scripts.laser_temp_checker
|
||||
python -m python_app.scripts.laser_temp_checker --session s.json --readings r.jsonl
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import signal
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from python_app.hardware_full.laser_control.monitoring import (
|
||||
DEFAULT_READINGS_PATH,
|
||||
DEFAULT_SESSION_PATH,
|
||||
LaserTemperatureChecker,
|
||||
LaserVariationSession,
|
||||
ReadingReader,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("laser_temp_checker")
|
||||
|
||||
_POLL_INTERVAL_S = 0.2
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Validate laser temperature against setpoints")
|
||||
parser.add_argument("--session", type=Path, default=DEFAULT_SESSION_PATH,
|
||||
help="Session snapshot with target setpoints + tolerance")
|
||||
parser.add_argument("--readings", type=Path, default=DEFAULT_READINGS_PATH,
|
||||
help="JSONL readings channel to tail")
|
||||
parser.add_argument("--tolerance", type=float, default=None,
|
||||
help="Override tolerance in °C (default: from session)")
|
||||
parser.add_argument("--reminder-every", type=int, default=0,
|
||||
help="Repeat a warning every N readings while off target (0=off)")
|
||||
parser.add_argument("--from-start", action="store_true",
|
||||
help="Validate the whole readings file, not just new lines")
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
||||
|
||||
session = LaserVariationSession.load(args.session)
|
||||
if args.tolerance is not None:
|
||||
session.tolerance_c = args.tolerance
|
||||
checker = LaserTemperatureChecker.from_session(session, reminder_every=args.reminder_every)
|
||||
logger.info(
|
||||
"Checking against T1=%.3f T2=%.3f °C, tolerance ±%.3f °C (%s)",
|
||||
session.target_temp1, session.target_temp2, session.tolerance_c, session.variation_type,
|
||||
)
|
||||
|
||||
reader = ReadingReader(args.readings, from_start=args.from_start)
|
||||
stop_event = threading.Event()
|
||||
|
||||
def request_stop(_signum: int, _frame: object) -> None:
|
||||
stop_event.set()
|
||||
|
||||
signal.signal(signal.SIGINT, request_stop)
|
||||
signal.signal(signal.SIGTERM, request_stop)
|
||||
|
||||
while not stop_event.is_set():
|
||||
for reading in reader.poll():
|
||||
checker.process(reading)
|
||||
stop_event.wait(_POLL_INTERVAL_S)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,149 +0,0 @@
|
||||
"""Standalone laser temperature monitor process.
|
||||
|
||||
Owns the laser serial port, (optionally) starts a current-variation task, then
|
||||
polls the board once per sweep and appends each reading to a JSONL channel that
|
||||
the temperature checker tails. Runs until SIGINT/SIGTERM.
|
||||
|
||||
Examples::
|
||||
|
||||
# Start LD1 current variation from a run config, then monitor:
|
||||
python -m python_app.scripts.laser_temp_monitor --config run_config.json --start
|
||||
|
||||
# Monitor a variation that is already running:
|
||||
python -m python_app.scripts.laser_temp_monitor --config run_config.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import signal
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from python_app.hardware_full.laser_control.controller import (
|
||||
DEVICE_MAIN_MESSAGE_ID,
|
||||
LaserController,
|
||||
)
|
||||
from python_app.hardware_full.laser_control.exceptions import PortBusyError
|
||||
from python_app.hardware_full.laser_control.models import VariationType
|
||||
from python_app.hardware_full.laser_control.monitoring import (
|
||||
DEFAULT_READINGS_PATH,
|
||||
DEFAULT_SESSION_PATH,
|
||||
LaserTemperatureMonitor,
|
||||
LaserVariationSession,
|
||||
ReadingWriter,
|
||||
resolve_period_s,
|
||||
)
|
||||
from python_app.models.run_config_schema import RunConfigModel
|
||||
|
||||
logger = logging.getLogger("laser_temp_monitor")
|
||||
|
||||
|
||||
def _start_variation(controller: LaserController, variation) -> None:
|
||||
"""Send the CHANGE_CURRENT_LD1 task and freeze the session snapshot."""
|
||||
controller.reset()
|
||||
controller.set_manual_mode(
|
||||
temp1=variation.static_temp1,
|
||||
temp2=variation.static_temp2,
|
||||
current1=variation.static_current1,
|
||||
current2=variation.static_current2,
|
||||
message_id=DEVICE_MAIN_MESSAGE_ID,
|
||||
)
|
||||
controller.start_variation(
|
||||
variation_type=VariationType[variation.variation_type],
|
||||
params={
|
||||
"static_temp1": variation.static_temp1,
|
||||
"static_temp2": variation.static_temp2,
|
||||
"static_current1": variation.static_current1,
|
||||
"static_current2": variation.static_current2,
|
||||
"min_value": variation.min_value,
|
||||
"max_value": variation.max_value,
|
||||
"step": variation.step,
|
||||
"time_step": variation.time_step,
|
||||
"delay_time": variation.delay_time,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Poll laser temperature once per sweep")
|
||||
parser.add_argument("--config", required=True, type=Path, help="Path to run_config.json")
|
||||
parser.add_argument("--readings", type=Path, default=DEFAULT_READINGS_PATH,
|
||||
help="JSONL readings channel to append to")
|
||||
parser.add_argument("--session", type=Path, default=DEFAULT_SESSION_PATH,
|
||||
help="Session snapshot path (written with --start)")
|
||||
parser.add_argument("--strategy", default="computed",
|
||||
help="'computed' (per sweep) or 'interval:<ms>'")
|
||||
parser.add_argument("--start", action="store_true",
|
||||
help="Send CHANGE_CURRENT_LD1 before monitoring")
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
||||
|
||||
config = RunConfigModel.load_from_path(args.config)
|
||||
laser = config.radar.laser_control
|
||||
variation = laser.variation
|
||||
if variation.variation_type != "CHANGE_CURRENT_LD1":
|
||||
logger.warning(
|
||||
"Only CHANGE_CURRENT_LD1 is supported by firmware; got %s",
|
||||
variation.variation_type,
|
||||
)
|
||||
|
||||
period_s = resolve_period_s(
|
||||
args.strategy,
|
||||
min_value=variation.min_value,
|
||||
max_value=variation.max_value,
|
||||
step=variation.step,
|
||||
time_step_us=variation.time_step,
|
||||
delay_time_ms=variation.delay_time,
|
||||
)
|
||||
|
||||
stop_event = threading.Event()
|
||||
|
||||
def request_stop(_signum: int, _frame: object) -> None:
|
||||
stop_event.set()
|
||||
|
||||
signal.signal(signal.SIGINT, request_stop)
|
||||
signal.signal(signal.SIGTERM, request_stop)
|
||||
|
||||
controller = LaserController(
|
||||
port=laser.port or None,
|
||||
pi_coeff1_p=laser.pi_coeff1_p,
|
||||
pi_coeff1_i=laser.pi_coeff1_i,
|
||||
pi_coeff2_p=laser.pi_coeff2_p,
|
||||
pi_coeff2_i=laser.pi_coeff2_i,
|
||||
)
|
||||
try:
|
||||
controller.connect()
|
||||
except PortBusyError as exc:
|
||||
# Expected, benign conflict: the manual-control UI (or another monitor)
|
||||
# already owns the port. Exit cleanly with guidance, not a traceback.
|
||||
logger.error("%s", exc)
|
||||
return 2
|
||||
try:
|
||||
if args.start:
|
||||
_start_variation(controller, variation)
|
||||
LaserVariationSession(
|
||||
variation_type=variation.variation_type,
|
||||
target_temp1=variation.static_temp1,
|
||||
target_temp2=variation.static_temp2,
|
||||
tolerance_c=variation.temp_tolerance_c,
|
||||
started_at_iso=datetime.now().isoformat(timespec="seconds"),
|
||||
).save(args.session)
|
||||
logger.info("Started CHANGE_CURRENT_LD1 and wrote session %s", args.session)
|
||||
|
||||
with ReadingWriter(args.readings) as writer:
|
||||
monitor = LaserTemperatureMonitor(
|
||||
controller=controller, writer=writer, period_s=period_s
|
||||
)
|
||||
logger.info("Monitoring to %s (period=%.3fs)", args.readings, period_s)
|
||||
monitor.run(stop_event)
|
||||
finally:
|
||||
controller.disconnect()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Measure only RPH reference phase anchors using the current acquisition settings.
|
||||
|
||||
Start the laser with Apply Radar and stop acquisition before running this tool.
|
||||
No laser commands are sent. --apply replaces only phase0_rad and phase1_rad;
|
||||
frequency anchors, band, collector settings and JSON formatting are preserved.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.file_io import atomic_write_text
|
||||
from python_app.hardware_full.rph_radar.service import RphRadarService
|
||||
from python_app.models.run_config_schema import RunConfigModel
|
||||
|
||||
|
||||
def _value_span(text: str, path: tuple[str, ...]) -> tuple[int, int]:
|
||||
"""Locate a unique JSON member without reserializing its neighbours."""
|
||||
decoder = json.JSONDecoder()
|
||||
def skip_space(index: int) -> int:
|
||||
while text[index] in " \t\r\n":
|
||||
index += 1
|
||||
return index
|
||||
|
||||
start, end = 0, len(text)
|
||||
for member in path:
|
||||
start = skip_space(start)
|
||||
if text[start] != "{":
|
||||
raise ValueError(f"Expected JSON object before {member}")
|
||||
cursor, found = start + 1, None
|
||||
while True:
|
||||
cursor = skip_space(cursor)
|
||||
if text[cursor] == "}":
|
||||
break
|
||||
key, cursor = decoder.raw_decode(text, cursor)
|
||||
cursor = skip_space(cursor) + 1 # colon; JSON already validated
|
||||
cursor = skip_space(cursor)
|
||||
_, next_cursor = decoder.raw_decode(text, cursor)
|
||||
if key == member:
|
||||
if found is not None:
|
||||
raise ValueError(f"Duplicate JSON member: {member}")
|
||||
found = (cursor, next_cursor)
|
||||
cursor = skip_space(next_cursor)
|
||||
if text[cursor] == "}":
|
||||
break
|
||||
cursor += 1 # comma
|
||||
if found is None:
|
||||
raise ValueError(f"Profile must contain explicit {'.'.join(path)}")
|
||||
start, end = found
|
||||
return start, end
|
||||
|
||||
|
||||
def calibrate(config: RunConfigModel, sweeps: int, warmup: int) -> dict[str, float]:
|
||||
"""Median endpoints after aligning each sweep to the preceding phase branch."""
|
||||
if sweeps < 1 or warmup < 0:
|
||||
raise ValueError("sweeps must be positive and warmup nonnegative")
|
||||
if not config.is_rph_radar or config.radar.driver_mode != "native":
|
||||
raise ValueError("Calibration requires a native rph_radar profile")
|
||||
previous = config.radar.rph_radar.phase_calibration.phase0_rad
|
||||
endpoints = []
|
||||
service = RphRadarService(config)
|
||||
try:
|
||||
service.open()
|
||||
for index in range(warmup + sweeps):
|
||||
raw = service.read_raw_sweep()
|
||||
if index < warmup:
|
||||
continue
|
||||
reference = np.asarray(raw.reference, dtype=np.complex128)
|
||||
if (raw.dirty or reference.ndim != 1 or reference.size < 2
|
||||
or not np.all(np.isfinite(reference)) or np.any(np.abs(reference) <= 1e-9)):
|
||||
raise ValueError("Invalid reference sweep; phase calibration was not changed")
|
||||
phase = np.unwrap(np.angle(reference))
|
||||
phase += round((previous - float(phase[0])) / (2 * np.pi)) * (2 * np.pi)
|
||||
if abs(float(phase[-1] - phase[0])) <= 1e-9:
|
||||
raise ValueError("Degenerate reference phase span")
|
||||
endpoints.append((phase[0], phase[-1]))
|
||||
previous = float(phase[0])
|
||||
finally:
|
||||
service.close()
|
||||
phase0, phase1 = np.median(endpoints, axis=0)
|
||||
if abs(float(phase1 - phase0)) <= 1e-9:
|
||||
raise ValueError("Degenerate median phase span")
|
||||
return {"phase0_rad": float(phase0), "phase1_rad": float(phase1)}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Calibrate RadioPhotonic radar (RPH) phase anchors")
|
||||
parser.add_argument("--config", required=True, type=Path)
|
||||
parser.add_argument("--sweeps", type=int, default=200)
|
||||
parser.add_argument("--warmup", type=int, default=10)
|
||||
parser.add_argument("--apply", action="store_true", help="Write only the two phase anchors")
|
||||
args = parser.parse_args()
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
||||
original = args.config.read_bytes()
|
||||
text = original.decode("utf-8")
|
||||
config = RunConfigModel.from_dict(json.loads(text))
|
||||
fields = ("phase0_rad", "phase1_rad")
|
||||
spans = {name: _value_span(text, ("radar", "rph_radar", "phase_calibration", name)) for name in fields}
|
||||
result = calibrate(config, args.sweeps, args.warmup)
|
||||
print(json.dumps(result, allow_nan=False))
|
||||
if args.apply:
|
||||
for name, (start, end) in sorted(spans.items(), key=lambda item: item[1][0], reverse=True):
|
||||
text = text[:start] + json.dumps(result[name], allow_nan=False) + text[end:]
|
||||
if args.config.read_bytes() != original:
|
||||
raise RuntimeError("Profile changed during capture; refusing to overwrite it")
|
||||
atomic_write_text(args.config, text, durable=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+31
-32
@@ -1,4 +1,4 @@
|
||||
"""Raw acquisition producer for the external Kamil ADC radar mode."""
|
||||
"""Raw acquisition producer for RadioPhotonic radar (RPH)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -9,7 +9,7 @@ import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.hardware_full.kamil_adc import KamilAdcService
|
||||
from python_app.hardware_full.rph_radar.service import RphRadarService
|
||||
from python_app.hardware_full.switch_service import SwitchService, select_and_settle
|
||||
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
|
||||
from python_app.models.run_config_schema import RunConfigModel
|
||||
@@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def _open_radar_with_retry(
|
||||
config: RunConfigModel,
|
||||
radar: KamilAdcService,
|
||||
radar: RphRadarService,
|
||||
input_switch: SwitchService | None,
|
||||
output_switch: SwitchService | None,
|
||||
stop_requested: threading.Event,
|
||||
@@ -28,7 +28,7 @@ def _open_radar_with_retry(
|
||||
"""Reopen the ADC and Python-owned switches, cleaning partial opens before retry."""
|
||||
close_devices(input_switch, output_switch, radar)
|
||||
|
||||
def attempt() -> KamilAdcService:
|
||||
def attempt() -> RphRadarService:
|
||||
try:
|
||||
radar.open(stop_event=stop_requested)
|
||||
radar.configure(config.radar.sweep)
|
||||
@@ -41,16 +41,16 @@ def _open_radar_with_retry(
|
||||
raise
|
||||
return radar
|
||||
|
||||
return retry_open(attempt, stop_requested, "Kamil ADC") is not None
|
||||
return retry_open(attempt, stop_requested, "RadioPhotonic radar (RPH)") is not None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Run producer process until config or signal requests exit."""
|
||||
config_path, config, stop_requested = load_producer_config("Publish Kamil ADC raw sweeps to SHM rings")
|
||||
if not config.is_kamil_adc:
|
||||
raise RuntimeError("kamil_adc_raw_producer requires radar.model='kamil_adc'")
|
||||
config_path, config, stop_requested = load_producer_config("Publish RadioPhotonic radar (RPH) raw sweeps to SHM rings")
|
||||
if not config.is_rph_radar:
|
||||
raise RuntimeError("rph_radar_raw_producer requires radar.model='rph_radar'")
|
||||
logger.info(
|
||||
"Kamil ADC raw producer starting: config=%s, combos=%d, continuous=%s",
|
||||
"RadioPhotonic radar (RPH) raw producer starting: config=%s, combos=%d, continuous=%s",
|
||||
config_path, len(config.combos), config.runtime.continuous,
|
||||
)
|
||||
|
||||
@@ -59,24 +59,21 @@ def main() -> int:
|
||||
try:
|
||||
publisher = RawPublisher(config.rings, cleanup)
|
||||
# Switch-aware mode: with native switches the collector drives the RF switches
|
||||
# itself, in the hardware gap between sweeps, and tags each sweep with its combo
|
||||
# — no sweep lost at a switch boundary. The producer then only reads tagged
|
||||
# sweeps and must not touch the GPIO lines the collector owns. With mock switches
|
||||
# (dev/tests) we keep the Python-driven path, which is fine where speed and the
|
||||
# in-gap timing do not matter.
|
||||
# itself and tags each sweep with its combo and source cycle. Otherwise Python
|
||||
# owns the switches and starts a fresh collector session after each changed pair.
|
||||
collector_driven = (
|
||||
config.input_switch.driver_mode == "native"
|
||||
and config.output_switch.driver_mode == "native"
|
||||
)
|
||||
radar = KamilAdcService(
|
||||
radar = RphRadarService(
|
||||
config,
|
||||
switch_config_path=str(config_path) if collector_driven else None,
|
||||
collector_driven=collector_driven,
|
||||
)
|
||||
input_switch = None if collector_driven else SwitchService.from_model(config.input_switch)
|
||||
output_switch = None if collector_driven else SwitchService.from_model(config.output_switch)
|
||||
logger.info(
|
||||
"Kamil ADC switch control: %s",
|
||||
"collector-driven (in-gap, lossless)" if collector_driven else "producer-driven",
|
||||
"RadioPhotonic radar (RPH) switch control: %s",
|
||||
"collector-driven" if collector_driven else "producer-driven",
|
||||
)
|
||||
|
||||
if not _open_radar_with_retry(config, radar, input_switch, output_switch, stop_requested):
|
||||
@@ -85,8 +82,8 @@ def main() -> int:
|
||||
collection_id = 1
|
||||
while not stop_requested.is_set():
|
||||
collection_start = time.monotonic()
|
||||
capture_start_ns = time.monotonic_ns()
|
||||
traces: list[TraceData] = []
|
||||
cycle_id = None
|
||||
|
||||
try:
|
||||
for combo in config.combos:
|
||||
@@ -95,30 +92,30 @@ def main() -> int:
|
||||
if collector_driven:
|
||||
# The collector already switched and tagged the sweep; just
|
||||
# read the clean capture for this combination.
|
||||
sweep_start_ns = time.monotonic_ns()
|
||||
sweep = radar.acquire(combo=(combo.input, combo.output))
|
||||
if cycle_id is not None and cycle_id != radar.last_cycle_id:
|
||||
break # A dropped or overwritten pair invalidates this cycle.
|
||||
cycle_id = radar.last_cycle_id
|
||||
else:
|
||||
if input_switch is None or output_switch is None:
|
||||
raise RuntimeError("Producer-driven switches are not initialized")
|
||||
if not select_and_settle(output_switch, input_switch, combo.output, combo.input,
|
||||
config.runtime.settling_ms, wait=stop_requested.wait):
|
||||
break
|
||||
# Stamped after switching and settling so the window covers
|
||||
# the sweep alone, not the dead time before it.
|
||||
sweep_start_ns = time.monotonic_ns()
|
||||
radar.prepare_combo(ComboKey(combo.input, combo.output))
|
||||
sweep = radar.acquire()
|
||||
traces.append(
|
||||
TraceData(
|
||||
combo=ComboKey(input=combo.input, output=combo.output),
|
||||
frequency_hz=np.asarray(sweep.x, dtype=np.float32),
|
||||
s21=np.asarray(sweep.trace("s21"), dtype=np.complex64),
|
||||
capture_start_ns=sweep_start_ns,
|
||||
capture_end_ns=time.monotonic_ns(),
|
||||
capture_start_ns=sweep.capture_start_ns,
|
||||
capture_end_ns=sweep.capture_end_ns,
|
||||
)
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — reconnect forever, never give up
|
||||
logger.warning(
|
||||
"Kamil ADC acquisition failed; reconnecting and waiting for the device: %s",
|
||||
"RadioPhotonic radar (RPH) acquisition failed; reconnecting and waiting for the device: %s",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
@@ -132,8 +129,10 @@ def main() -> int:
|
||||
if len(traces) != len(config.combos):
|
||||
if stop_requested.is_set():
|
||||
break
|
||||
if collector_driven:
|
||||
continue # Latest-wins transport skipped part of a source cycle.
|
||||
logger.warning(
|
||||
"Kamil ADC produced an incomplete collection (%d of %d combos); "
|
||||
"RadioPhotonic radar (RPH) produced an incomplete collection (%d of %d combos); "
|
||||
"reconnecting and waiting for the device",
|
||||
len(traces),
|
||||
len(config.combos),
|
||||
@@ -143,11 +142,11 @@ def main() -> int:
|
||||
continue
|
||||
|
||||
collection = SweepCollection(
|
||||
collection_id=collection_id,
|
||||
collection_id=cycle_id if cycle_id is not None else collection_id,
|
||||
monotonic_ns=time.monotonic_ns(),
|
||||
traces=traces,
|
||||
capture_start_ns=capture_start_ns,
|
||||
capture_end_ns=time.monotonic_ns(),
|
||||
capture_start_ns=min(trace.capture_start_ns for trace in traces),
|
||||
capture_end_ns=max(trace.capture_end_ns for trace in traces),
|
||||
)
|
||||
publisher.publish(collection)
|
||||
|
||||
@@ -155,13 +154,13 @@ def main() -> int:
|
||||
break
|
||||
collection_duration_s = time.monotonic() - collection_start
|
||||
if collection_id == 1 or collection_id % 20 == 0 or collection_duration_s > 2.0:
|
||||
logger.info("Kamil ADC collection %d acquired in %.3f s", collection_id, collection_duration_s)
|
||||
logger.info("RadioPhotonic radar (RPH) collection %d acquired in %.3f s", collection_id, collection_duration_s)
|
||||
collection_id += 1
|
||||
finally:
|
||||
close_devices(output_switch, input_switch, radar)
|
||||
cleanup.close()
|
||||
|
||||
logger.info("Kamil ADC raw producer stopped")
|
||||
logger.info("RadioPhotonic radar (RPH) raw producer stopped")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ def radar_config_filename_prefix(
|
||||
dataset's name carries the configuration it was captured with. ADC-mode radars have
|
||||
no fixed sweep, so the point count is reported as ``adc``.
|
||||
"""
|
||||
points_token = "adc" if model_name.strip().lower() == "kamil_adc" else f"{int(sweep_points)}pts"
|
||||
points_token = "adc" if model_name.strip().lower() == "rph_radar" else f"{int(sweep_points)}pts"
|
||||
tokens = (
|
||||
model_name,
|
||||
_format_frequency_span(sweep_start_hz, sweep_stop_hz),
|
||||
|
||||
@@ -53,7 +53,7 @@ class ComboNavigationTest(unittest.TestCase):
|
||||
self.w._plots.draw(result)
|
||||
|
||||
def test_next_button_draws_cached_combo_without_changing_acquisition(self) -> None:
|
||||
for model in ("librevna", "librevna_multi", "kamil_adc"):
|
||||
for model in ("librevna", "librevna_multi", "rph_radar"):
|
||||
with self.subTest(model=model):
|
||||
self.w._profile.state.config.radar.model = model
|
||||
self._seed_bscan()
|
||||
|
||||
@@ -63,4 +63,4 @@ printf 'updated environment' > "$(dirname -- "$0")/../state"
|
||||
self.assertTrue((target / 'run_config_examples/device.json.new').exists())
|
||||
self.assertEqual((target / 'python_app/runtime/gui_session_state.json').read_text(), 'session')
|
||||
self.assertFalse((target / 'python_app/runtime/logs').exists())
|
||||
self.assertIn('kamil_adc_collector', log.read_text().splitlines())
|
||||
self.assertIn('rph_radar_collector', log.read_text().splitlines())
|
||||
|
||||
@@ -303,10 +303,10 @@ class ShutdownReentryTest(unittest.TestCase):
|
||||
_mock(pipeline._status).assert_called_with("Status: error")
|
||||
self.assertFalse(pipeline.can_resume(expected_stop_seq))
|
||||
|
||||
def test_unsupported_kamil_adc_mock_fails_before_starting_any_process(self) -> None:
|
||||
def test_unsupported_rph_radar_mock_fails_before_starting_any_process(self) -> None:
|
||||
pipeline = _pipeline(self)
|
||||
config = RunConfigModel()
|
||||
config.radar.model = "kamil_adc"
|
||||
config.radar.model = "rph_radar"
|
||||
config.apply_device_model_constraints()
|
||||
_mock(pipeline._build_config).return_value = config
|
||||
pipeline.stop_all = mock.Mock()
|
||||
|
||||
@@ -16,7 +16,7 @@ from unittest.mock import Mock, patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.hardware_full.kamil_adc.tty_reader import KamilAdcTtyReader
|
||||
from python_app.hardware_full.rph_radar.tty_reader import RphRadarTtyReader
|
||||
from python_app.hardware_full.laser_control.protocol import Protocol
|
||||
from python_app.hardware_full.librevna_service import LibreVnaService
|
||||
from python_app.hardware_full.librevna_driver.api.vna import VNAController
|
||||
@@ -30,7 +30,7 @@ from python_app.hardware_full.switched_matrix_radar_service import SwitchedMatri
|
||||
from python_app.models.run_config_schema import RadarSweepModel, RunConfigModel
|
||||
from python_app.models.dataset_model import SweepCollection
|
||||
from python_app.orchestration.shm.decoder import RAW_MAGIC, decode_trace_collection
|
||||
from python_app.scripts import kamil_adc_raw_producer, matrix_raw_producer, producer_common
|
||||
from python_app.scripts import rph_radar_raw_producer, matrix_raw_producer, producer_common
|
||||
from python_app.workflows.radar_config_variants import scan_radar_config_variants
|
||||
|
||||
|
||||
@@ -252,7 +252,7 @@ class TtyLifecycleTest(unittest.TestCase):
|
||||
with self.subTest(combination=combination):
|
||||
master, slave = pty.openpty()
|
||||
tty.setraw(slave)
|
||||
reader = KamilAdcTtyReader(os.ttyname(slave))
|
||||
reader = RphRadarTtyReader(os.ttyname(slave))
|
||||
errors = []
|
||||
started = threading.Event()
|
||||
reader.open()
|
||||
@@ -260,10 +260,7 @@ class TtyLifecycleTest(unittest.TestCase):
|
||||
def wait():
|
||||
started.set()
|
||||
try:
|
||||
if combination is None:
|
||||
reader.read_sweep(timeout_s=20)
|
||||
else:
|
||||
reader.read_sweep_for(combination, timeout_s=20)
|
||||
reader.read_sweep(timeout_s=20, combo=combination)
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
@@ -283,7 +280,7 @@ class TtyLifecycleTest(unittest.TestCase):
|
||||
|
||||
def test_failed_reader_thread_start_closes_descriptor(self) -> None:
|
||||
read_fd, write_fd = os.pipe()
|
||||
reader = KamilAdcTtyReader("/dev/test")
|
||||
reader = RphRadarTtyReader("/dev/test")
|
||||
try:
|
||||
with patch("os.open", return_value=read_fd), patch("threading.Thread.start", side_effect=RuntimeError("thread failed")):
|
||||
with self.assertRaises(RuntimeError):
|
||||
@@ -298,7 +295,7 @@ class TtyLifecycleTest(unittest.TestCase):
|
||||
|
||||
class ProducerCleanupTest(unittest.TestCase):
|
||||
def test_partial_ring_initialization_closes_first_writer(self) -> None:
|
||||
for module, model in ((matrix_raw_producer, "librevna_multi"), (kamil_adc_raw_producer, "kamil_adc")):
|
||||
for module, model in ((matrix_raw_producer, "librevna_multi"), (rph_radar_raw_producer, "rph_radar")):
|
||||
with self.subTest(producer=module.__name__):
|
||||
config = RunConfigModel()
|
||||
config.radar.model = model
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
"""Tests for the Kamil ADC TTY wire-protocol parser."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.hardware_full.kamil_adc.protocol import (
|
||||
COMBO_MARKER,
|
||||
MAIN_MARKER,
|
||||
REFERENCE_MARKER,
|
||||
KamilAdcStreamParser,
|
||||
)
|
||||
|
||||
|
||||
def _boundary() -> bytes:
|
||||
return struct.pack("<HHHH", MAIN_MARKER, 0xFFFF, 0xFFFF, 0xFFFF)
|
||||
|
||||
|
||||
def _combo(input_pos: int, output_pos: int, dirty: int = 0) -> bytes:
|
||||
return struct.pack("<HHhh", COMBO_MARKER, input_pos, output_pos, dirty)
|
||||
|
||||
|
||||
def _main(step: int, real: int, imag: int) -> bytes:
|
||||
return struct.pack("<HHhh", MAIN_MARKER, step, real, imag)
|
||||
|
||||
|
||||
def _reference(step: int, real: int, imag: int) -> bytes:
|
||||
return struct.pack("<HHhh", REFERENCE_MARKER, step, real, imag)
|
||||
|
||||
|
||||
class KamilAdcStreamParserTest(unittest.TestCase):
|
||||
def test_pairs_main_and_reference_by_step(self) -> None:
|
||||
parser = KamilAdcStreamParser()
|
||||
stream = (
|
||||
_boundary()
|
||||
+ _main(1, 10, -1) + _reference(1, 100, 5)
|
||||
+ _main(2, 20, -2) + _reference(2, 200, 6)
|
||||
+ _boundary()
|
||||
)
|
||||
sweeps = parser.feed(stream)
|
||||
self.assertEqual(len(sweeps), 1)
|
||||
sweep = sweeps[0]
|
||||
self.assertEqual(sweep.steps.tolist(), [1, 2])
|
||||
self.assertEqual(sweep.main.tolist(), [complex(10, -1), complex(20, -2)])
|
||||
self.assertEqual(sweep.reference.tolist(), [complex(100, 5), complex(200, 6)])
|
||||
|
||||
def test_keeps_only_steps_present_in_both_channels(self) -> None:
|
||||
parser = KamilAdcStreamParser()
|
||||
stream = (
|
||||
_boundary()
|
||||
+ _main(1, 10, 0) # main only -> dropped
|
||||
+ _main(2, 20, 0) + _reference(2, 200, 0) # both -> kept
|
||||
+ _reference(3, 300, 0) # reference only -> dropped
|
||||
+ _boundary()
|
||||
)
|
||||
(sweep,) = parser.feed(stream)
|
||||
self.assertEqual(sweep.steps.tolist(), [2])
|
||||
self.assertEqual(sweep.main.tolist(), [complex(20, 0)])
|
||||
self.assertEqual(sweep.reference.tolist(), [complex(200, 0)])
|
||||
|
||||
def test_orders_by_ascending_step_regardless_of_arrival(self) -> None:
|
||||
parser = KamilAdcStreamParser()
|
||||
stream = (
|
||||
_boundary()
|
||||
+ _main(3, 3, 0) + _reference(3, 30, 0)
|
||||
+ _main(1, 1, 0) + _reference(1, 10, 0)
|
||||
+ _main(2, 2, 0) + _reference(2, 20, 0)
|
||||
+ _boundary()
|
||||
)
|
||||
(sweep,) = parser.feed(stream)
|
||||
self.assertEqual(sweep.steps.tolist(), [1, 2, 3])
|
||||
self.assertEqual(sweep.main.real.tolist(), [1, 2, 3])
|
||||
|
||||
def test_discards_preroll_before_first_boundary(self) -> None:
|
||||
parser = KamilAdcStreamParser()
|
||||
# Garbage + a partial point before the first real boundary must be skipped.
|
||||
stream = (
|
||||
_main(7, 7, 7) # pre-roll point (no preceding boundary) -> ignored
|
||||
+ _boundary()
|
||||
+ _main(1, 11, 0) + _reference(1, 1, 0)
|
||||
+ _boundary()
|
||||
)
|
||||
(sweep,) = parser.feed(stream)
|
||||
self.assertEqual(sweep.steps.tolist(), [1])
|
||||
|
||||
def test_handles_chunk_splits_across_frames(self) -> None:
|
||||
parser = KamilAdcStreamParser()
|
||||
stream = (
|
||||
_boundary()
|
||||
+ _main(1, 10, 0) + _reference(1, 100, 0)
|
||||
+ _main(2, 20, 0) + _reference(2, 200, 0)
|
||||
+ _boundary()
|
||||
)
|
||||
sweeps: list = []
|
||||
# Feed one byte at a time to exercise reassembly across feed() calls.
|
||||
for byte in stream:
|
||||
sweeps.extend(parser.feed(bytes([byte])))
|
||||
self.assertEqual(len(sweeps), 1)
|
||||
self.assertEqual(sweeps[0].steps.tolist(), [1, 2])
|
||||
|
||||
def test_multiple_sweeps_in_one_feed(self) -> None:
|
||||
parser = KamilAdcStreamParser()
|
||||
stream = (
|
||||
_boundary()
|
||||
+ _main(1, 1, 0) + _reference(1, 10, 0)
|
||||
+ _boundary()
|
||||
+ _main(1, 2, 0) + _reference(1, 20, 0)
|
||||
+ _boundary()
|
||||
)
|
||||
sweeps = parser.feed(stream)
|
||||
self.assertEqual(len(sweeps), 2)
|
||||
self.assertEqual(sweeps[0].main.real.tolist(), [1])
|
||||
self.assertEqual(sweeps[1].main.real.tolist(), [2])
|
||||
|
||||
def test_empty_sweep_between_boundaries_is_skipped(self) -> None:
|
||||
parser = KamilAdcStreamParser()
|
||||
stream = _boundary() + _boundary() + _main(1, 5, 0) + _reference(1, 1, 0) + _boundary()
|
||||
sweeps = parser.feed(stream)
|
||||
self.assertEqual(len(sweeps), 1)
|
||||
self.assertEqual(sweeps[0].steps.tolist(), [1])
|
||||
|
||||
def test_corrupt_marker_raises(self) -> None:
|
||||
parser = KamilAdcStreamParser()
|
||||
corrupt = struct.pack("<HHhh", 0x001A, 1, 5, 5) # unknown marker
|
||||
with self.assertRaisesRegex(ValueError, "protocol violation"):
|
||||
parser.feed(_boundary() + _main(1, 1, 0) + corrupt + _boundary())
|
||||
|
||||
def test_partial_trailing_frame_is_buffered(self) -> None:
|
||||
parser = KamilAdcStreamParser()
|
||||
self.assertEqual(parser.feed(_boundary() + _main(1, 1, 0)[:5]), [])
|
||||
# Supply the rest of the frame plus its reference and the closing boundary.
|
||||
rest = _main(1, 1, 0)[5:]
|
||||
(sweep,) = parser.feed(rest + _reference(1, 9, 0) + _boundary())
|
||||
self.assertEqual(sweep.steps.tolist(), [1])
|
||||
|
||||
def test_reset_clears_state(self) -> None:
|
||||
parser = KamilAdcStreamParser()
|
||||
parser.feed(_boundary() + _main(1, 1, 0))
|
||||
parser.reset()
|
||||
# After reset we must re-align on a fresh boundary before collecting.
|
||||
sweeps = parser.feed(_main(9, 9, 0) + _boundary() + _main(1, 2, 0) + _reference(1, 2, 0) + _boundary())
|
||||
self.assertEqual(len(sweeps), 1)
|
||||
self.assertEqual(sweeps[0].main.real.tolist(), [2])
|
||||
|
||||
def test_untagged_sweep_has_no_combo(self) -> None:
|
||||
parser = KamilAdcStreamParser()
|
||||
(sweep,) = parser.feed(_boundary() + _main(1, 1, 0) + _reference(1, 9, 0) + _boundary())
|
||||
self.assertIsNone(sweep.combo)
|
||||
self.assertFalse(sweep.dirty)
|
||||
|
||||
def test_combo_tag_labels_following_sweep(self) -> None:
|
||||
parser = KamilAdcStreamParser()
|
||||
stream = (
|
||||
_boundary() + _combo(1, 2)
|
||||
+ _main(1, 10, 0) + _reference(1, 100, 0)
|
||||
+ _boundary() + _combo(3, 0, dirty=1)
|
||||
+ _main(1, 20, 0) + _reference(1, 200, 0)
|
||||
+ _boundary()
|
||||
)
|
||||
first, second = parser.feed(stream)
|
||||
self.assertEqual(first.combo, (1, 2))
|
||||
self.assertFalse(first.dirty)
|
||||
self.assertEqual(second.combo, (3, 0))
|
||||
self.assertTrue(second.dirty)
|
||||
|
||||
def test_combo_not_carried_into_untagged_sweep(self) -> None:
|
||||
parser = KamilAdcStreamParser()
|
||||
stream = (
|
||||
_boundary() + _combo(1, 1)
|
||||
+ _main(1, 1, 0) + _reference(1, 1, 0)
|
||||
+ _boundary() # next sweep has no combo frame
|
||||
+ _main(2, 2, 0) + _reference(2, 2, 0)
|
||||
+ _boundary()
|
||||
)
|
||||
first, second = parser.feed(stream)
|
||||
self.assertEqual(first.combo, (1, 1))
|
||||
self.assertIsNone(second.combo)
|
||||
|
||||
def test_dtypes(self) -> None:
|
||||
parser = KamilAdcStreamParser()
|
||||
(sweep,) = parser.feed(_boundary() + _main(1, 1, 2) + _reference(1, 3, 4) + _boundary())
|
||||
self.assertEqual(sweep.main.dtype, np.complex64)
|
||||
self.assertEqual(sweep.reference.dtype, np.complex64)
|
||||
self.assertEqual(sweep.steps.dtype, np.int32)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,312 +0,0 @@
|
||||
"""Tests for the Kamil ADC TTY reader, config round-trip, and producer wiring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pty
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import tty
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from python_app.hardware_full.kamil_adc import KamilAdcService, KamilAdcTtyReader
|
||||
from python_app.hardware_full.kamil_adc.protocol import (
|
||||
COMBO_MARKER,
|
||||
MAIN_MARKER,
|
||||
REFERENCE_MARKER,
|
||||
)
|
||||
from python_app.models.run_config_schema import RunConfigModel
|
||||
from python_app.orchestration.process_supervisor import ProcessSupervisor
|
||||
|
||||
|
||||
def _boundary() -> bytes:
|
||||
return struct.pack("<HHHH", MAIN_MARKER, 0xFFFF, 0xFFFF, 0xFFFF)
|
||||
|
||||
|
||||
def _main(step: int, real: int, imag: int) -> bytes:
|
||||
return struct.pack("<HHhh", MAIN_MARKER, step, real, imag)
|
||||
|
||||
|
||||
def _reference(step: int, real: int, imag: int) -> bytes:
|
||||
return struct.pack("<HHhh", REFERENCE_MARKER, step, real, imag)
|
||||
|
||||
|
||||
def _combo(input_pos: int, output_pos: int, dirty: int = 0) -> bytes:
|
||||
return struct.pack("<HHhh", COMBO_MARKER, input_pos, output_pos, dirty)
|
||||
|
||||
|
||||
class KamilAdcTtyReaderTest(unittest.TestCase):
|
||||
"""End-to-end tests over a PTY exercising the background reader thread."""
|
||||
|
||||
def _open_pty_reader(self) -> tuple[int, int, KamilAdcTtyReader]:
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
tty.setraw(slave_fd)
|
||||
reader = KamilAdcTtyReader(os.ttyname(slave_fd))
|
||||
reader.open()
|
||||
return master_fd, slave_fd, reader
|
||||
|
||||
@staticmethod
|
||||
def _close(master_fd: int, slave_fd: int, reader: KamilAdcTtyReader) -> None:
|
||||
try:
|
||||
reader.close()
|
||||
finally:
|
||||
os.close(master_fd)
|
||||
os.close(slave_fd)
|
||||
|
||||
def test_publishes_sweep_with_aligned_main_and_reference(self) -> None:
|
||||
master_fd, slave_fd, reader = self._open_pty_reader()
|
||||
try:
|
||||
os.write(
|
||||
master_fd,
|
||||
_boundary()
|
||||
+ _main(1, 10, -1) + _reference(1, 100, 5)
|
||||
+ _main(2, -20, 2) + _reference(2, 200, 6)
|
||||
+ _boundary(),
|
||||
)
|
||||
sweep = reader.read_sweep(timeout_s=1.0)
|
||||
self.assertEqual(sweep.steps.tolist(), [1, 2])
|
||||
self.assertEqual(sweep.main.tolist(), [complex(10, -1), complex(-20, 2)])
|
||||
self.assertEqual(sweep.reference.tolist(), [complex(100, 5), complex(200, 6)])
|
||||
finally:
|
||||
self._close(master_fd, slave_fd, reader)
|
||||
|
||||
def test_variable_length_sweeps_are_allowed(self) -> None:
|
||||
"""Unlike the old format, sweep length may vary — no locking, just resample later."""
|
||||
master_fd, slave_fd, reader = self._open_pty_reader()
|
||||
try:
|
||||
# One complete sweep per write, read between, so delivery is deterministic
|
||||
# (the reader publishes only the latest, overwriting unread sweeps).
|
||||
os.write(master_fd, _boundary() + _main(1, 1, 0) + _reference(1, 9, 0) + _boundary())
|
||||
first = reader.read_sweep(timeout_s=1.0)
|
||||
self.assertEqual(first.steps.tolist(), [1])
|
||||
|
||||
os.write(
|
||||
master_fd,
|
||||
_main(1, 2, 0) + _reference(1, 8, 0)
|
||||
+ _main(2, 3, 0) + _reference(2, 7, 0)
|
||||
+ _boundary(),
|
||||
)
|
||||
second = reader.read_sweep(timeout_s=1.0)
|
||||
self.assertEqual(second.steps.tolist(), [1, 2])
|
||||
finally:
|
||||
self._close(master_fd, slave_fd, reader)
|
||||
|
||||
def test_only_latest_sweep_is_published(self) -> None:
|
||||
master_fd, slave_fd, reader = self._open_pty_reader()
|
||||
try:
|
||||
payload = (
|
||||
_boundary() + _main(1, 1, 0) + _reference(1, 1, 0)
|
||||
+ _boundary() + _main(1, 2, 0) + _reference(1, 2, 0)
|
||||
+ _boundary() + _main(1, 3, 0) + _reference(1, 3, 0)
|
||||
+ _boundary()
|
||||
)
|
||||
os.write(master_fd, payload)
|
||||
deadline = time.monotonic() + 1.0
|
||||
while time.monotonic() < deadline and reader.published_count < 3:
|
||||
time.sleep(0.005)
|
||||
self.assertGreaterEqual(reader.published_count, 3)
|
||||
sweep = reader.read_sweep(timeout_s=1.0)
|
||||
self.assertEqual(sweep.main.tolist(), [complex(3, 0)])
|
||||
finally:
|
||||
self._close(master_fd, slave_fd, reader)
|
||||
|
||||
def test_corrupt_frame_fails_fast(self) -> None:
|
||||
master_fd, slave_fd, reader = self._open_pty_reader()
|
||||
try:
|
||||
corrupt = struct.pack("<HHhh", 0x001A, 1, 5, 5) # unknown marker
|
||||
os.write(master_fd, _boundary() + _main(1, 10, -1) + corrupt + _boundary())
|
||||
with self.assertRaises((ValueError, RuntimeError)):
|
||||
reader.read_sweep(timeout_s=1.0)
|
||||
finally:
|
||||
self._close(master_fd, slave_fd, reader)
|
||||
|
||||
def test_no_completed_sweep_times_out(self) -> None:
|
||||
master_fd, slave_fd, reader = self._open_pty_reader()
|
||||
try:
|
||||
os.write(master_fd, _boundary() + _main(1, 10, -1) + _reference(1, 1, 0))
|
||||
with self.assertRaisesRegex(TimeoutError, "Timed out waiting for Kamil ADC sweep"):
|
||||
reader.read_sweep(timeout_s=0.1)
|
||||
finally:
|
||||
self._close(master_fd, slave_fd, reader)
|
||||
|
||||
def test_read_sweep_for_demuxes_by_combo(self) -> None:
|
||||
master_fd, slave_fd, reader = self._open_pty_reader()
|
||||
try:
|
||||
os.write(
|
||||
master_fd,
|
||||
_boundary() + _combo(0, 0) + _main(1, 11, 0) + _reference(1, 1, 0)
|
||||
+ _boundary() + _combo(0, 1) + _main(1, 22, 0) + _reference(1, 1, 0)
|
||||
+ _boundary(),
|
||||
)
|
||||
# Each combination is served from its own slot, regardless of order.
|
||||
second = reader.read_sweep_for((0, 1), timeout_s=1.0)
|
||||
self.assertEqual(second.main.real.tolist(), [22])
|
||||
self.assertEqual(second.combo, (0, 1))
|
||||
first = reader.read_sweep_for((0, 0), timeout_s=1.0)
|
||||
self.assertEqual(first.main.real.tolist(), [11])
|
||||
finally:
|
||||
self._close(master_fd, slave_fd, reader)
|
||||
|
||||
def test_read_sweep_for_drops_dirty_and_takes_retake(self) -> None:
|
||||
master_fd, slave_fd, reader = self._open_pty_reader()
|
||||
try:
|
||||
os.write(
|
||||
master_fd,
|
||||
# A dirty combo (0,1) sweep, then its clean re-take of the same combo.
|
||||
_boundary() + _combo(0, 1, dirty=1) + _main(1, 99, 0) + _reference(1, 1, 0)
|
||||
+ _boundary() + _combo(0, 1) + _main(1, 42, 0) + _reference(1, 1, 0)
|
||||
+ _boundary(),
|
||||
)
|
||||
sweep = reader.read_sweep_for((0, 1), timeout_s=1.0)
|
||||
# The dirty sweep (99) is dropped; only the clean re-take (42) is served.
|
||||
self.assertEqual(sweep.main.real.tolist(), [42])
|
||||
self.assertFalse(sweep.dirty)
|
||||
finally:
|
||||
self._close(master_fd, slave_fd, reader)
|
||||
|
||||
|
||||
class KamilAdcConfigTest(unittest.TestCase):
|
||||
def test_config_round_trip_preserves_kamil_sections(self) -> None:
|
||||
payload = {
|
||||
"radar": {
|
||||
"model": "kamil_adc",
|
||||
"serial": "kamil_adc",
|
||||
"driver_mode": "native",
|
||||
"kamil_adc": {
|
||||
"project_dir": "",
|
||||
"executable_path": "build/bin/kamil_adc_collector",
|
||||
"tty_path": "/tmp/ttyADC_data",
|
||||
"args": ["profile:phase", "do8_freq_ref"],
|
||||
"env": {"ADC_ENV": "1"},
|
||||
"startup_timeout_s": 7.0,
|
||||
"sweep_timeout_s": 8.0,
|
||||
"stop_timeout_s": 3.0,
|
||||
"phase_calibration": {
|
||||
"phase0_rad": 1.5,
|
||||
"freq0_hz": 2_046_000_000.0,
|
||||
"phase1_rad": 301.0,
|
||||
"freq1_hz": 5_612_000_000.0,
|
||||
},
|
||||
"band": {
|
||||
"start_hz": 2_100_000_000.0,
|
||||
"stop_hz": 5_500_000_000.0,
|
||||
"points": 1024,
|
||||
},
|
||||
},
|
||||
"laser_control": {"enabled": True, "port": "/dev/ttyUSB0", "mode": "manual"},
|
||||
"sweep": {"start_hz": 1.0, "stop_hz": 2.0, "points": 2},
|
||||
},
|
||||
"switches": {"port1": {"positions": 1}, "port2": {"positions": 1}},
|
||||
}
|
||||
|
||||
encoded = RunConfigModel.from_dict(payload).to_dict()
|
||||
kamil = encoded["radar"]["kamil_adc"]
|
||||
|
||||
self.assertEqual(encoded["radar"]["model"], "kamil_adc")
|
||||
self.assertEqual(kamil["executable_path"], "build/bin/kamil_adc_collector")
|
||||
self.assertEqual(kamil["args"], ["profile:phase", "do8_freq_ref"])
|
||||
self.assertEqual(kamil["phase_calibration"]["phase0_rad"], 1.5)
|
||||
self.assertEqual(kamil["phase_calibration"]["freq1_hz"], 5_612_000_000.0)
|
||||
self.assertEqual(kamil["band"], {"start_hz": 2_100_000_000.0, "stop_hz": 5_500_000_000.0, "points": 1024})
|
||||
|
||||
def test_close_never_raises_when_collector_refuses_to_die(self) -> None:
|
||||
"""close() must stay exception-safe even if a SIGKILL'd collector is not
|
||||
reaped within the grace window (e.g. wedged in USB D-state)."""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config = RunConfigModel.from_dict(
|
||||
{
|
||||
"radar": {
|
||||
"model": "kamil_adc",
|
||||
"driver_mode": "native",
|
||||
"kamil_adc": {
|
||||
"project_dir": tmp_dir,
|
||||
"executable_path": "/bin/sh", # any real executable
|
||||
"tty_path": "/tmp/ttyADC_test",
|
||||
},
|
||||
},
|
||||
"switches": {"port1": {"positions": 1}, "port2": {"positions": 1}},
|
||||
}
|
||||
)
|
||||
service = KamilAdcService(config)
|
||||
|
||||
process = mock.Mock(spec=subprocess.Popen, pid=2_000_000_000)
|
||||
process.poll.return_value = None # always "alive"
|
||||
process.wait.side_effect = subprocess.TimeoutExpired("kamil_adc_collector", timeout=3.0)
|
||||
service._process = process
|
||||
with mock.patch("python_app.hardware_full.kamil_adc.service.os.killpg"):
|
||||
service.close() # must not raise
|
||||
|
||||
def test_drain_after_switch_waits_for_fresh_sweeps(self) -> None:
|
||||
"""After a switch change, drain must skip the configured number of freshly
|
||||
published sweeps before returning, so the next capture is post-switch."""
|
||||
from python_app.hardware_full.kamil_adc import service as service_module
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config = RunConfigModel.from_dict(
|
||||
{
|
||||
"radar": {
|
||||
"model": "kamil_adc",
|
||||
"driver_mode": "native",
|
||||
"kamil_adc": {
|
||||
"project_dir": tmp_dir,
|
||||
"executable_path": "/bin/sh",
|
||||
"tty_path": "/tmp/ttyADC_test",
|
||||
"sweep_timeout_s": 5.0,
|
||||
},
|
||||
},
|
||||
"switches": {"port1": {"positions": 1}, "port2": {"positions": 1}},
|
||||
}
|
||||
)
|
||||
service = KamilAdcService(config)
|
||||
reader = mock.Mock(spec=KamilAdcTtyReader, published_count=10)
|
||||
service._reader = reader
|
||||
service._process = mock.Mock(poll=mock.Mock(return_value=None))
|
||||
|
||||
# Each poll-sleep advances the published count, as the reader thread would.
|
||||
def _advance(_seconds: float) -> None:
|
||||
reader.published_count += 1
|
||||
|
||||
with mock.patch.object(service_module.time, "sleep", _advance):
|
||||
service.drain_after_switch(sweeps=3)
|
||||
|
||||
# Started at 10, must have waited for at least 3 more sweeps.
|
||||
self.assertGreaterEqual(reader.published_count, 13)
|
||||
|
||||
def test_drain_after_switch_is_noop_when_not_open(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config = RunConfigModel.from_dict(
|
||||
{
|
||||
"radar": {
|
||||
"model": "kamil_adc",
|
||||
"driver_mode": "native",
|
||||
"kamil_adc": {
|
||||
"project_dir": tmp_dir,
|
||||
"executable_path": "/bin/sh",
|
||||
"tty_path": "/tmp/ttyADC_test",
|
||||
},
|
||||
},
|
||||
"switches": {"port1": {"positions": 1}, "port2": {"positions": 1}},
|
||||
}
|
||||
)
|
||||
KamilAdcService(config).drain_after_switch() # no reader → must not raise
|
||||
|
||||
def test_supervisor_selects_kamil_adc_producer(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config_path = Path(tmp_dir) / "run_config.json"
|
||||
config_path.write_text(json.dumps({"radar": {"model": "kamil_adc"}}), encoding="utf-8")
|
||||
command = ProcessSupervisor(Path("/repo"))._acquisition_command(config_path)
|
||||
|
||||
self.assertEqual(
|
||||
command,
|
||||
[sys.executable, "-m", "python_app.scripts.kamil_adc_raw_producer", "--config", str(config_path)],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -8,9 +8,9 @@ import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from python_app.hardware_full.laser_control.controller import DEVICE_MAIN_MESSAGE_ID, LaserController
|
||||
from python_app.hardware_full.laser_control.models import VariationType
|
||||
from python_app.hardware_full.laser_control.protocol import Protocol, TaskType
|
||||
from python_app.hardware_full.kamil_adc import apply_kamil_adc_laser_control
|
||||
from python_app.hardware_full.laser_control.exceptions import CommunicationError, ValidationError
|
||||
from python_app.hardware_full.laser_control.protocol import Protocol
|
||||
from python_app.hardware_full.rph_radar.laser import apply_rph_radar_laser_control
|
||||
from python_app.models.run_config_schema import RunConfigModel
|
||||
|
||||
|
||||
@@ -18,11 +18,6 @@ DEVICE_MAIN_MANUAL_HEX = "1111ff37ffa518ab000000000000000a8000000a8000ff003d2acc
|
||||
DEVICE_MAIN_CHANGE_CURRENT_LD1_HEX = (
|
||||
"7777ff3701003d2acc2c10008813ffa5cc2c18ab0a00000a8000000a8000b600"
|
||||
)
|
||||
DEVICE_MAIN_CHANGE_CURRENT_LD2_HEX = (
|
||||
# Word 5 (step) = current_ma_to_n(0.05)=0x0010, matching LD1 and min/max — see the
|
||||
# step-encoding fix in protocol.py (was the inconsistent int(step*100)=0x0005).
|
||||
"7777ff3702003d2acc2c1000881318ab3d2affa50a00000a8000000a80004406"
|
||||
)
|
||||
|
||||
|
||||
class _FakeProtocol:
|
||||
@@ -43,11 +38,11 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
|
||||
self.controller.connect.return_value = True
|
||||
|
||||
@staticmethod
|
||||
def _kamil_config(laser_payload: dict[str, object]) -> RunConfigModel:
|
||||
def _rph_config(laser_payload: dict[str, object]) -> RunConfigModel:
|
||||
return RunConfigModel.from_dict(
|
||||
{
|
||||
"radar": {
|
||||
"model": "kamil_adc",
|
||||
"model": "rph_radar",
|
||||
"driver_mode": "native",
|
||||
"laser_control": laser_payload,
|
||||
}
|
||||
@@ -71,7 +66,6 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
|
||||
|
||||
def test_variation_commands_match_device_main_bytes(self) -> None:
|
||||
ld1_command = Protocol.encode_task_enable(
|
||||
task_type=TaskType.CHANGE_CURRENT_LD1,
|
||||
static_temp1=28.0,
|
||||
static_temp2=28.9,
|
||||
static_current1=33.0,
|
||||
@@ -81,50 +75,18 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
|
||||
step=0.05,
|
||||
time_step=50,
|
||||
delay_time=10,
|
||||
message_id=DEVICE_MAIN_MESSAGE_ID,
|
||||
pi_coeff1_p=2560,
|
||||
pi_coeff1_i=128,
|
||||
pi_coeff2_p=2560,
|
||||
pi_coeff2_i=128,
|
||||
)
|
||||
ld2_command = Protocol.encode_task_enable(
|
||||
task_type=TaskType.CHANGE_CURRENT_LD2,
|
||||
static_temp1=28.0,
|
||||
static_temp2=28.9,
|
||||
static_current1=33.0,
|
||||
static_current2=35.0,
|
||||
min_value=33.0,
|
||||
max_value=35.0,
|
||||
step=0.05,
|
||||
time_step=50,
|
||||
delay_time=10,
|
||||
message_id=DEVICE_MAIN_MESSAGE_ID,
|
||||
pi_coeff1_p=2560,
|
||||
pi_coeff1_i=128,
|
||||
pi_coeff2_p=2560,
|
||||
pi_coeff2_i=128,
|
||||
)
|
||||
|
||||
self.assertEqual(ld1_command.hex(), DEVICE_MAIN_CHANGE_CURRENT_LD1_HEX)
|
||||
self.assertEqual(ld2_command.hex(), DEVICE_MAIN_CHANGE_CURRENT_LD2_HEX)
|
||||
|
||||
def test_ld1_and_ld2_encode_current_step_identically(self) -> None:
|
||||
# Regression guard for the LD2 step-scale bug: both current-variation channels
|
||||
# must encode the step with the same scale (current_ma_to_n), like min/max.
|
||||
ld1, ld2 = [Protocol.encode_task_enable(
|
||||
task_type=task_type, static_temp1=28.0, static_temp2=28.9,
|
||||
static_current1=33.0, static_current2=35.0, min_value=33.0, max_value=35.0,
|
||||
step=0.05, time_step=50, delay_time=10, message_id=DEVICE_MAIN_MESSAGE_ID,
|
||||
pi_coeff1_p=2560, pi_coeff1_i=128, pi_coeff2_p=2560, pi_coeff2_i=128,
|
||||
) for task_type in (TaskType.CHANGE_CURRENT_LD1, TaskType.CHANGE_CURRENT_LD2)]
|
||||
# Word 5 (step) is at bytes 10:12 in both frames (sync, header, task, min, max, step).
|
||||
self.assertEqual(ld1[10:12], ld2[10:12])
|
||||
|
||||
def test_start_sequence_matches_device_main_order(self) -> None:
|
||||
cleanup = ExitStack()
|
||||
self.addCleanup(cleanup.close)
|
||||
fake_protocol = _FakeProtocol()
|
||||
controller = LaserController(pi_coeff1_p=2560, pi_coeff1_i=128, pi_coeff2_p=2560, pi_coeff2_i=128)
|
||||
controller = LaserController("/dev/ttyLASER", pi_coeff1_p=2560, pi_coeff1_i=128, pi_coeff2_p=2560, pi_coeff2_i=128)
|
||||
cleanup.enter_context(patch.object(controller, "_protocol", fake_protocol))
|
||||
|
||||
with patch("python_app.hardware_full.laser_control.controller.time.sleep", return_value=None):
|
||||
@@ -137,7 +99,6 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
|
||||
message_id=DEVICE_MAIN_MESSAGE_ID,
|
||||
)
|
||||
controller.start_variation(
|
||||
variation_type=VariationType.CHANGE_CURRENT_LD1,
|
||||
params={
|
||||
"static_temp1": 28.0,
|
||||
"static_temp2": 28.9,
|
||||
@@ -160,34 +121,8 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
|
||||
],
|
||||
)
|
||||
|
||||
def test_stop_sequence_restores_device_main_manual_bytes(self) -> None:
|
||||
cleanup = ExitStack()
|
||||
self.addCleanup(cleanup.close)
|
||||
fake_protocol = _FakeProtocol()
|
||||
controller = LaserController(pi_coeff1_p=2560, pi_coeff1_i=128, pi_coeff2_p=2560, pi_coeff2_i=128)
|
||||
cleanup.enter_context(patch.object(controller, "_protocol", fake_protocol))
|
||||
|
||||
with patch("python_app.hardware_full.laser_control.controller.time.sleep", return_value=None):
|
||||
controller.set_manual_mode(
|
||||
temp1=28.0,
|
||||
temp2=28.9,
|
||||
current1=33.0,
|
||||
current2=35.0,
|
||||
message_id=DEVICE_MAIN_MESSAGE_ID,
|
||||
)
|
||||
fake_protocol.sent.clear()
|
||||
controller.stop_task(restore_message_id=DEVICE_MAIN_MESSAGE_ID)
|
||||
|
||||
self.assertEqual(
|
||||
[command.hex() for command in fake_protocol.sent],
|
||||
[
|
||||
"2222",
|
||||
DEVICE_MAIN_MANUAL_HEX,
|
||||
],
|
||||
)
|
||||
|
||||
def test_apply_radar_variation_sequence_matches_device_main_order(self) -> None:
|
||||
config = self._kamil_config(
|
||||
config = self._rph_config(
|
||||
{
|
||||
"enabled": True,
|
||||
"port": "/dev/ttyUSB0",
|
||||
@@ -211,8 +146,8 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
|
||||
}
|
||||
)
|
||||
|
||||
with patch("python_app.hardware_full.laser_control.controller.LaserController", return_value=self.controller):
|
||||
applied = apply_kamil_adc_laser_control(config)
|
||||
with patch("python_app.hardware_full.rph_radar.laser.LaserController", return_value=self.controller):
|
||||
applied = apply_rph_radar_laser_control(config)
|
||||
|
||||
self.assertTrue(applied)
|
||||
controller = self.controller
|
||||
@@ -233,13 +168,12 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
|
||||
)
|
||||
# The variation handoff itself (type + params), not just call order, must be right.
|
||||
variation_payload = controller.start_variation.call_args.kwargs
|
||||
self.assertEqual(variation_payload["variation_type"], VariationType.CHANGE_CURRENT_LD1)
|
||||
self.assertEqual(variation_payload["params"]["step"], 0.05)
|
||||
self.assertEqual(variation_payload["params"]["min_value"], 33.0)
|
||||
self.assertEqual(variation_payload["params"]["max_value"], 35.0)
|
||||
|
||||
def test_apply_radar_manual_mode_sets_manual_without_variation(self) -> None:
|
||||
config = self._kamil_config(
|
||||
config = self._rph_config(
|
||||
{
|
||||
"enabled": True,
|
||||
"port": "/dev/ttyUSB0",
|
||||
@@ -247,27 +181,53 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
|
||||
"manual": {"temp1": 26.0, "temp2": 27.0, "current1": 30.0, "current2": 31.0},
|
||||
}
|
||||
)
|
||||
with patch("python_app.hardware_full.laser_control.controller.LaserController", return_value=self.controller):
|
||||
applied = apply_kamil_adc_laser_control(config)
|
||||
with patch("python_app.hardware_full.rph_radar.laser.LaserController", return_value=self.controller):
|
||||
applied = apply_rph_radar_laser_control(config)
|
||||
self.assertTrue(applied)
|
||||
controller = self.controller
|
||||
self.assertEqual([call[0] for call in controller.method_calls], ["connect", "reset", "set_manual_mode", "disconnect"])
|
||||
self.assertEqual(controller.set_manual_mode.call_args.kwargs["current1"], 30.0)
|
||||
|
||||
def test_apply_radar_unknown_variation_type_raises(self) -> None:
|
||||
config = self._kamil_config(
|
||||
config = self._rph_config(
|
||||
{"enabled": True, "port": "/dev/ttyUSB0", "mode": "variation",
|
||||
"variation": {"variation_type": "NOT_A_REAL_TYPE"}}
|
||||
)
|
||||
with patch("python_app.hardware_full.laser_control.controller.LaserController", return_value=self.controller):
|
||||
with patch("python_app.hardware_full.rph_radar.laser.LaserController", return_value=self.controller):
|
||||
with self.assertRaisesRegex(ValueError, "variation_type"):
|
||||
apply_kamil_adc_laser_control(config)
|
||||
apply_rph_radar_laser_control(config)
|
||||
|
||||
def test_state_failure_cannot_report_command_success(self) -> None:
|
||||
controller = LaserController("/dev/ttyLASER")
|
||||
for reply in (b"", b"\x00", b"\x04\x00", b"\x03\x00"):
|
||||
with self.subTest(reply=reply), patch.object(controller, "_protocol") as protocol:
|
||||
protocol.receive_raw.return_value = reply
|
||||
with patch("python_app.hardware_full.laser_control.controller.time.sleep"):
|
||||
with self.assertRaises(CommunicationError):
|
||||
controller.reset()
|
||||
|
||||
def test_invalid_settings_are_rejected_before_connect_or_reset(self) -> None:
|
||||
for mode, field, value in (("manual", "temp1", 100.0),
|
||||
("variation", "static_current2", 100.0),
|
||||
("variation", "step", 0.0),
|
||||
("variation", "step", 0.002),
|
||||
("variation", "variation_type", "CHANGE_TEMPERATURE_LD1"),
|
||||
("pi", "pi_coeff1_p", 65536)):
|
||||
config = self._rph_config({"enabled": True, "port": "/dev/ttyUSB0", "mode": "manual" if mode == "pi" else mode})
|
||||
target = config.radar.laser_control
|
||||
setattr(target if mode == "pi" else getattr(target, mode), field, value)
|
||||
with self.subTest(mode=mode, field=field), patch(
|
||||
"python_app.hardware_full.rph_radar.laser.LaserController", return_value=self.controller
|
||||
) as factory:
|
||||
with self.assertRaises((ValueError, ValidationError)):
|
||||
apply_rph_radar_laser_control(config)
|
||||
factory.assert_not_called()
|
||||
|
||||
def test_apply_radar_skips_disabled_laser_control(self) -> None:
|
||||
config = self._kamil_config({"enabled": False})
|
||||
config = self._rph_config({"enabled": False})
|
||||
|
||||
with patch("python_app.hardware_full.laser_control.controller.LaserController", return_value=self.controller) as factory:
|
||||
applied = apply_kamil_adc_laser_control(config)
|
||||
with patch("python_app.hardware_full.rph_radar.laser.LaserController", return_value=self.controller) as factory:
|
||||
applied = apply_rph_radar_laser_control(config)
|
||||
|
||||
self.assertFalse(applied)
|
||||
factory.assert_not_called()
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
"""Tests for the laser current-variation temperature monitoring package."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from python_app.hardware_full.laser_control.monitoring import (
|
||||
LaserTemperatureChecker,
|
||||
LaserTemperatureMonitor,
|
||||
LaserVariationSession,
|
||||
ReadingReader,
|
||||
ReadingWriter,
|
||||
TemperatureReading,
|
||||
compute_sweep_period_s,
|
||||
resolve_period_s,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeMeasurements:
|
||||
temp1: float
|
||||
temp2: float
|
||||
temp_ext1: Optional[float] = None
|
||||
temp_ext2: Optional[float] = None
|
||||
current1: Optional[float] = None
|
||||
current2: Optional[float] = None
|
||||
|
||||
|
||||
class _FakeController:
|
||||
"""Returns a queued sequence of measurements, then None."""
|
||||
|
||||
def __init__(self, measurements: List[Optional[_FakeMeasurements]]) -> None:
|
||||
self._queue = list(measurements)
|
||||
|
||||
def get_measurements(self) -> Optional[_FakeMeasurements]:
|
||||
return self._queue.pop(0) if self._queue else None
|
||||
|
||||
|
||||
class SessionRoundTripTest(unittest.TestCase):
|
||||
def test_save_then_load_preserves_targets_and_tolerance(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "session.json"
|
||||
LaserVariationSession(
|
||||
variation_type="CHANGE_CURRENT_LD1",
|
||||
target_temp1=28.0,
|
||||
target_temp2=28.9,
|
||||
tolerance_c=0.03,
|
||||
started_at_iso="2026-07-27T12:00:00",
|
||||
).save(path)
|
||||
|
||||
loaded = LaserVariationSession.load(path)
|
||||
|
||||
self.assertEqual(loaded.variation_type, "CHANGE_CURRENT_LD1")
|
||||
self.assertAlmostEqual(loaded.target_temp1, 28.0)
|
||||
self.assertAlmostEqual(loaded.target_temp2, 28.9)
|
||||
self.assertAlmostEqual(loaded.tolerance_c, 0.03)
|
||||
|
||||
def test_load_missing_file_raises(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
LaserVariationSession.load(Path(tmp) / "absent.json")
|
||||
|
||||
|
||||
class ReadingsChannelTest(unittest.TestCase):
|
||||
def _reading(self, seq: int, t1: float = 25.0, t2: float = 25.0) -> TemperatureReading:
|
||||
return TemperatureReading(seq=seq, mono_ns=seq, temp1=t1, temp2=t2)
|
||||
|
||||
def test_reader_tails_appended_lines_in_order(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "readings.jsonl"
|
||||
reader = ReadingReader(path) # start at (nonexistent) end
|
||||
with ReadingWriter(path) as writer:
|
||||
writer.write(self._reading(0, 25.0))
|
||||
writer.write(self._reading(1, 26.0))
|
||||
first = list(reader.poll())
|
||||
writer.write(self._reading(2, 27.0))
|
||||
second = list(reader.poll())
|
||||
|
||||
self.assertEqual([r.seq for r in first], [0, 1])
|
||||
self.assertEqual([r.seq for r in second], [2])
|
||||
self.assertAlmostEqual(first[1].temp1, 26.0)
|
||||
|
||||
def test_partial_trailing_line_is_buffered_until_newline(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "readings.jsonl"
|
||||
path.write_text('{"seq":0,"mono_ns":0,"temp1":25.0,"temp2":25.0}\n{"seq":1,"mono',
|
||||
encoding="utf-8")
|
||||
reader = ReadingReader(path, from_start=True)
|
||||
first = list(reader.poll())
|
||||
# Complete the truncated line.
|
||||
with path.open("a", encoding="utf-8") as fh:
|
||||
fh.write('_ns":1,"temp1":26.0,"temp2":26.0}\n')
|
||||
second = list(reader.poll())
|
||||
|
||||
self.assertEqual([r.seq for r in first], [0])
|
||||
self.assertEqual([r.seq for r in second], [1])
|
||||
|
||||
|
||||
class SweepPeriodTest(unittest.TestCase):
|
||||
def test_compute_sweep_period_matches_formula(self) -> None:
|
||||
# (35-33)/0.05 = 40 -> 41 points; per point = 10ms + 50us = 0.01005s.
|
||||
period = compute_sweep_period_s(33.0, 35.0, 0.05, time_step_us=50, delay_time_ms=10)
|
||||
self.assertAlmostEqual(period, 41 * 0.01005, places=6)
|
||||
|
||||
def test_resolve_interval_strategy(self) -> None:
|
||||
period = resolve_period_s(
|
||||
"interval:250", min_value=33.0, max_value=35.0, step=0.05,
|
||||
time_step_us=50, delay_time_ms=10,
|
||||
)
|
||||
self.assertAlmostEqual(period, 0.25)
|
||||
|
||||
def test_resolve_rejects_unknown_strategy(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
resolve_period_s("bogus", min_value=0, max_value=1, step=0.1,
|
||||
time_step_us=50, delay_time_ms=10)
|
||||
|
||||
|
||||
class MonitorTest(unittest.TestCase):
|
||||
def test_read_once_maps_measurement_fields(self) -> None:
|
||||
controller = _FakeController([_FakeMeasurements(
|
||||
temp1=28.01, temp2=28.9, temp_ext1=22.0, temp_ext2=23.0,
|
||||
current1=33.0, current2=35.0,
|
||||
)])
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with ReadingWriter(Path(tmp) / "r.jsonl") as writer:
|
||||
monitor = LaserTemperatureMonitor(controller, writer, period_s=0.0)
|
||||
reading = monitor.read_once(7)
|
||||
|
||||
assert reading is not None
|
||||
self.assertEqual(reading.seq, 7)
|
||||
self.assertAlmostEqual(reading.temp1, 28.01)
|
||||
assert reading.temp_ext1 is not None and reading.current2 is not None
|
||||
self.assertAlmostEqual(reading.temp_ext1, 22.0)
|
||||
self.assertAlmostEqual(reading.current2, 35.0)
|
||||
|
||||
def test_run_publishes_until_stopped(self) -> None:
|
||||
controller = _FakeController([
|
||||
_FakeMeasurements(28.0, 28.9),
|
||||
_FakeMeasurements(28.0, 28.9),
|
||||
])
|
||||
stop = threading.Event()
|
||||
|
||||
writer = mock.Mock(spec=ReadingWriter)
|
||||
writer.write.side_effect = lambda _reading: stop.set()
|
||||
monitor = LaserTemperatureMonitor(controller, writer, period_s=0.0)
|
||||
monitor.run(stop)
|
||||
|
||||
writer.write.assert_called_once()
|
||||
self.assertEqual(writer.write.call_args.args[0].seq, 0)
|
||||
|
||||
|
||||
class CheckerTest(unittest.TestCase):
|
||||
def _checker(self, reminder_every: int = 0) -> LaserTemperatureChecker:
|
||||
return LaserTemperatureChecker(target_temp1=28.0, target_temp2=28.9,
|
||||
tolerance_c=0.03, reminder_every=reminder_every)
|
||||
|
||||
def _reading(self, t1: float, t2: float, seq: int = 0) -> TemperatureReading:
|
||||
return TemperatureReading(seq=seq, mono_ns=seq, temp1=t1, temp2=t2)
|
||||
|
||||
def test_laser1_off_target_warns_once_for_laser1(self) -> None:
|
||||
checker = self._checker()
|
||||
warned = checker.process(self._reading(t1=28.05, t2=28.9)) # laser1 off by 0.05
|
||||
self.assertEqual([d.laser for d in warned], [1])
|
||||
|
||||
def test_within_tolerance_no_warning(self) -> None:
|
||||
checker = self._checker()
|
||||
warned = checker.process(self._reading(t1=28.01, t2=28.9)) # 0.01 < 0.03
|
||||
self.assertEqual(warned, [])
|
||||
|
||||
def test_boundary_equal_tolerance_is_ok(self) -> None:
|
||||
checker = self._checker()
|
||||
warned = checker.process(self._reading(t1=28.03, t2=28.9)) # |Δ|==tol -> within
|
||||
self.assertEqual(warned, [])
|
||||
|
||||
def test_both_lasers_off_target_warn_independently(self) -> None:
|
||||
checker = self._checker()
|
||||
warned = checker.process(self._reading(t1=27.9, t2=29.0))
|
||||
self.assertEqual(sorted(d.laser for d in warned), [1, 2])
|
||||
|
||||
def test_persistent_mismatch_warns_once_then_silent(self) -> None:
|
||||
checker = self._checker()
|
||||
first = checker.process(self._reading(t1=28.1, t2=28.9, seq=0))
|
||||
second = checker.process(self._reading(t1=28.1, t2=28.9, seq=1))
|
||||
self.assertEqual([d.laser for d in first], [1])
|
||||
self.assertEqual(second, []) # no reminder configured
|
||||
|
||||
def test_reminder_repeats_warning(self) -> None:
|
||||
checker = self._checker(reminder_every=2)
|
||||
checker.process(self._reading(t1=28.1, t2=28.9, seq=0)) # initial warn
|
||||
self.assertEqual(checker.process(self._reading(t1=28.1, t2=28.9, seq=1)), [])
|
||||
again = checker.process(self._reading(t1=28.1, t2=28.9, seq=2)) # reminder
|
||||
self.assertEqual([d.laser for d in again], [1])
|
||||
|
||||
def test_recovery_clears_mismatch_state(self) -> None:
|
||||
checker = self._checker()
|
||||
checker.process(self._reading(t1=28.1, t2=28.9, seq=0)) # warn
|
||||
checker.process(self._reading(t1=28.0, t2=28.9, seq=1)) # recover (info, no warn)
|
||||
rewarn = checker.process(self._reading(t1=28.1, t2=28.9, seq=2)) # warns again
|
||||
self.assertEqual([d.laser for d in rewarn], [1])
|
||||
|
||||
def test_from_session_uses_session_targets(self) -> None:
|
||||
session = LaserVariationSession(
|
||||
variation_type="CHANGE_CURRENT_LD1",
|
||||
target_temp1=30.0, target_temp2=31.0, tolerance_c=0.03,
|
||||
)
|
||||
checker = LaserTemperatureChecker.from_session(session)
|
||||
warned = checker.process(self._reading(t1=30.1, t2=31.0))
|
||||
self.assertEqual([d.laser for d in warned], [1])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -7,7 +7,9 @@ from unittest import mock
|
||||
from PyQt6.QtWidgets import QApplication, QComboBox, QDoubleSpinBox, QStackedWidget
|
||||
|
||||
from python_app.gui.antenna_geometry_editor import AntennaGeometryEditor
|
||||
from python_app.gui.controllers.radar_settings_controller import RadarSettingsController
|
||||
from python_app.gui.controllers.sections.processing_section import build_processing_group
|
||||
from python_app.gui.controllers.sections.radar_section import build_radar_group
|
||||
from python_app.models.gui_profile_schema import GuiStateModel
|
||||
from python_app.models.dataset_model import SweepCollection
|
||||
from python_app.models.run_config_schema import RunConfigModel
|
||||
@@ -33,6 +35,26 @@ class LiveSettingsRegressionTest(unittest.TestCase):
|
||||
editor = widget(section, f"_{mode}_{bound}_freq_mhz", QDoubleSpinBox)
|
||||
self.assertEqual((editor.minimum(), editor.maximum(), editor.value()), (1, 9500, expected))
|
||||
|
||||
def test_rph_processing_limits_use_band_instead_of_hidden_vna_fields(self) -> None:
|
||||
config, gui = RunConfigModel(), GuiStateModel()
|
||||
config.radar.model = "rph_radar"
|
||||
config.radar.sweep.start_hz, config.radar.sweep.stop_hz = 1e6, 2e6
|
||||
config.radar.rph_radar.band.start_hz, config.radar.rph_radar.band.stop_hz = 2.1e9, 5.5e9
|
||||
gui.processing.bscan.start_freq_mhz, gui.processing.bscan.stop_freq_mhz = 2200, 5400
|
||||
radar, processing = build_radar_group(config), build_processing_group(config, gui)
|
||||
for group in (radar.group, processing.group):
|
||||
self.addCleanup(group.deleteLater)
|
||||
controller = RadarSettingsController(radar, mock.Mock(), processing, lambda: config, mock.Mock(),
|
||||
reset_preprocess=lambda: None, live_changed=lambda: None)
|
||||
for stage in ("initial", "resync"):
|
||||
if stage == "resync":
|
||||
controller.sync_frequency_limits()
|
||||
with self.subTest(stage=stage):
|
||||
for editor in processing.frequency_controls:
|
||||
self.assertEqual((editor.minimum(), editor.maximum()), (2100, 5500))
|
||||
self.assertEqual(widget(processing, "_bscan_start_freq_mhz", QDoubleSpinBox).value(), 2200)
|
||||
self.assertEqual(widget(processing, "_bscan_stop_freq_mhz", QDoubleSpinBox).value(), 5400)
|
||||
|
||||
def test_failed_live_write_restores_web_batch_and_desktop_changes(self) -> None:
|
||||
window = isolated_window(self.addCleanup)
|
||||
window._pipeline.timer.stop()
|
||||
|
||||
@@ -11,12 +11,12 @@ from python_app.models.run_config_schema import RunConfigModel
|
||||
from python_app.workflows.neutral_preprocess import build_neutral_s21_sets, supports_neutral_preprocess_sets
|
||||
|
||||
|
||||
def _kamil_config(*, points: int = 5) -> RunConfigModel:
|
||||
def _rph_config(*, points: int = 5) -> RunConfigModel:
|
||||
return RunConfigModel.from_dict(
|
||||
{
|
||||
"radar": {
|
||||
"model": "kamil_adc",
|
||||
"kamil_adc": {
|
||||
"model": "rph_radar",
|
||||
"rph_radar": {
|
||||
"band": {"start_hz": 2_100_000_000.0, "stop_hz": 5_500_000_000.0, "points": points},
|
||||
},
|
||||
},
|
||||
@@ -26,9 +26,9 @@ def _kamil_config(*, points: int = 5) -> RunConfigModel:
|
||||
)
|
||||
|
||||
|
||||
class KamilAdcNeutralPreprocessTest(unittest.TestCase):
|
||||
class RphRadarNeutralPreprocessTest(unittest.TestCase):
|
||||
def test_builds_passthrough_s21_sets_on_band_grid(self) -> None:
|
||||
calibration, reference = build_neutral_s21_sets(_kamil_config(points=5))
|
||||
calibration, reference = build_neutral_s21_sets(_rph_config(points=5))
|
||||
|
||||
expected_frequency = np.linspace(2_100_000_000.0, 5_500_000_000.0, 5).astype(np.float32)
|
||||
self.assertEqual(len(calibration.traces), 2)
|
||||
@@ -46,22 +46,19 @@ class KamilAdcNeutralPreprocessTest(unittest.TestCase):
|
||||
|
||||
def test_grid_matches_processor_grid(self) -> None:
|
||||
"""The neutral axis must be byte-identical to the acquisition grid."""
|
||||
from python_app.hardware_full.kamil_adc import (
|
||||
KamilAdcProcessingParams,
|
||||
KamilAdcSweepProcessor,
|
||||
)
|
||||
from python_app.hardware_full.rph_radar.processing import RphRadarSweepProcessor
|
||||
|
||||
config = _kamil_config(points=17)
|
||||
processor = KamilAdcSweepProcessor(KamilAdcProcessingParams.from_kamil_model(config.radar.kamil_adc))
|
||||
config = _rph_config(points=17)
|
||||
processor = RphRadarSweepProcessor(config.radar.rph_radar)
|
||||
calibration, _reference = build_neutral_s21_sets(config)
|
||||
np.testing.assert_array_equal(calibration.traces[0].frequency_hz, processor.grid_hz)
|
||||
|
||||
def test_rejects_degenerate_band(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "points must be >= 2"):
|
||||
build_neutral_s21_sets(_kamil_config(points=1))
|
||||
with self.assertRaisesRegex(ValueError, "band.points must be"):
|
||||
build_neutral_s21_sets(_rph_config(points=1))
|
||||
|
||||
def test_dtypes_are_float32_and_complex64(self) -> None:
|
||||
calibration, _reference = build_neutral_s21_sets(_kamil_config())
|
||||
calibration, _reference = build_neutral_s21_sets(_rph_config())
|
||||
trace = calibration.traces[0]
|
||||
self.assertEqual(trace.frequency_hz.dtype, np.float32)
|
||||
self.assertEqual(trace.s21.dtype, np.complex64)
|
||||
@@ -69,7 +66,7 @@ class KamilAdcNeutralPreprocessTest(unittest.TestCase):
|
||||
def test_calibration_s21_is_a_nonzero_divisor(self) -> None:
|
||||
# The preprocessor divides measured/calibration, so calibration S21
|
||||
# must never be zero — that is the whole point of the '1+0j neutral' contract.
|
||||
calibration, _reference = build_neutral_s21_sets(_kamil_config())
|
||||
calibration, _reference = build_neutral_s21_sets(_rph_config())
|
||||
for trace in calibration.traces:
|
||||
self.assertTrue(bool(np.all(trace.s21 != 0)))
|
||||
|
||||
|
||||
@@ -141,9 +141,9 @@ class RadarModelCommandTest(unittest.TestCase):
|
||||
cmd = self.supervisor._acquisition_command(self._config_with_model("librevna_multi"))
|
||||
self.assertEqual(cmd[:3], [sys.executable, "-m", "python_app.scripts.matrix_raw_producer"])
|
||||
|
||||
def test_kamil_producer_for_kamil_adc(self) -> None:
|
||||
cmd = self.supervisor._acquisition_command(self._config_with_model("kamil_adc"))
|
||||
self.assertEqual(cmd[:3], [sys.executable, "-m", "python_app.scripts.kamil_adc_raw_producer"])
|
||||
def test_rph_producer_for_rph_radar(self) -> None:
|
||||
cmd = self.supervisor._acquisition_command(self._config_with_model("rph_radar"))
|
||||
self.assertEqual(cmd[:3], [sys.executable, "-m", "python_app.scripts.rph_radar_raw_producer"])
|
||||
|
||||
|
||||
def test_single_and_multi_use_python_in_both_modes(self) -> None:
|
||||
@@ -270,19 +270,19 @@ class SupervisorLifecycleTest(unittest.TestCase):
|
||||
group_kill.assert_not_called()
|
||||
kill.assert_called_once_with(123, signal.SIGTERM)
|
||||
|
||||
def test_collector_reaper_does_not_match_command_text(self) -> None:
|
||||
def test_stop_does_not_kill_unowned_collector(self) -> None:
|
||||
entries = [Path("/proc/123"), Path("/proc/456")]
|
||||
cmdlines = [
|
||||
b"bash\x00-c\x00echo kamil_adc_collector\x00",
|
||||
b"/project/build/bin/kamil_adc_collector\x00--config\x00config.json\x00",
|
||||
b"bash\x00-c\x00echo rph_radar_collector\x00",
|
||||
b"/project/build/bin/rph_radar_collector\x00--config\x00config.json\x00",
|
||||
]
|
||||
with (
|
||||
mock.patch.object(Path, "iterdir", return_value=entries),
|
||||
mock.patch.object(Path, "read_bytes", side_effect=cmdlines),
|
||||
mock.patch.object(ProcessSupervisor, "_signal_group") as signal_group,
|
||||
):
|
||||
self.supervisor._kill_kamil_collectors()
|
||||
signal_group.assert_called_once_with(456, signal.SIGKILL)
|
||||
self.supervisor.stop_all()
|
||||
signal_group.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Phase-only calibration preserves acquisition settings and unrelated JSON bytes."""
|
||||
|
||||
from contextlib import redirect_stdout
|
||||
from io import StringIO
|
||||
import json
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.models.run_config_schema import RunConfigModel
|
||||
from python_app.scripts import rph_radar_calibrate as calibration
|
||||
|
||||
|
||||
class RphCalibrationTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
logging_setup = patch.object(calibration.logging, "basicConfig")
|
||||
logging_setup.start()
|
||||
self.addCleanup(logging_setup.stop)
|
||||
self.payload = {"radar": {"model": "rph_radar", "driver_mode": "native", "rph_radar": {
|
||||
"executable_path": "/custom/collector", "args": ["duration_ms:123"],
|
||||
"band": {"start_hz": 1.2e9, "stop_hz": 1.8e9, "points": 137},
|
||||
"phase_calibration": {"phase0_rad": 3.456789, "phase1_rad": 9.456789,
|
||||
"freq0_hz": 1e9, "freq1_hz": 2e9}}}, "note": "Пример: phase0_rad"}
|
||||
self.config = RunConfigModel.from_dict(self.payload)
|
||||
self.service = Mock()
|
||||
|
||||
def raw(self, phase: np.ndarray) -> SimpleNamespace:
|
||||
return SimpleNamespace(reference=np.exp(1j * phase), dirty=False)
|
||||
|
||||
def test_apply_preserves_every_other_byte_and_uses_current_settings(self) -> None:
|
||||
for indent, separators in ((None, (",", ":")), (2, (",", ": ")), ("\t", (",", " : "))):
|
||||
original = json.dumps(self.payload, ensure_ascii=False, indent=indent, separators=separators).replace("\n", "\r\n").encode()
|
||||
for apply in (False, True):
|
||||
with self.subTest(indent=indent, apply=apply), TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "profile.json"
|
||||
path.write_bytes(original)
|
||||
self.service.read_raw_sweep.side_effect = [self.raw(np.array([0., 1.]))] + [
|
||||
self.raw(np.linspace(start, start + 2., 20)) for start in (3.10, -3.10, 3.12)]
|
||||
argv = ["calibrate", "--config", str(path), "--sweeps", "3", "--warmup", "1"]
|
||||
with patch.object(calibration, "RphRadarService", return_value=self.service) as factory, \
|
||||
patch("sys.argv", argv + (["--apply"] if apply else [])), redirect_stdout(StringIO()) as output:
|
||||
self.assertEqual(calibration.main(), 0)
|
||||
result = json.loads(output.getvalue())
|
||||
self.assertAlmostEqual(result["phase0_rad"], 3.12)
|
||||
self.assertAlmostEqual(result["phase1_rad"], 5.12)
|
||||
expected = original
|
||||
if apply:
|
||||
expected = original.replace(b"3.456789", json.dumps(result["phase0_rad"]).encode()).replace(
|
||||
b"9.456789", json.dumps(result["phase1_rad"]).encode())
|
||||
self.assertEqual(path.read_bytes(), expected)
|
||||
self.assertEqual(factory.call_args.args[0].to_dict(), self.config.to_dict())
|
||||
self.assertEqual(self.service.read_raw_sweep.call_count, 4)
|
||||
self.service.close.assert_called_once()
|
||||
self.service.reset_mock()
|
||||
|
||||
def test_invalid_reference_never_produces_anchors_and_always_closes(self) -> None:
|
||||
for reference in (np.array([]), np.array([1]), np.array([1, np.nan]),
|
||||
np.array([1, np.inf]), np.array([0, 1]), np.ones(3), np.ones((2, 2))):
|
||||
self.service.read_raw_sweep.return_value = SimpleNamespace(reference=reference, dirty=False)
|
||||
with self.subTest(reference=reference), patch.object(calibration, "RphRadarService", return_value=self.service):
|
||||
with self.assertRaises(ValueError):
|
||||
calibration.calibrate(self.config, sweeps=1, warmup=0)
|
||||
self.service.close.assert_called_once()
|
||||
self.service.reset_mock()
|
||||
|
||||
def test_changed_profile_is_not_overwritten(self) -> None:
|
||||
with TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "profile.json"
|
||||
path.write_text(json.dumps(self.payload))
|
||||
def change_profile(*_args):
|
||||
path.write_bytes(b"external edit")
|
||||
return {"phase0_rad": 3., "phase1_rad": 6.}
|
||||
with patch.object(calibration, "calibrate", side_effect=change_profile), \
|
||||
patch("sys.argv", ["calibrate", "--config", str(path), "--apply"]), redirect_stdout(StringIO()):
|
||||
with self.assertRaisesRegex(RuntimeError, "changed during capture"):
|
||||
calibration.main()
|
||||
self.assertEqual(path.read_bytes(), b"external edit")
|
||||
|
||||
def test_missing_anchors_fail_before_capture(self) -> None:
|
||||
del self.payload["radar"]["rph_radar"]["phase_calibration"]["phase0_rad"]
|
||||
with TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "profile.json"
|
||||
path.write_text(json.dumps(self.payload))
|
||||
with patch.object(calibration, "calibrate") as capture, patch("sys.argv", ["calibrate", "--config", str(path)]):
|
||||
with self.assertRaisesRegex(ValueError, "explicit.*phase0_rad"):
|
||||
calibration.main()
|
||||
capture.assert_not_called()
|
||||
+10
-45
@@ -1,4 +1,4 @@
|
||||
"""Tests for the Kamil ADC reference-channel signal processing.
|
||||
"""Tests for the RadioPhotonic radar (RPH) reference-channel signal processing.
|
||||
|
||||
References are built from an explicit *unwrapped* phase ramp whose first sample
|
||||
lies in ``(-pi, pi]`` and whose steps are below ``pi``, so that
|
||||
@@ -9,15 +9,12 @@ accumulating forward) and lets us reason precisely about the frequency mapping.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.hardware_full.kamil_adc.processing import (
|
||||
KamilAdcProcessingParams,
|
||||
KamilAdcSweepProcessor,
|
||||
)
|
||||
from python_app.hardware_full.rph_radar.processing import RphRadarSweepProcessor
|
||||
from python_app.models.run_config_schema import RphRadarModel, RphRadarBandModel, RphRadarPhaseCalibrationModel
|
||||
|
||||
|
||||
def _reference(phase: np.ndarray, amplitude: float = 1000.0) -> np.ndarray:
|
||||
@@ -25,46 +22,14 @@ def _reference(phase: np.ndarray, amplitude: float = 1000.0) -> np.ndarray:
|
||||
return amplitude * np.exp(1j * np.asarray(phase, dtype=np.float64))
|
||||
|
||||
|
||||
# Calibration shared by the processor tests: phase 0 rad -> 2 GHz, phase 100 rad
|
||||
# -> 4 GHz (slope 2e7 Hz/rad). Band [2.5, 3.5] GHz corresponds to phase [25, 75].
|
||||
_CALIBRATION = KamilAdcProcessingParams(
|
||||
phase0_rad=0.0,
|
||||
freq0_hz=2_000_000_000.0,
|
||||
phase1_rad=100.0,
|
||||
freq1_hz=4_000_000_000.0,
|
||||
band_start_hz=2_500_000_000.0,
|
||||
band_stop_hz=3_500_000_000.0,
|
||||
band_points=11,
|
||||
)
|
||||
|
||||
|
||||
class ProcessingParamsTest(unittest.TestCase):
|
||||
def _params(self, **overrides: float) -> KamilAdcProcessingParams:
|
||||
return replace(_CALIBRATION, **overrides)
|
||||
|
||||
def test_slope_is_constant_from_anchors(self) -> None:
|
||||
self.assertAlmostEqual(self._params().hz_per_rad, 2.0e7)
|
||||
|
||||
def test_rejects_equal_phase_anchors(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "distinct phases"):
|
||||
self._params(phase0_rad=5.0, phase1_rad=5.0)
|
||||
|
||||
def test_rejects_equal_frequency_anchors(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "distinct frequencies"):
|
||||
self._params(freq0_hz=3.0e9, freq1_hz=3.0e9)
|
||||
|
||||
def test_rejects_inverted_band(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "stop_hz must be greater"):
|
||||
self._params(band_start_hz=4.0e9, band_stop_hz=3.0e9)
|
||||
|
||||
def test_rejects_degenerate_point_count(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "points must be >= 2"):
|
||||
self._params(band_points=1)
|
||||
|
||||
|
||||
class SweepProcessorTest(unittest.TestCase):
|
||||
def _processor(self, **overrides: float) -> KamilAdcSweepProcessor:
|
||||
return KamilAdcSweepProcessor(replace(_CALIBRATION, **overrides))
|
||||
def _processor(self, **phase: float) -> RphRadarSweepProcessor:
|
||||
calibration = {"phase0_rad": 0., "freq0_hz": 2e9, "phase1_rad": 100., "freq1_hz": 4e9}
|
||||
calibration.update(phase)
|
||||
return RphRadarSweepProcessor(RphRadarModel(
|
||||
phase_calibration=RphRadarPhaseCalibrationModel(**calibration),
|
||||
band=RphRadarBandModel(start_hz=2.5e9, stop_hz=3.5e9, points=11),
|
||||
))
|
||||
|
||||
def test_grid_is_fixed_and_identical_across_calls(self) -> None:
|
||||
processor = self._processor()
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Collector packets preserve whole sweeps, source windows and pair identity."""
|
||||
|
||||
import struct
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.hardware_full.rph_radar.protocol import RphRadarStreamParser
|
||||
|
||||
|
||||
def packet(*, source_id=1, cycle_id=1, start=100, end=200, combo=(1, 2), dirty=0, points=None):
|
||||
if points is None:
|
||||
points = [(0xA, 1, 10, -1), (0xA8, 1, 100, 5)]
|
||||
payload = b"".join(struct.pack("<HHhh", *point) for point in points)
|
||||
return struct.pack("<4sIQQQQHHI", b"RPH1", len(payload), source_id, cycle_id,
|
||||
start, end, *combo, dirty) + payload
|
||||
|
||||
|
||||
class RphRadarStreamParserTest(unittest.TestCase):
|
||||
def test_fragmented_packets_keep_source_window_and_identity(self):
|
||||
parser = RphRadarStreamParser()
|
||||
sweeps = []
|
||||
for byte in b"preroll" + packet(source_id=73, cycle_id=9):
|
||||
sweeps.extend(parser.feed(bytes([byte])))
|
||||
self.assertEqual(len(sweeps), 1)
|
||||
sweep = sweeps[0]
|
||||
self.assertEqual((sweep.source_id, sweep.cycle_id, sweep.capture_start_ns, sweep.capture_end_ns),
|
||||
(73, 9, 100, 200))
|
||||
self.assertEqual(sweep.combo, (1, 2))
|
||||
self.assertEqual(sweep.main.tolist(), [10-1j])
|
||||
self.assertEqual(sweep.reference.tolist(), [100+5j])
|
||||
self.assertEqual(sweep.main.dtype, np.complex64)
|
||||
|
||||
def test_whole_packet_loss_does_not_merge_pairs(self):
|
||||
parser = RphRadarStreamParser()
|
||||
first, last = parser.feed(packet(source_id=1) + packet(source_id=3, combo=(2, 3),
|
||||
points=[(0xA, 2, 20, 0), (0xA8, 2, 200, 0)]))
|
||||
self.assertEqual((first.combo, first.steps.tolist()), ((1, 2), [1]))
|
||||
self.assertEqual((last.combo, last.steps.tolist()), ((2, 3), [2]))
|
||||
self.assertEqual(last.source_id, 3)
|
||||
|
||||
def test_aligns_both_channels_and_keeps_only_shared_steps(self):
|
||||
parser = RphRadarStreamParser()
|
||||
(sweep,) = parser.feed(packet(points=[(0xA, 3, 3, 0), (0xA8, 3, 30, 0),
|
||||
(0xA, 1, 1, 0), (0xA8, 1, 10, 0), (0xA, 2, 2, 0)]))
|
||||
self.assertEqual(sweep.steps.tolist(), [1, 3])
|
||||
self.assertEqual(sweep.main.real.tolist(), [1, 3])
|
||||
self.assertEqual(sweep.reference.real.tolist(), [10, 30])
|
||||
|
||||
def test_bad_window_size_flags_and_point_marker_fail_fast(self):
|
||||
for encoded in (packet(start=0), packet(start=200, end=100), packet(dirty=2),
|
||||
packet(source_id=0), packet(cycle_id=0),
|
||||
packet(points=[(0x1A, 1, 1, 1)]),
|
||||
b"RPH1" + struct.pack("<I", 0xFFFFFFFF) + bytes(40)):
|
||||
with self.subTest(encoded=encoded), self.assertRaisesRegex(ValueError, "protocol"):
|
||||
RphRadarStreamParser().feed(encoded)
|
||||
|
||||
def test_reset_discards_partial_packet(self):
|
||||
parser = RphRadarStreamParser()
|
||||
self.assertEqual(parser.feed(packet()[:53]), [])
|
||||
parser.reset()
|
||||
(sweep,) = parser.feed(packet(source_id=2, dirty=1, combo=(65535, 65535)))
|
||||
self.assertEqual(sweep.source_id, 2)
|
||||
self.assertIsNone(sweep.combo)
|
||||
self.assertTrue(sweep.dirty)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,243 @@
|
||||
"""PTY integration, source timing, fresh captures and owned collector lifecycle."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pty
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import tty
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.hardware_full.rph_radar.service import RphRadarService
|
||||
from python_app.hardware_full.rph_radar.tty_reader import RphRadarTtyReader
|
||||
from python_app.hardware_full.rph_radar.protocol import RphRadarStreamParser
|
||||
from python_app.hardware_full.librevna_driver.models import SweepResult
|
||||
from python_app.models.dataset_model import ComboKey
|
||||
from python_app.models.run_config_schema import RunConfigModel
|
||||
from python_app.orchestration.process_supervisor import ProcessSupervisor
|
||||
from python_app.scripts import rph_radar_raw_producer as producer
|
||||
from python_app.tests.test_rph_radar_protocol import packet
|
||||
|
||||
|
||||
def config():
|
||||
return RunConfigModel.from_dict({
|
||||
"radar": {"model": "rph_radar", "driver_mode": "native", "rph_radar": {
|
||||
"executable_path": "/bin/sh", "tty_path": "/tmp/rph-test"}},
|
||||
"switches": {"port1": {"positions": 1}, "port2": {"positions": 1}},
|
||||
"run": {"continuous": False, "combos": [{"input": 0, "output": 0}]},
|
||||
})
|
||||
|
||||
|
||||
class RphRadarTtyReaderTest(unittest.TestCase):
|
||||
def test_pty_delivers_latest_complete_clean_sweeps_by_pair(self):
|
||||
master, slave = pty.openpty()
|
||||
tty.setraw(slave)
|
||||
reader = RphRadarTtyReader(os.ttyname(slave))
|
||||
reader.open()
|
||||
try:
|
||||
os.write(master, packet(source_id=1, combo=(0, 0))
|
||||
+ packet(source_id=2, combo=(0, 1), dirty=1)
|
||||
+ packet(source_id=3, combo=(0, 1)))
|
||||
deadline = time.monotonic() + 1
|
||||
while reader.published_count < 3 and time.monotonic() < deadline:
|
||||
time.sleep(.005)
|
||||
self.assertEqual(reader.read_sweep(timeout_s=1).source_id, 3)
|
||||
self.assertEqual(reader.read_sweep(combo=(0, 0), timeout_s=1).source_id, 1)
|
||||
self.assertEqual(reader.read_sweep(combo=(0, 1), timeout_s=1).source_id, 3)
|
||||
with self.assertRaises(TimeoutError):
|
||||
reader.read_sweep(combo=(0, 1), timeout_s=.01)
|
||||
os.write(master, packet(start=0))
|
||||
with self.assertRaisesRegex(ValueError, "protocol"):
|
||||
reader.read_sweep(timeout_s=1)
|
||||
finally:
|
||||
reader.close()
|
||||
os.close(master)
|
||||
os.close(slave)
|
||||
|
||||
def test_new_source_session_rejects_backlog_despite_adc_clock_drift(self):
|
||||
for settling_ms in (0, 2):
|
||||
with self.subTest(settling_ms=settling_ms):
|
||||
cfg = config()
|
||||
cfg.runtime.settling_ms = settling_ms
|
||||
service = RphRadarService(cfg)
|
||||
# Physically this packet crossed the switch; a +50 ppm ADC clock
|
||||
# after 1000 s puts its timestamp 44 ms past the host switch.
|
||||
stale, fresh = RphRadarStreamParser().feed(
|
||||
packet(source_id=9, start=1_044_000_000, end=1_064_000_000)
|
||||
+ packet(source_id=1, cycle_id=1, start=1_100_000_000, end=1_120_000_000,
|
||||
points=[(0xA, 1, 20, 0), (0xA8, 1, 100, 0)]))
|
||||
old_reader = mock.Mock(read_sweep=mock.Mock(return_value=stale))
|
||||
new_reader = mock.Mock(read_sweep=mock.Mock(return_value=fresh))
|
||||
service._reader = old_reader
|
||||
service._process = mock.Mock(poll=mock.Mock(return_value=None))
|
||||
service._stop_event = threading.Event()
|
||||
processor = mock.Mock(grid_hz=np.array([1.]), _previous_anchor_rad=3.3,
|
||||
process=mock.Mock(side_effect=lambda main, reference: main))
|
||||
service._processor = processor
|
||||
|
||||
def reopen(_service, *, stop_event):
|
||||
old_reader.close.assert_called_once()
|
||||
self.assertIs(stop_event, service._stop_event)
|
||||
service._reader = new_reader
|
||||
service._process = mock.Mock(poll=mock.Mock(return_value=None))
|
||||
|
||||
with mock.patch.object(RphRadarService, "open", autospec=True, side_effect=reopen) as opened, \
|
||||
mock.patch("python_app.hardware_full.rph_radar.service.time.monotonic_ns", return_value=1_000_000_000), \
|
||||
mock.patch("python_app.hardware_full.rph_radar.service.time.sleep") as sleep:
|
||||
with mock.patch.object(RphRadarService, "_stop_process", side_effect=lambda: setattr(service._process, "poll", mock.Mock(return_value=0))):
|
||||
service.prepare_combo(ComboKey(0, 0))
|
||||
result = service.acquire()
|
||||
np.testing.assert_array_equal(result.trace("s21"), [20+0j])
|
||||
service.prepare_combo(ComboKey(0, 0))
|
||||
opened.assert_called_once()
|
||||
self.assertEqual(sleep.call_count, int(settling_ms == 0))
|
||||
self.assertEqual((result.capture_start_ns, result.capture_end_ns), (1_100_000_000, 1_120_000_000))
|
||||
self.assertIs(service._processor, processor)
|
||||
self.assertEqual(processor._previous_anchor_rad, 3.3)
|
||||
old_reader.read_sweep.assert_not_called()
|
||||
|
||||
def test_collector_output_is_captured_in_existing_log(self):
|
||||
import io
|
||||
service = RphRadarService(config())
|
||||
process = mock.Mock(stdout=io.BytesIO(b"ADC disconnected\n"), poll=mock.Mock(return_value=1))
|
||||
with mock.patch("python_app.hardware_full.rph_radar.service.subprocess.Popen", return_value=process) as launch:
|
||||
with self.assertLogs("python_app.hardware_full.rph_radar.service", "INFO") as logs:
|
||||
service._start_process()
|
||||
service.close()
|
||||
self.assertIn("ADC disconnected", " ".join(logs.output))
|
||||
self.assertEqual(launch.call_args.kwargs["stdout"], subprocess.PIPE)
|
||||
|
||||
def test_producer_drains_switch_and_never_mixes_source_cycles(self):
|
||||
for native in (False, True):
|
||||
with self.subTest(collector_driven=native):
|
||||
cfg = config()
|
||||
cfg.input_switch.driver_mode = "native"
|
||||
cfg.output_switch.driver_mode = "native" if native else "mock"
|
||||
cfg.combos = cfg.build_full_combos(1, 2) if native else cfg.combos
|
||||
radar, publisher = mock.Mock(), mock.Mock()
|
||||
windows = iter(((1, 1, 5), (2, 6, 10), (3, 11, 15), (3, 16, 20)) if native
|
||||
else ((1, 11, 20),))
|
||||
|
||||
def acquire(**_kwargs):
|
||||
radar.last_cycle_id, start, end = next(windows)
|
||||
return SweepResult(np.array([1., 2.]), {"s21": np.ones(2)},
|
||||
capture_start_ns=start, capture_end_ns=end)
|
||||
|
||||
radar.acquire.side_effect = acquire
|
||||
with mock.patch.object(producer, "load_producer_config", return_value=(Path("unused"), cfg, threading.Event())), \
|
||||
mock.patch.object(producer, "RphRadarService", return_value=radar), \
|
||||
mock.patch.object(producer, "RawPublisher", return_value=publisher), \
|
||||
mock.patch.object(producer.SwitchService, "from_model"), \
|
||||
mock.patch.object(producer, "_open_radar_with_retry", return_value=True), \
|
||||
mock.patch.object(producer, "select_and_settle", return_value=True):
|
||||
self.assertEqual(producer.main(), 0)
|
||||
if not native:
|
||||
calls = radar.mock_calls
|
||||
self.assertLess(calls.index(mock.call.prepare_combo(ComboKey(0, 0))), calls.index(mock.call.acquire()))
|
||||
publisher.publish.assert_called_once()
|
||||
collection = publisher.publish.call_args.args[0]
|
||||
self.assertEqual(collection.collection_id, 3 if native else 1)
|
||||
self.assertEqual((collection.capture_start_ns, collection.capture_end_ns), (11, 20))
|
||||
self.assertEqual(collection.traces[0].capture_start_ns, 11)
|
||||
|
||||
|
||||
class RphRadarConfigTest(unittest.TestCase):
|
||||
def test_collector_uses_current_settings_without_rereading_profile(self):
|
||||
cfg = config()
|
||||
cfg.runtime.settling_ms = 7
|
||||
service = RphRadarService(cfg, collector_driven=True)
|
||||
payload = next(arg.removeprefix("switching:") for arg in service.command if arg.startswith("switching:"))
|
||||
switching = json.loads(payload)
|
||||
self.assertEqual(switching["settling_ms"], 7)
|
||||
self.assertEqual(switching["combos"], [[0, 0]])
|
||||
self.assertEqual(switching["input"]["gpio_chip"], cfg.input_switch.gpio_chip)
|
||||
self.assertFalse(any(arg.startswith("config:") for arg in service.command))
|
||||
|
||||
def test_config_round_trip_preserves_rph_sections(self) -> None:
|
||||
payload = {
|
||||
"radar": {
|
||||
"model": "rph_radar",
|
||||
"serial": "rph_radar",
|
||||
"driver_mode": "native",
|
||||
"rph_radar": {
|
||||
"project_dir": "",
|
||||
"executable_path": "build/bin/rph_radar_collector",
|
||||
"tty_path": "/tmp/ttyADC_data",
|
||||
"args": ["profile:phase", "do8_freq_ref"],
|
||||
"env": {"ADC_ENV": "1"},
|
||||
"startup_timeout_s": 7.0,
|
||||
"sweep_timeout_s": 8.0,
|
||||
"stop_timeout_s": 3.0,
|
||||
"phase_calibration": {
|
||||
"phase0_rad": 1.5,
|
||||
"freq0_hz": 2_046_000_000.0,
|
||||
"phase1_rad": 301.0,
|
||||
"freq1_hz": 5_612_000_000.0,
|
||||
},
|
||||
"band": {
|
||||
"start_hz": 2_100_000_000.0,
|
||||
"stop_hz": 5_500_000_000.0,
|
||||
"points": 1024,
|
||||
},
|
||||
},
|
||||
"laser_control": {"enabled": True, "port": "/dev/ttyUSB0", "mode": "manual"},
|
||||
"sweep": {"start_hz": 1.0, "stop_hz": 2.0, "points": 2},
|
||||
},
|
||||
"switches": {"port1": {"positions": 1}, "port2": {"positions": 1}},
|
||||
}
|
||||
|
||||
encoded = RunConfigModel.from_dict(payload).to_dict()
|
||||
rph = encoded["radar"]["rph_radar"]
|
||||
|
||||
self.assertEqual(encoded["radar"]["model"], "rph_radar")
|
||||
self.assertEqual(rph["executable_path"], "build/bin/rph_radar_collector")
|
||||
self.assertEqual(rph["args"], ["profile:phase", "do8_freq_ref"])
|
||||
self.assertEqual(rph["phase_calibration"]["phase0_rad"], 1.5)
|
||||
self.assertEqual(rph["phase_calibration"]["freq1_hz"], 5_612_000_000.0)
|
||||
self.assertEqual(rph["band"], {"start_hz": 2_100_000_000.0, "stop_hz": 5_500_000_000.0, "points": 1024})
|
||||
|
||||
def test_unreaped_collector_blocks_prepare_and_retry_before_touching_tty(self):
|
||||
service = RphRadarService(config())
|
||||
process = mock.Mock(spec=subprocess.Popen, pid=2_000_000_000)
|
||||
process.poll.return_value = None
|
||||
process.wait.side_effect = subprocess.TimeoutExpired("rph_radar_collector", timeout=.5)
|
||||
service._process = process
|
||||
with mock.patch("python_app.hardware_full.rph_radar.service.os.killpg"):
|
||||
with self.assertRaisesRegex(RuntimeError, "did not stop"):
|
||||
service.prepare_combo(ComboKey(0, 0))
|
||||
service.close() # Producer retry closes again; still exception-safe.
|
||||
with mock.patch("python_app.hardware_full.rph_radar.service._prepare_tty_path_for_collector") as unlink, \
|
||||
mock.patch.object(RphRadarService, "_start_process") as start, \
|
||||
mock.patch.object(RphRadarService, "_wait_for_tty"), \
|
||||
mock.patch("python_app.hardware_full.rph_radar.service.RphRadarTtyReader"):
|
||||
with self.assertRaisesRegex(RuntimeError, "did not stop"):
|
||||
service.open()
|
||||
unlink.assert_not_called()
|
||||
start.assert_not_called()
|
||||
self.assertIs(service._process, process)
|
||||
process.poll.return_value = 0
|
||||
service.open() # Recovery becomes possible only after actual exit.
|
||||
start.assert_called_once()
|
||||
service.close()
|
||||
|
||||
def test_supervisor_selects_rph_radar_producer(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config_path = Path(tmp_dir) / "run_config.json"
|
||||
config_path.write_text(json.dumps({"radar": {"model": "rph_radar"}}), encoding="utf-8")
|
||||
command = ProcessSupervisor(Path("/repo"))._acquisition_command(config_path)
|
||||
|
||||
self.assertEqual(
|
||||
command,
|
||||
[sys.executable, "-m", "python_app.scripts.rph_radar_raw_producer", "--config", str(config_path)],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -99,6 +99,17 @@ class StrictNumericTypingTest(unittest.TestCase):
|
||||
class StructuralTypingTest(unittest.TestCase):
|
||||
"""A present-but-wrong-shaped section fails loudly instead of being dropped."""
|
||||
|
||||
def test_old_adc_model_is_rejected_without_alias(self) -> None:
|
||||
payload = _valid_payload()
|
||||
payload["radar"]["model"] = "kamil_adc"
|
||||
with self.assertRaisesRegex(ValueError, "rph_radar"):
|
||||
RunConfigModel.from_dict(payload)
|
||||
|
||||
def test_inactive_old_adc_section_from_saved_profiles_is_ignored(self) -> None:
|
||||
payload = _valid_payload()
|
||||
payload["radar"]["kamil_adc"] = {"tty_path": "/tmp/old"}
|
||||
self.assertEqual(RunConfigModel.from_dict(payload).radar.model, payload["radar"]["model"])
|
||||
|
||||
def test_root_must_be_an_object(self) -> None:
|
||||
for payload in (None, [], 'bad', 42):
|
||||
with self.subTest(payload=payload), self.assertRaisesRegex(ValueError, 'root'):
|
||||
@@ -112,6 +123,19 @@ class StructuralTypingTest(unittest.TestCase):
|
||||
|
||||
|
||||
class AcquisitionValidationTest(unittest.TestCase):
|
||||
def test_rph_key_normalizes_active_laser_mode(self) -> None:
|
||||
keys = []
|
||||
for mode in ("variation", " Variation ", "VARIATION"):
|
||||
config = self.config("rph_radar")
|
||||
laser = config.radar.laser_control
|
||||
laser.enabled, laser.mode = True, mode
|
||||
original = config.radar_key()
|
||||
keys.append(original)
|
||||
laser.variation.max_value += 1
|
||||
with self.subTest(mode=mode):
|
||||
self.assertNotEqual(config.radar_key(), original)
|
||||
self.assertEqual(len(set(keys)), 1)
|
||||
|
||||
def test_default_combo_expansion_is_bounded_before_allocation(self) -> None:
|
||||
payload = _valid_payload()
|
||||
payload["switches"]["port1"]["positions"] = 65536
|
||||
@@ -149,9 +173,31 @@ class AcquisitionValidationTest(unittest.TestCase):
|
||||
ring.slot_size_bytes = original
|
||||
validate_acquisition_config(config)
|
||||
|
||||
def test_kamil_requires_native_mode(self) -> None:
|
||||
def test_radar_key_separates_switch_matrices(self) -> None:
|
||||
stock, per_point = self.config("librevna_multi"), self.config("librevna_multi")
|
||||
per_point.radar.multi_device.stimulus_switch_positions = 2
|
||||
self.assertTrue(stock.radar_key().endswith("_out1_in1_stim1"))
|
||||
self.assertNotEqual(stock.radar_key(), per_point.radar_key())
|
||||
|
||||
def test_rph_requires_native_mode(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "native"):
|
||||
validate_acquisition_config(self.config("kamil_adc"))
|
||||
validate_acquisition_config(self.config("rph_radar"))
|
||||
|
||||
def test_adc_phase_band_and_timeouts_fail_preflight(self) -> None:
|
||||
for section, field, value in (
|
||||
("phase_calibration", "phase1_rad", 0.0),
|
||||
("phase_calibration", "freq1_hz", 2_046_000_000.0),
|
||||
("phase_calibration", "phase0_rad", float("nan")),
|
||||
("band", "stop_hz", 2_100_000_000.0),
|
||||
("band", "start_hz", -1.0), ("band", "points", 1),
|
||||
("", "sweep_timeout_s", 0.0), ("", "startup_timeout_s", float("inf")),
|
||||
):
|
||||
config = self.config("rph_radar")
|
||||
config.radar.driver_mode = "native"
|
||||
adc = config.radar.rph_radar
|
||||
setattr(getattr(adc, section) if section else adc, field, value)
|
||||
with self.subTest(section=section, field=field), self.assertRaises(ValueError):
|
||||
validate_acquisition_config(config)
|
||||
|
||||
def test_matrix_validates_physical_switch_instead_of_virtual_axis(self) -> None:
|
||||
config = self.config("librevna_multi")
|
||||
|
||||
@@ -75,6 +75,37 @@ class RemoveLastHistoryTest(unittest.TestCase):
|
||||
|
||||
|
||||
class RuntimeSignatureTest(unittest.TestCase):
|
||||
def test_reference_clock_changes_multi_device_history_only(self) -> None:
|
||||
for model in ("librevna_multi", "librevna", "rph_radar"):
|
||||
config = RunConfigModel()
|
||||
config.radar.model = model
|
||||
before = build_run_history_signature(config)
|
||||
config.radar.multi_device.force_external_reference = not config.radar.multi_device.force_external_reference
|
||||
with self.subTest(model=model):
|
||||
self.assertEqual(before != build_run_history_signature(config), model == "librevna_multi")
|
||||
|
||||
def test_adc_key_and_history_follow_measurement_not_launch_paths(self) -> None:
|
||||
original = RunConfigModel()
|
||||
original.radar.model, original.radar.driver_mode = "rph_radar", "native"
|
||||
for section, field, value, changes_identity in (
|
||||
("rph_radar.phase_calibration", "phase1_rad", 301.0, True),
|
||||
("rph_radar.band", "points", 1024, True),
|
||||
("rph_radar.band", "start_hz", 2.2e9, True),
|
||||
("rph_radar", "executable_path", "/new/collector", False),
|
||||
("rph_radar", "tty_path", "/new/tty", False),
|
||||
("sweep", "if_bandwidth_hz", 999.0, False),
|
||||
("", "serial", "unused ADC serial", False),
|
||||
):
|
||||
changed = original.clone()
|
||||
target = changed.radar
|
||||
for part in section.split(".") if section else ():
|
||||
target = getattr(target, part)
|
||||
setattr(target, field, value)
|
||||
with self.subTest(section=section, field=field):
|
||||
self.assertEqual(original.radar_key() != changed.radar_key(), changes_identity)
|
||||
self.assertEqual(build_run_history_signature(original) != build_run_history_signature(changed),
|
||||
changes_identity)
|
||||
|
||||
def test_every_ring_endpoint_setting_requires_restart(self) -> None:
|
||||
original = RunConfigModel()
|
||||
signature = build_processor_run_signature(original)
|
||||
|
||||
@@ -76,8 +76,8 @@ class RadarConfigPrefixTest(unittest.TestCase):
|
||||
|
||||
def test_adc_model_reports_adc_point_count(self) -> None:
|
||||
self.assertEqual(
|
||||
radar_config_filename_prefix("kamil_adc", 1e9, 6e9, 1, 0.0),
|
||||
"kamil_adc_1-6GHz_adc_0dBm",
|
||||
radar_config_filename_prefix("rph_radar", 1e9, 6e9, 1, 0.0),
|
||||
"rph_radar_1-6GHz_adc_0dBm",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Applying these sets leaves measurements unchanged and satisfies pipeline asset
|
||||
validation before physical calibration is available. The shortcut is enabled for
|
||||
verified frequency axes: Kamil ADC, multi-device LibreVNA and single-device mock.
|
||||
verified frequency axes: RadioPhotonic radar (RPH), multi-device LibreVNA and single-device mock.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -12,10 +12,7 @@ import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.hardware_full.kamil_adc import (
|
||||
KamilAdcProcessingParams,
|
||||
KamilAdcSweepProcessor,
|
||||
)
|
||||
from python_app.hardware_full.rph_radar.processing import RphRadarSweepProcessor
|
||||
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
|
||||
from python_app.models.run_config_schema import ComboModel, RunConfigModel
|
||||
|
||||
@@ -24,19 +21,16 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def supports_neutral_preprocess_sets(config: RunConfigModel) -> bool:
|
||||
"""Enable the shortcut only when the emitted frequency grid is verified."""
|
||||
return bool(config.is_kamil_adc or config.is_multi_device or (
|
||||
return bool(config.is_rph_radar or config.is_multi_device or (
|
||||
config.radar.model == RunConfigModel.LIBREVNA_MODEL and config.radar.driver_mode == "mock"
|
||||
))
|
||||
|
||||
|
||||
def neutral_frequency_grid_hz(config: RunConfigModel) -> np.ndarray:
|
||||
"""Derive the neutral axis; the preprocessor verifies it against measured data."""
|
||||
if config.is_kamil_adc:
|
||||
if config.is_rph_radar:
|
||||
# Single source of truth for the axis: the same grid the processor emits.
|
||||
processor = KamilAdcSweepProcessor(
|
||||
KamilAdcProcessingParams.from_kamil_model(config.radar.kamil_adc)
|
||||
)
|
||||
return processor.grid_hz
|
||||
return RphRadarSweepProcessor(config.radar.rph_radar).grid_hz
|
||||
|
||||
points = int(config.radar.sweep.points)
|
||||
if points < 1:
|
||||
|
||||
+2
-24
@@ -8,33 +8,12 @@
|
||||
"206930965532",
|
||||
"206930A15532"
|
||||
],
|
||||
"force_external_reference": true,
|
||||
"force_external_reference": false,
|
||||
"recovery_attempts": 3,
|
||||
"output_switch_positions": 1,
|
||||
"input_switch_positions": 1,
|
||||
"stimulus_switch_positions": 2
|
||||
},
|
||||
"kamil_adc": {
|
||||
"project_dir": "",
|
||||
"executable_path": "",
|
||||
"tty_path": "",
|
||||
"args": [],
|
||||
"env": {},
|
||||
"startup_timeout_s": 5.0,
|
||||
"sweep_timeout_s": 5.0,
|
||||
"stop_timeout_s": 2.0,
|
||||
"phase_calibration": {
|
||||
"phase0_rad": 0.0,
|
||||
"freq0_hz": 2046000000.0,
|
||||
"phase1_rad": 300.0,
|
||||
"freq1_hz": 5612000000.0
|
||||
},
|
||||
"band": {
|
||||
"start_hz": 2100000000.0,
|
||||
"stop_hz": 5500000000.0,
|
||||
"points": 2048
|
||||
}
|
||||
},
|
||||
"laser_control": {
|
||||
"enabled": false,
|
||||
"port": "",
|
||||
@@ -59,8 +38,7 @@
|
||||
"max_value": 35.0,
|
||||
"step": 0.1,
|
||||
"time_step": 20,
|
||||
"delay_time": 3,
|
||||
"temp_tolerance_c": 0.03
|
||||
"delay_time": 3
|
||||
}
|
||||
},
|
||||
"sweep": {
|
||||
|
||||
@@ -8,16 +8,6 @@
|
||||
"force_external_reference": false,
|
||||
"recovery_attempts": 3
|
||||
},
|
||||
"kamil_adc": {
|
||||
"project_dir": "",
|
||||
"executable_path": "",
|
||||
"tty_path": "",
|
||||
"args": [],
|
||||
"env": {},
|
||||
"startup_timeout_s": 5.0,
|
||||
"sweep_timeout_s": 5.0,
|
||||
"stop_timeout_s": 2.0
|
||||
},
|
||||
"laser_control": {
|
||||
"enabled": false,
|
||||
"port": "",
|
||||
|
||||
@@ -8,19 +8,9 @@
|
||||
"20A1307D5532",
|
||||
"2072306C5532"
|
||||
],
|
||||
"force_external_reference": true,
|
||||
"force_external_reference": false,
|
||||
"recovery_attempts": 3
|
||||
},
|
||||
"kamil_adc": {
|
||||
"project_dir": "",
|
||||
"executable_path": "",
|
||||
"tty_path": "",
|
||||
"args": [],
|
||||
"env": {},
|
||||
"startup_timeout_s": 5.0,
|
||||
"sweep_timeout_s": 5.0,
|
||||
"stop_timeout_s": 2.0
|
||||
},
|
||||
"laser_control": {
|
||||
"enabled": false,
|
||||
"port": "",
|
||||
|
||||
@@ -8,19 +8,9 @@
|
||||
"206930965532",
|
||||
"206930A15532"
|
||||
],
|
||||
"force_external_reference": true,
|
||||
"force_external_reference": false,
|
||||
"recovery_attempts": 3
|
||||
},
|
||||
"kamil_adc": {
|
||||
"project_dir": "",
|
||||
"executable_path": "",
|
||||
"tty_path": "",
|
||||
"args": [],
|
||||
"env": {},
|
||||
"startup_timeout_s": 5.0,
|
||||
"sweep_timeout_s": 5.0,
|
||||
"stop_timeout_s": 2.0
|
||||
},
|
||||
"laser_control": {
|
||||
"enabled": false,
|
||||
"port": "",
|
||||
|
||||
@@ -1,40 +1,19 @@
|
||||
{
|
||||
"radar": {
|
||||
"model": "librevna_multi",
|
||||
"serial": "206830985532",
|
||||
"serial": "209E307A5532",
|
||||
"driver_mode": "native",
|
||||
"multi_device": {
|
||||
"slave_serials": [
|
||||
"206930965532",
|
||||
"206930A15532"
|
||||
"20A1307D5532",
|
||||
"2072306C5532"
|
||||
],
|
||||
"force_external_reference": true,
|
||||
"force_external_reference": false,
|
||||
"recovery_attempts": 3,
|
||||
"output_switch_positions": 1,
|
||||
"input_switch_positions": 1,
|
||||
"stimulus_switch_positions": 2
|
||||
},
|
||||
"kamil_adc": {
|
||||
"project_dir": "",
|
||||
"executable_path": "",
|
||||
"tty_path": "",
|
||||
"args": [],
|
||||
"env": {},
|
||||
"startup_timeout_s": 5.0,
|
||||
"sweep_timeout_s": 5.0,
|
||||
"stop_timeout_s": 2.0,
|
||||
"phase_calibration": {
|
||||
"phase0_rad": 0.0,
|
||||
"freq0_hz": 2046000000.0,
|
||||
"phase1_rad": 300.0,
|
||||
"freq1_hz": 5612000000.0
|
||||
},
|
||||
"band": {
|
||||
"start_hz": 2100000000.0,
|
||||
"stop_hz": 5500000000.0,
|
||||
"points": 2048
|
||||
}
|
||||
},
|
||||
"laser_control": {
|
||||
"enabled": false,
|
||||
"port": "",
|
||||
@@ -59,17 +38,16 @@
|
||||
"max_value": 35.0,
|
||||
"step": 0.1,
|
||||
"time_step": 20,
|
||||
"delay_time": 3,
|
||||
"temp_tolerance_c": 0.03
|
||||
"delay_time": 3
|
||||
}
|
||||
},
|
||||
"sweep": {
|
||||
"start_hz": 1000000.0,
|
||||
"start_hz": 500000000.0,
|
||||
"stop_hz": 6000000000.0,
|
||||
"points": 1031,
|
||||
"if_bandwidth_hz": 50000.0,
|
||||
"stimulus_power_dbm": -10.0,
|
||||
"dwell_us": 2,
|
||||
"points": 201
|
||||
"stimulus_power_dbm": -3.0
|
||||
}
|
||||
},
|
||||
"switches": {
|
||||
@@ -190,11 +168,15 @@
|
||||
"preprocess": {
|
||||
"s21": {
|
||||
"calibration": {
|
||||
"set_name": "smoke_cal3",
|
||||
"set_name": "20260930_low_krot",
|
||||
"bundle_path": ""
|
||||
},
|
||||
"reference": {
|
||||
"set_name": "smoke_cal3",
|
||||
"set_name": "new_cable_nul",
|
||||
"bundle_path": ""
|
||||
},
|
||||
"object_calibration": {
|
||||
"set_name": "",
|
||||
"bundle_path": ""
|
||||
}
|
||||
},
|
||||
@@ -210,51 +192,51 @@
|
||||
"tx_geometry": [
|
||||
{
|
||||
"output_pos": 0,
|
||||
"x_m": 0.905,
|
||||
"x_m": -0.89,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"output_pos": 1,
|
||||
"x_m": -0.905,
|
||||
"x_m": 0.89,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"output_pos": 2,
|
||||
"x_m": 0.905,
|
||||
"y_m": 0.0,
|
||||
"x_m": -0.32,
|
||||
"y_m": 0.46,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"output_pos": 3,
|
||||
"x_m": -0.905,
|
||||
"y_m": 0.0,
|
||||
"x_m": 0.32,
|
||||
"y_m": 0.46,
|
||||
"z_m": 0.0
|
||||
}
|
||||
],
|
||||
"rx_geometry": [
|
||||
{
|
||||
"input_pos": 0,
|
||||
"x_m": -0.18,
|
||||
"x_m": 0.23,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"input_pos": 1,
|
||||
"x_m": 0.485,
|
||||
"y_m": 0.0,
|
||||
"x_m": 0.53,
|
||||
"y_m": 0.3,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"input_pos": 2,
|
||||
"x_m": -0.49,
|
||||
"y_m": 0.0,
|
||||
"x_m": -0.53,
|
||||
"y_m": 0.145,
|
||||
"z_m": 0.0
|
||||
},
|
||||
{
|
||||
"input_pos": 3,
|
||||
"x_m": 0.185,
|
||||
"x_m": -0.23,
|
||||
"y_m": 0.0,
|
||||
"z_m": 0.0
|
||||
}
|
||||
@@ -286,5 +268,117 @@
|
||||
"capacity": 50,
|
||||
"slot_size_bytes": 2097152
|
||||
}
|
||||
},
|
||||
"gui": {
|
||||
"version": 1,
|
||||
"switches": {
|
||||
"combo_mode": "text",
|
||||
"combos_text": "0:0,1:0,2:0,3:0,0:1,1:1,2:1,3:1,0:2,1:2,2:2,3:2,0:3,1:3,2:3,3:3",
|
||||
"single_input": "0",
|
||||
"single_output": "0"
|
||||
},
|
||||
"processing": {
|
||||
"selected_mode": "sar3d",
|
||||
"pass_through": {
|
||||
"show_magnitude": true,
|
||||
"show_phase": true,
|
||||
"unwrap_phase": false,
|
||||
"combo_filter": "",
|
||||
"fixed_y_enabled": false,
|
||||
"y_min_db": -100.0,
|
||||
"y_max_db": 0.0
|
||||
},
|
||||
"bscan": {
|
||||
"axis": "abs",
|
||||
"cut_m": 0.824,
|
||||
"max_depth_m": 1.0,
|
||||
"gain": 1.0,
|
||||
"start_freq_mhz": 2000.0,
|
||||
"stop_freq_mhz": 6000.0,
|
||||
"subtract_mean_ascan": false,
|
||||
"history_window_scans": 50
|
||||
},
|
||||
"gpr": {
|
||||
"input_positions": "0,1,2,3",
|
||||
"output_positions": "0,1,2,3",
|
||||
"min_depth_m": 3.0,
|
||||
"max_depth_m": 8.0,
|
||||
"range_comp_power": 0.1,
|
||||
"object_min_frac": 0.7,
|
||||
"score_mode": "combined",
|
||||
"motion_mode": "int_minus",
|
||||
"look_angle_deg": 10.0,
|
||||
"direction_sign": 1.0,
|
||||
"speed_m_s": 0.0,
|
||||
"ignore_socket_speed_enabled": true,
|
||||
"max_detected_objects_to_draw": 7,
|
||||
"draw_top_m_objects": 2,
|
||||
"object_approach_min_frames": 3,
|
||||
"start_freq_mhz": 2000.0,
|
||||
"stop_freq_mhz": 6000.0,
|
||||
"background_subtract_enabled": false,
|
||||
"background_mean_count": 10,
|
||||
"remove_sidelobe_objects_enabled": true,
|
||||
"imaging_plane_y_m": 0.0,
|
||||
"render_mode": "heatmap",
|
||||
"visible_x_min_m": -2.91,
|
||||
"visible_x_max_m": 2.91,
|
||||
"visible_z_min_m": 0.0,
|
||||
"visible_z_max_m": 8.0
|
||||
},
|
||||
"legacy_gpr": {
|
||||
"mode": "point",
|
||||
"input_positions": "0,1,2,3",
|
||||
"output_positions": "0,1,2,3",
|
||||
"min_depth_m": 2.0,
|
||||
"max_depth_m": 14.0,
|
||||
"comp_power": 0.2,
|
||||
"start_freq_mhz": 3000.0,
|
||||
"stop_freq_mhz": 6000.0,
|
||||
"speed_m_s": 0.0,
|
||||
"ignore_socket_speed_enabled": false,
|
||||
"look_angle_deg": 0.0,
|
||||
"apply_freq_phase_correction": true,
|
||||
"reference_mode": "frame_center",
|
||||
"snr_thresh": 4.5,
|
||||
"snr_comp_max": 25.0,
|
||||
"background_subtract_enabled": true,
|
||||
"background_mean_count": 10,
|
||||
"render_mode": "heatmap",
|
||||
"min_visible_pair_count": 1,
|
||||
"visible_x_min_m": -2.91,
|
||||
"visible_x_max_m": 2.91,
|
||||
"visible_z_min_m": 0.0,
|
||||
"visible_z_max_m": 14.0
|
||||
},
|
||||
"sar3d": {
|
||||
"input_positions": "",
|
||||
"output_positions": "",
|
||||
"min_depth_m": 2.5,
|
||||
"max_depth_m": 8.0,
|
||||
"start_freq_mhz": 2000.0,
|
||||
"stop_freq_mhz": 6000.0,
|
||||
"speed_m_s": 0.0,
|
||||
"ignore_socket_speed_enabled": false,
|
||||
"look_angle_deg": 10.0,
|
||||
"frame_radius": 0,
|
||||
"voxel_m": 0.02,
|
||||
"bp_ground_depth_m": 1.25,
|
||||
"comp_range_power": 0.3,
|
||||
"use_compensation": true,
|
||||
"normalize_each_frame": true
|
||||
}
|
||||
},
|
||||
"data_actions": {
|
||||
"save_path": "",
|
||||
"save_name": "snapshot_manual",
|
||||
"record_count": 100
|
||||
},
|
||||
"preprocess_dialog": {
|
||||
"set_name": "new_cable_nul",
|
||||
"radar_config_dir": "",
|
||||
"use_all_radar_configs": false,
|
||||
"median_sweep_count": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,33 +8,12 @@
|
||||
"206930965532",
|
||||
"206930A15532"
|
||||
],
|
||||
"force_external_reference": true,
|
||||
"force_external_reference": false,
|
||||
"recovery_attempts": 3,
|
||||
"output_switch_positions": 1,
|
||||
"input_switch_positions": 1,
|
||||
"stimulus_switch_positions": 2
|
||||
},
|
||||
"kamil_adc": {
|
||||
"project_dir": "",
|
||||
"executable_path": "",
|
||||
"tty_path": "",
|
||||
"args": [],
|
||||
"env": {},
|
||||
"startup_timeout_s": 5.0,
|
||||
"sweep_timeout_s": 5.0,
|
||||
"stop_timeout_s": 2.0,
|
||||
"phase_calibration": {
|
||||
"phase0_rad": 0.0,
|
||||
"freq0_hz": 2046000000.0,
|
||||
"phase1_rad": 300.0,
|
||||
"freq1_hz": 5612000000.0
|
||||
},
|
||||
"band": {
|
||||
"start_hz": 2100000000.0,
|
||||
"stop_hz": 5500000000.0,
|
||||
"points": 2048
|
||||
}
|
||||
},
|
||||
"laser_control": {
|
||||
"enabled": false,
|
||||
"port": "",
|
||||
@@ -59,8 +38,7 @@
|
||||
"max_value": 35.0,
|
||||
"step": 0.1,
|
||||
"time_step": 20,
|
||||
"delay_time": 3,
|
||||
"temp_tolerance_c": 0.03
|
||||
"delay_time": 3
|
||||
}
|
||||
},
|
||||
"sweep": {
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"radar": {
|
||||
"model": "kamil_adc",
|
||||
"model": "rph_radar",
|
||||
"serial": "kamil_adc",
|
||||
"driver_mode": "native",
|
||||
"multi_device": {
|
||||
@@ -8,9 +8,9 @@
|
||||
"force_external_reference": false,
|
||||
"recovery_attempts": 3
|
||||
},
|
||||
"kamil_adc": {
|
||||
"rph_radar": {
|
||||
"project_dir": "",
|
||||
"executable_path": "build/bin/kamil_adc_collector",
|
||||
"executable_path": "build/bin/rph_radar_collector",
|
||||
"tty_path": "/tmp/ttyADC_data",
|
||||
"args": [
|
||||
"profile:phase",
|
||||
@@ -8,16 +8,6 @@
|
||||
"force_external_reference": false,
|
||||
"recovery_attempts": 3
|
||||
},
|
||||
"kamil_adc": {
|
||||
"project_dir": "",
|
||||
"executable_path": "",
|
||||
"tty_path": "",
|
||||
"args": [],
|
||||
"env": {},
|
||||
"startup_timeout_s": 5.0,
|
||||
"sweep_timeout_s": 5.0,
|
||||
"stop_timeout_s": 2.0
|
||||
},
|
||||
"laser_control": {
|
||||
"enabled": false,
|
||||
"port": "",
|
||||
|
||||
@@ -8,16 +8,6 @@
|
||||
"force_external_reference": false,
|
||||
"recovery_attempts": 3
|
||||
},
|
||||
"kamil_adc": {
|
||||
"project_dir": "",
|
||||
"executable_path": "",
|
||||
"tty_path": "",
|
||||
"args": [],
|
||||
"env": {},
|
||||
"startup_timeout_s": 5.0,
|
||||
"sweep_timeout_s": 5.0,
|
||||
"stop_timeout_s": 2.0
|
||||
},
|
||||
"laser_control": {
|
||||
"enabled": false,
|
||||
"port": "",
|
||||
|
||||
@@ -286,11 +286,11 @@ EOF
|
||||
build_cpp_binaries() {
|
||||
local jobs
|
||||
jobs="${BUILD_JOBS:-$(nproc)}"
|
||||
# The Kamil ADC collector is an extra, device-specific binary built only for
|
||||
# The RadioPhotonic radar (RPH) collector is an extra, device-specific binary built only for
|
||||
# that model; all models share the core pipeline binaries (`all`).
|
||||
local targets="all"
|
||||
if [[ "${RADAR_MODEL}" == "kamil_adc" ]]; then
|
||||
targets="all kamil_adc_collector"
|
||||
if [[ "${RADAR_MODEL}" == "rph_radar" ]]; then
|
||||
targets="all rph_radar_collector"
|
||||
fi
|
||||
if make -C "${PROJECT_ROOT}" -q ${targets} >/dev/null 2>&1; then
|
||||
echo "[start.sh] C++ binaries are up to date; skipping build."
|
||||
@@ -308,22 +308,14 @@ cleanup_known_shm() {
|
||||
/dev/shm/radar_preprocessed \
|
||||
/dev/shm/radar_preprocessed_tap \
|
||||
/dev/shm/radar_results \
|
||||
/dev/shm/radar_raw_kamil_adc \
|
||||
/dev/shm/radar_raw_tap_kamil_adc \
|
||||
/dev/shm/radar_preprocessed_kamil_adc \
|
||||
/dev/shm/radar_preprocessed_tap_kamil_adc \
|
||||
/dev/shm/radar_results_kamil_adc \
|
||||
/dev/shm/radar_raw_rph_radar \
|
||||
/dev/shm/radar_raw_tap_rph_radar \
|
||||
/dev/shm/radar_preprocessed_rph_radar \
|
||||
/dev/shm/radar_preprocessed_tap_rph_radar \
|
||||
/dev/shm/radar_results_rph_radar \
|
||||
|| true
|
||||
}
|
||||
|
||||
kill_stale_adc_collector() {
|
||||
# Exact executable matching: an argument mentioning the collector is not a process to kill.
|
||||
PYTHONPATH="${PROJECT_ROOT}${PYTHONPATH:+:${PYTHONPATH}}" "${PYTHON_CMD}" -c '
|
||||
from python_app.orchestration.process_supervisor import ProcessSupervisor
|
||||
ProcessSupervisor._kill_kamil_collectors()
|
||||
'
|
||||
}
|
||||
|
||||
run_gui() {
|
||||
if [[ -n "${PROFILE_PATH}" ]]; then
|
||||
export RADAR_SYSTEM_PROFILE="${PROFILE_PATH}"
|
||||
@@ -393,7 +385,7 @@ import sys
|
||||
from pathlib import Path
|
||||
from python_app.orchestration.producer_selection import acquisition_command
|
||||
command = acquisition_command(Path(sys.argv[1]))
|
||||
print("kamil_adc_collector" if "python_app.scripts.kamil_adc_raw_producer" in command else "")
|
||||
print("rph_radar_collector" if "python_app.scripts.rph_radar_raw_producer" in command else "")
|
||||
' "${PROFILE_PATH:-${PROJECT_ROOT}/run_config.json}")"
|
||||
required_bins="${required_bins} ${acquisition_binary}"
|
||||
for bin in ${required_bins}; do
|
||||
@@ -431,7 +423,7 @@ main() {
|
||||
# LibreVNA uses Python/libusb; Python/C++ SHM needs libatomic.
|
||||
if ((HEADLESS == 0)); then
|
||||
ensure_system_dependencies
|
||||
if [[ "${RADAR_MODEL}" != "kamil_adc" ]]; then ensure_usb_access_rules; fi
|
||||
if [[ "${RADAR_MODEL}" != "rph_radar" ]]; then ensure_usb_access_rules; fi
|
||||
fi
|
||||
ensure_python_dependencies
|
||||
|
||||
@@ -456,9 +448,6 @@ main() {
|
||||
if ((CLEAN_SHM == 1)); then
|
||||
cleanup_known_shm
|
||||
fi
|
||||
# Clear any ADC collector wedged by a previous run before we launch a new one.
|
||||
kill_stale_adc_collector
|
||||
|
||||
if ((PRODUCER_ONLY == 1)); then
|
||||
run_producer_only
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user