Rename kamil_adc to rph_radar and fix its data path

This commit is contained in:
Ayzen
2026-10-06 18:15:18 +03:00
parent aef549ea76
commit a185e61495
94 changed files with 2851 additions and 9485 deletions
+39 -32
View File
@@ -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 $@)
+46 -14
View File
@@ -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
@@ -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
View File
@@ -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
+3 -2
View File
@@ -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)
+2 -10
View File
@@ -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",
]
-129
View File
@@ -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()
@@ -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
@@ -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,
)
@@ -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}")
+38 -218
View File
@@ -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),
}
+32 -67
View File
@@ -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:
+25 -3
View File
@@ -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).
+2 -41
View File
@@ -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}")
-78
View File
@@ -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())
-149
View File
@@ -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())
+114
View File
@@ -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())
@@ -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
+1 -1
View File
@@ -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),
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -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()
+6 -9
View File
@@ -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
-191
View File
@@ -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()
-312
View File
@@ -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()
+44 -84
View File
@@ -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()
+12 -15
View File
@@ -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()
@@ -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()
+243
View File
@@ -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()
+48 -2
View File
@@ -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")
+31
View File
@@ -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)
+2 -2
View File
@@ -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",
)
+5 -11
View File
@@ -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
View File
@@ -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": {
@@ -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": "",
-10
View File
@@ -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": "",
+10 -21
View File
@@ -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