Rename kamil_adc to rph_radar and fix its data path
This commit is contained in:
@@ -63,64 +63,60 @@ PROCESSOR_SOURCES := \
|
|||||||
data_acq_and_processing/processing/data_processor/src/main.cpp \
|
data_acq_and_processing/processing/data_processor/src/main.cpp \
|
||||||
$(LOCATOR_SOURCES)
|
$(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
|
# proprietary libx502api/libe502api at runtime via dlopen, so it builds anywhere
|
||||||
# from the vendored headers alone and only needs those libraries present at run
|
# 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.
|
# time. Built on demand (not part of `all`) for radar.model == rph_radar.
|
||||||
KAMIL_COLLECTOR_DIR := data_acq_and_processing/kamil_adc_collector
|
RPH_COLLECTOR_DIR := data_acq_and_processing/rph_radar_collector
|
||||||
# The collector now also drives the RF switches itself (switch-aware mode), so it
|
# The collector drives H7992 switches from the validated JSON supplied by Python.
|
||||||
# uses the common H7992 driver and run_config loader, which
|
RPH_COLLECTOR_INCLUDES := \
|
||||||
# pull in the common config/ipc/locator headers and the vendored nlohmann/json.
|
-I$(RPH_COLLECTOR_DIR)/include \
|
||||||
KAMIL_COLLECTOR_INCLUDES := \
|
-I$(RPH_COLLECTOR_DIR)/vendor/lcard \
|
||||||
-I$(KAMIL_COLLECTOR_DIR)/include \
|
|
||||||
-I$(KAMIL_COLLECTOR_DIR)/vendor/lcard \
|
|
||||||
-Idata_acq_and_processing/common_cpp/ipc/include \
|
-Idata_acq_and_processing/common_cpp/ipc/include \
|
||||||
-Idata_acq_and_processing/common_cpp/config/include \
|
-Idata_acq_and_processing/common_cpp/config/include \
|
||||||
-Idata_acq_and_processing/common_cpp/hardware/include \
|
-Idata_acq_and_processing/common_cpp/hardware/include \
|
||||||
-Idata_acq_and_processing/processing/locator/include \
|
-Idata_acq_and_processing/processing/locator/include \
|
||||||
-Idata_acq_and_processing/third_party
|
-Idata_acq_and_processing/third_party
|
||||||
KAMIL_COLLECTOR_LDFLAGS := -pthread -ldl -lutil
|
RPH_COLLECTOR_LDFLAGS := -pthread -ldl -lutil
|
||||||
# Collector sources, including the shared drivers/config loader it reuses. These
|
# Collector sources, including the shared H7992 driver. These
|
||||||
# are compiled into a private build/kamil/ tree (see rule below) so they never
|
# are compiled into a private build/rph/ tree (see rule below) so they never
|
||||||
# collide with pipeline objects, which use different include paths.
|
# collide with pipeline objects, which use different include paths.
|
||||||
KAMIL_COLLECTOR_SOURCES := \
|
RPH_COLLECTOR_SOURCES := \
|
||||||
$(KAMIL_COLLECTOR_DIR)/src/main.cpp \
|
$(RPH_COLLECTOR_DIR)/src/main.cpp \
|
||||||
$(KAMIL_COLLECTOR_DIR)/src/tty_protocol_writer.cpp \
|
$(RPH_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 \
|
|
||||||
data_acq_and_processing/common_cpp/hardware/src/h7992_minimal_driver.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))
|
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))
|
DATA_PROCESSOR_OBJS := $(addprefix $(BUILD_DIR)/,$(COMMON_SOURCES:.cpp=.o) $(PROCESSOR_SOURCES:.cpp=.o))
|
||||||
KAMIL_BUILD_DIR := $(BUILD_DIR)/kamil
|
RPH_BUILD_DIR := $(BUILD_DIR)/rph
|
||||||
KAMIL_COLLECTOR_OBJS := $(addprefix $(KAMIL_BUILD_DIR)/,$(KAMIL_COLLECTOR_SOURCES:.cpp=.o))
|
RPH_COLLECTOR_OBJS := $(addprefix $(RPH_BUILD_DIR)/,$(RPH_COLLECTOR_SOURCES:.cpp=.o))
|
||||||
DEPFILES := $(sort $(PREPROCESSOR_OBJS:.o=.d) $(DATA_PROCESSOR_OBJS:.o=.d) $(KAMIL_COLLECTOR_OBJS:.o=.d))
|
DEPFILES := $(sort $(PREPROCESSOR_OBJS:.o=.d) $(DATA_PROCESSOR_OBJS:.o=.d) $(RPH_COLLECTOR_OBJS:.o=.d))
|
||||||
|
|
||||||
NATIVE_TEST_SOURCES := $(COMMON_SOURCES) $(LOCATOR_SOURCES) \
|
NATIVE_TEST_SOURCES := $(COMMON_SOURCES) $(LOCATOR_SOURCES) \
|
||||||
data_acq_and_processing/tests/native_regressions.cpp \
|
data_acq_and_processing/tests/native_regressions.cpp \
|
||||||
data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp \
|
data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp \
|
||||||
$(filter-out %/main.cpp,$(PREPROC_SOURCES)) \
|
$(filter-out %/main.cpp,$(PREPROC_SOURCES)) \
|
||||||
$(KAMIL_COLLECTOR_DIR)/src/capture_file_writer.cpp \
|
$(RPH_COLLECTOR_DIR)/src/tty_protocol_writer.cpp \
|
||||||
$(KAMIL_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_BUILD_DIR := $(BUILD_DIR)/native_tests
|
||||||
NATIVE_TEST_OBJS := $(addprefix $(NATIVE_BUILD_DIR)/,$(NATIVE_TEST_SOURCES:.cpp=.o))
|
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_CXXFLAGS := $(CXXFLAGS) -O1 -g $(if $(NATIVE_SANITIZERS),-fsanitize=$(NATIVE_SANITIZERS) -fno-omit-frame-pointer)
|
||||||
NATIVE_FLAGS_STAMP := $(NATIVE_BUILD_DIR)/compiler.flags
|
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))
|
$(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 := \
|
TARGETS := \
|
||||||
$(BIN_DIR)/data_preprocessor \
|
$(BIN_DIR)/data_preprocessor \
|
||||||
$(BIN_DIR)/data_processor
|
$(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)
|
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
|
$(NATIVE_BUILD_DIR)/native_regressions run_config_examples/run_config_simulator.example.json
|
||||||
|
|
||||||
SAR3D_TEST_SOURCES := data_acq_and_processing/tests/sar3d_fixture.cpp \
|
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)
|
$(NATIVE_BUILD_DIR)/%.o: %.cpp $(NATIVE_FLAGS_STAMP)
|
||||||
@mkdir -p $(dir $@)
|
@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)
|
$(BIN_DIR)/data_preprocessor: $(PREPROCESSOR_OBJS)
|
||||||
@mkdir -p $(BIN_DIR)
|
@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)
|
$(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.
|
# 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 $@)
|
@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)
|
@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
|
$(BUILD_DIR)/%.o: %.cpp
|
||||||
@mkdir -p $(dir $@)
|
@mkdir -p $(dir $@)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ skips overwritten frames and continues from the retained tail:
|
|||||||
|
|
||||||
1. **Acquisition** — Python producers configure the radar, acquire frequency sweeps,
|
1. **Acquisition** — Python producers configure the radar, acquire frequency sweeps,
|
||||||
and publish raw S21 traces. Both single and multi-device LibreVNA use
|
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,
|
2. **Preprocessing** — `data_preprocessor` applies through calibration,
|
||||||
reference subtraction, optional frequency-domain notch filtering, and an
|
reference subtraction, optional frequency-domain notch filtering, and an
|
||||||
optional object-based MIMO correction of S21.
|
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** — single LibreVNA.
|
||||||
- **librevna_multi** — synchronized multi-device LibreVNA.
|
- **librevna_multi** — synchronized multi-device LibreVNA.
|
||||||
- **kamil_adc** — [Kamil ADC collector](data_acq_and_processing/kamil_adc_collector/README.md),
|
- **rph_radar** — [RadioPhotonic radar (RPH) collector](data_acq_and_processing/rph_radar_collector/README.md),
|
||||||
with optional laser control and [temperature monitoring](python_app/hardware_full/laser_control/monitoring/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.
|
- **mock / simulator** — driver-less mode for development without hardware.
|
||||||
|
|
||||||
## Processing modes
|
## Processing modes
|
||||||
@@ -86,7 +89,7 @@ so an older in-flight result cannot overwrite new geometry.
|
|||||||
|
|
||||||
| Path | Contents |
|
| 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/`. |
|
| `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. |
|
| `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`). |
|
| `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
|
## Requirements
|
||||||
|
|
||||||
- Linux (POSIX shared memory; GPIO on Raspberry Pi). The deployment target is a
|
- 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
|
**Raspberry Pi 5**.
|
||||||
specific (GPIO12/13/18/19, `dtoverlay=pwm-2chan`).
|
|
||||||
- A C++20 compiler and `make`; Eigen and nlohmann/json are vendored.
|
- A C++20 compiler and `make`; Eigen and nlohmann/json are vendored.
|
||||||
- `libusb-1.0` runtime and `libatomic.so.1` (`libatomic1` on Debian/Ubuntu).
|
- `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
|
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 --upgrade pip
|
||||||
.venv/bin/python -m pip install -r requirements.txt
|
.venv/bin/python -m pip install -r requirements.txt
|
||||||
make # data_preprocessor and data_processor in build/bin/
|
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
|
`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
|
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
|
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
|
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.
|
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
|
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
|
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
|
leaves the NumPy dataset's `config_profile.json` intact. A streaming write failure
|
||||||
keeps all collections already published; only the incomplete collection is discarded.
|
keeps all collections already published; only the incomplete collection is discarded.
|
||||||
The offline snapshot converter uses this same exporter. Legacy snapshots without
|
Legacy snapshots without capture windows retain repeated IDs through one-to-one
|
||||||
capture windows retain repeated IDs through one-to-one time/index matching;
|
time/index matching; processing completion timestamps are not compared with
|
||||||
processing completion timestamps are not compared with acquisition timestamps.
|
acquisition timestamps.
|
||||||
|
|
||||||
### Calibration and reference sets
|
### 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
|
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
|
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
|
change of model, serials, sweep parameters or multi-device switch positions changes
|
||||||
calibration/reference sets; select or capture matching sets before **Start**.
|
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
|
## 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,
|
The tests cover failed writes and settings commits, recordings during manual Save,
|
||||||
repeated Start/Stop/Close, web-action cancellation, malformed IPC frames, hardware
|
repeated Start/Stop/Close, web-action cancellation, malformed IPC frames, hardware
|
||||||
resource cleanup with mock failures, and an isolated deployment update. Native tests
|
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
|
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.
|
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
|
// * 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
|
// receive on all Rx channels in parallel. A run of 8 traces is just 2
|
||||||
// Tx-events; all (tx_k, *) pairs share one timestamp.
|
// 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;
|
// measure each pair separately. A run of 8 traces is 8 separate sweeps;
|
||||||
// every pair has its own timestamp.
|
// every pair has its own timestamp.
|
||||||
// Both cases reduce to: there are N events in the frame, each event lasts
|
// Both cases reduce to: there are N events in the frame, each event lasts
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# RadioPhotonic radar (RPH) collector
|
||||||
|
|
||||||
|
Application-owned acquisition binary for the **L-Card E-502** ADC. It captures the
|
||||||
|
dual-channel main + reference IQ stream and publishes complete `RPH1` sweeps over
|
||||||
|
a PTY. `RphRadarService` in `python_app/hardware_full/rph_radar` reads and
|
||||||
|
processes that stream for `python_app.scripts.rph_radar_raw_producer`.
|
||||||
|
|
||||||
|
This is the data *source* for `radar.model == "rph_radar"`, paired with the Python
|
||||||
|
`rph_radar_raw_producer`. LibreVNA acquisition uses Python drivers.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
| Path | Contents |
|
||||||
|
|------|----------|
|
||||||
|
| `src/` | Collector implementation (`main.cpp`) and the PTY writer. |
|
||||||
|
| `include/` | PTY header and `switch_sequencer.h`. |
|
||||||
|
| `vendor/lcard/` | L-Card X502/E502 SDK headers (`x502api.h`, `e502api.h`, `lcard_pstdint.h`), vendored verbatim so the collector builds with no external include path. |
|
||||||
|
|
||||||
|
In switch-aware mode, `SwitchSequencer` drives the H7992 switches at actual
|
||||||
|
DI_SYN2 sweep ends. Duration limits and Stop never advance the switches. Every
|
||||||
|
physical combination change discards the remaining decoded ADC/DIN block and
|
||||||
|
recreates the source session after `max(1 ms, run.settling_ms)` has elapsed.
|
||||||
|
Timestamps never decide whether a sweep belongs to the new combination. The
|
||||||
|
collector completes a cycle only after a full, clean sweep for each combination.
|
||||||
|
An unchanged combination keeps the existing session.
|
||||||
|
|
||||||
|
A restart stops cyclic DOUT with `WAIT_DONE`, stops and disables the streams,
|
||||||
|
restores DO2 high / DO1 and DO8 low, then closes and frees the SDK descriptor.
|
||||||
|
After settling, a new descriptor is created, opened and configured. The collector
|
||||||
|
reloads the DO1/DO8 pattern, waits for `OutCycleSetup(WAIT_DONE)`, and starts
|
||||||
|
ADC/DIN/DOUT together. Pending samples and phase accumulators are cleared;
|
||||||
|
source sweep and cycle IDs remain continuous. The initial high DI_SYN2 portion
|
||||||
|
is dirty: acquisition waits for a complete rising-to-falling sweep. This can
|
||||||
|
skip a variable number of physical sweeps while reopening; no fixed skip count
|
||||||
|
or firmware-pause estimate is used. Hardware validation must measure reopen
|
||||||
|
latency, USB resource release and multi-combination throughput.
|
||||||
|
|
||||||
|
The application uses the internal ADC clock. ADC and DIN start together,
|
||||||
|
continuously; DI_SYN2 gates sweeps in software. Source times are the monotonic
|
||||||
|
clock immediately before `StreamsStart` plus the ADC sample index divided by the
|
||||||
|
configured sample rate. These are source-clock estimates, not hardware timestamps;
|
||||||
|
startup latency shifts the initial epoch earlier. USB and PTY queueing do not
|
||||||
|
shift timestamps to the time Python reads a sweep. An estimated source timestamp
|
||||||
|
ahead of receipt stops acquisition instead of publishing a future capture window.
|
||||||
|
ADC/host drift can trigger this guard during long single-combination runs; its
|
||||||
|
bound and the accuracy of capture windows need measurement on hardware. Drift
|
||||||
|
cannot make data from an old SDK session clean after a physical switch.
|
||||||
|
|
||||||
|
The collector uses the common H7992 driver, with its own include set and
|
||||||
|
`build/rph/` object tree.
|
||||||
|
|
||||||
|
## PTY format
|
||||||
|
|
||||||
|
Each packet is a 48-byte little-endian header (`<4sIQQQQHHI`) followed by the
|
||||||
|
payload: `RPH1`, payload bytes (u32), source sweep ID, cycle ID, start/end ns
|
||||||
|
(four u64), input/output positions (two u16), flags (u32; bit 0 means dirty).
|
||||||
|
Without collector switching both positions are `0xffff`, and cycle equals sweep
|
||||||
|
ID. Payload points remain four u16 words: marker (`0x000a` main, `0x00a8`
|
||||||
|
reference), step index, signed I, signed Q. There are no boundary or combo frames.
|
||||||
|
The queue drops complete sweeps on overflow; a packet already being written is
|
||||||
|
never truncated. Maximum payload is 1,048,544 bytes. Python matches complete
|
||||||
|
cycles. Python-controlled switching closes the previous collector and PTY reader,
|
||||||
|
then starts a new collector after settling; phase-processing state is preserved.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make rph_radar_collector # -> build/bin/rph_radar_collector
|
||||||
|
```
|
||||||
|
|
||||||
|
It is **not** part of `make all`; `start.sh` builds it automatically when the
|
||||||
|
active config selects `radar.model == "rph_radar"`, unless `--skip-build` is set.
|
||||||
|
The Linux build uses the repository's common sources and vendored headers and
|
||||||
|
links with `-pthread -ldl -lutil`; no installed L-Card SDK is needed to build it.
|
||||||
|
|
||||||
|
## Runtime
|
||||||
|
|
||||||
|
The collector loads the proprietary `libx502api.so` / `libe502api.so` at run time
|
||||||
|
via `dlopen`, so those libraries must be discoverable by the dynamic loader.
|
||||||
|
The RPH radar service prepends `~/.local/lib` (their default install location)
|
||||||
|
unless the config pins `LD_LIBRARY_PATH` in `radar.rph_radar.env`.
|
||||||
|
|
||||||
|
There is one acquisition path: USB, differential ADC channels 2/3, maximum
|
||||||
|
internal-clock rate, rising/falling DI_SYN2 gating, DO1 pair subtraction and DO8
|
||||||
|
reference overlay. `radar.rph_radar.args` sets ADC range, internal reference,
|
||||||
|
maximum sweep duration and DO8 period. The example's fixed-path options remain
|
||||||
|
accepted as guards; incompatible modes and unused diagnostic options are rejected.
|
||||||
|
The service appends `tty:<path>` from `radar.rph_radar.tty_path`.
|
||||||
|
|
||||||
|
When both RF switches use `driver_mode: "native"`, Python passes
|
||||||
|
`switching:<JSON>` with the already decoded input/output switch settings, combos
|
||||||
|
and settling time. The collector reads no profile file. Otherwise, Python selects
|
||||||
|
the switches and starts a new collector after settling when the combination
|
||||||
|
changes. Calibration captures also use this Python-controlled path.
|
||||||
|
|
||||||
|
The launching service owns the collector process and forwards its output to the
|
||||||
|
application log. On Linux, parent death sends SIGKILL to the collector; normal
|
||||||
|
Stop drains and closes the service's own child. No global process-name kill is
|
||||||
|
used.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <chrono>
|
||||||
|
#include <memory>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "shared_types.hpp"
|
||||||
|
#include "h7992_minimal_driver.hpp"
|
||||||
|
|
||||||
|
namespace radar::rph {
|
||||||
|
|
||||||
|
// Every real GPIO transition invalidates the ADC session. No timestamp can
|
||||||
|
// declare queued data clean; only a newly started source session can do that.
|
||||||
|
class SwitchSequencer {
|
||||||
|
public:
|
||||||
|
struct Settings {
|
||||||
|
/// Combinations to cycle through, one sweep each, in order. Empty disables
|
||||||
|
/// the sequencer entirely (the collector then streams a single passthrough
|
||||||
|
/// channel exactly as before).
|
||||||
|
std::vector<radar::ipc::ComboKey> combos{};
|
||||||
|
/// RF settling allowance after both GPIO writes complete.
|
||||||
|
double settle_ms = 1.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
SwitchSequencer(
|
||||||
|
Settings settings,
|
||||||
|
std::unique_ptr<drivers::H7992MinimalDriver> input_switch,
|
||||||
|
std::unique_ptr<drivers::H7992MinimalDriver> output_switch
|
||||||
|
)
|
||||||
|
: settings_(std::move(settings)),
|
||||||
|
input_switch_(std::move(input_switch)),
|
||||||
|
output_switch_(std::move(output_switch)) {}
|
||||||
|
|
||||||
|
[[nodiscard]] auto enabled() const -> bool { return !settings_.combos.empty(); }
|
||||||
|
|
||||||
|
/// Open both switch drivers and park at the first combination.
|
||||||
|
void open() {
|
||||||
|
if (!enabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
output_switch_->open();
|
||||||
|
input_switch_->open();
|
||||||
|
index_ = 0;
|
||||||
|
applied_ = settings_.combos.front();
|
||||||
|
apply(applied_);
|
||||||
|
upcoming_dirty_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Combination the in-progress / upcoming sweep is captured under.
|
||||||
|
[[nodiscard]] auto current_combo() const -> radar::ipc::ComboKey {
|
||||||
|
return settings_.combos[index_];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the in-progress / upcoming sweep is expected to straddle a switch
|
||||||
|
/// transition and must be dropped by the consumer.
|
||||||
|
[[nodiscard]] auto current_dirty() const -> bool { return upcoming_dirty_; }
|
||||||
|
|
||||||
|
[[nodiscard]] auto cycle_id() const -> std::uint64_t { return cycle_id_; }
|
||||||
|
|
||||||
|
[[nodiscard]] auto settled_after_ns() const -> std::uint64_t { return settled_after_ns_; }
|
||||||
|
void session_started() { upcoming_dirty_ = false; }
|
||||||
|
|
||||||
|
bool on_sweep_end(bool complete = true) {
|
||||||
|
if (!enabled() || !complete || upcoming_dirty_) { return false; }
|
||||||
|
index_ = (index_ + 1U) % settings_.combos.size();
|
||||||
|
if (index_ == 0) { ++cycle_id_; }
|
||||||
|
const radar::ipc::ComboKey next = settings_.combos[index_];
|
||||||
|
if (!(next == applied_)) {
|
||||||
|
apply(next);
|
||||||
|
applied_ = next;
|
||||||
|
upcoming_dirty_ = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
void apply(const radar::ipc::ComboKey& combo) {
|
||||||
|
// Set the transmitter before the receiver.
|
||||||
|
output_switch_->switch_to(combo.output_pos);
|
||||||
|
input_switch_->switch_to(combo.input_pos);
|
||||||
|
settled_after_ns_ = static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::nanoseconds>(
|
||||||
|
std::chrono::steady_clock::now().time_since_epoch()).count()) +
|
||||||
|
static_cast<std::uint64_t>(settings_.settle_ms * 1e6);
|
||||||
|
}
|
||||||
|
|
||||||
|
Settings settings_;
|
||||||
|
std::unique_ptr<drivers::H7992MinimalDriver> input_switch_;
|
||||||
|
std::unique_ptr<drivers::H7992MinimalDriver> output_switch_;
|
||||||
|
std::size_t index_ = 0;
|
||||||
|
radar::ipc::ComboKey applied_{};
|
||||||
|
bool upcoming_dirty_ = false;
|
||||||
|
std::uint64_t settled_after_ns_ = 0, cycle_id_ = 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace radar::rph
|
||||||
+7
-4
@@ -4,6 +4,7 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
class TtyProtocolWriter {
|
class TtyProtocolWriter {
|
||||||
public:
|
public:
|
||||||
@@ -21,9 +22,12 @@ public:
|
|||||||
TtyProtocolWriter(TtyProtocolWriter&& other) noexcept = delete;
|
TtyProtocolWriter(TtyProtocolWriter&& other) noexcept = delete;
|
||||||
TtyProtocolWriter& operator=(TtyProtocolWriter&& other) noexcept = delete;
|
TtyProtocolWriter& operator=(TtyProtocolWriter&& other) noexcept = delete;
|
||||||
|
|
||||||
void emit_packet_start(uint16_t marker = 0x000A);
|
struct SweepMetadata {
|
||||||
void emit_step(uint16_t index, int16_t ch1_avg, int16_t ch2_avg);
|
std::uint64_t source_id, cycle_id, capture_start_ns, capture_end_ns;
|
||||||
void enqueue_encoded_frames(const uint16_t* words, std::size_t frame_count);
|
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;
|
StatsSnapshot stats() const;
|
||||||
|
|
||||||
const std::string& path() const;
|
const std::string& path() const;
|
||||||
@@ -31,7 +35,6 @@ public:
|
|||||||
void shutdown();
|
void shutdown();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void enqueue_frame(uint16_t word0, uint16_t word1, uint16_t word2, uint16_t word3);
|
|
||||||
void worker_loop();
|
void worker_loop();
|
||||||
|
|
||||||
std::string path_;
|
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 "channel_bundle_support.hpp"
|
||||||
#include "data_preprocessor.hpp"
|
#include "data_preprocessor.hpp"
|
||||||
#include "locator/tcp_server.hpp"
|
#include "locator/tcp_server.hpp"
|
||||||
@@ -7,6 +6,8 @@
|
|||||||
#include "runtime_wait.hpp"
|
#include "runtime_wait.hpp"
|
||||||
#include "shm_ring.hpp"
|
#include "shm_ring.hpp"
|
||||||
#include "tty_protocol_writer.h"
|
#include "tty_protocol_writer.h"
|
||||||
|
#include "switch_sequencer.h"
|
||||||
|
#include <fcntl.h>
|
||||||
|
|
||||||
#include <sys/mman.h>
|
#include <sys/mman.h>
|
||||||
#include <sys/socket.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) {
|
void test_asset_reload_preserves_data(const Workspace& workspace) {
|
||||||
const auto save = [&](const char* name, float sample) {
|
const auto save = [&](const char* name, float sample) {
|
||||||
return save_bundle(workspace, name, {.traces = {
|
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(); }
|
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) {
|
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);
|
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));
|
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||||
const auto start = std::chrono::steady_clock::now();
|
const auto start = std::chrono::steady_clock::now();
|
||||||
writer.shutdown();
|
writer.shutdown();
|
||||||
@@ -524,9 +558,10 @@ int main(int argc, char** argv) {
|
|||||||
test_acquisition_identity();
|
test_acquisition_identity();
|
||||||
test_interruptible_wait();
|
test_interruptible_wait();
|
||||||
test_cosine_notch_taper();
|
test_cosine_notch_taper();
|
||||||
test_csv_failures(workspace);
|
test_tty_sweep_overflow(workspace);
|
||||||
test_locator_packet_errors();
|
test_switch_backlog();
|
||||||
test_unread_tty_shutdown(workspace);
|
test_unread_tty_shutdown(workspace);
|
||||||
|
test_locator_packet_errors();
|
||||||
std::cout << "native regressions passed (10,000 decoder mutations; 20,000 concurrent ring frames)\n";
|
std::cout << "native regressions passed (10,000 decoder mutations; 20,000 concurrent ring frames)\n";
|
||||||
} catch (const std::exception& error) {
|
} catch (const std::exception& error) {
|
||||||
std::cerr << "native regression failed: " << error.what() << '\n';
|
std::cerr << "native regression failed: " << error.what() << '\n';
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// SDK contract fixture: Stop retains queued input; only a new descriptor is fresh.
|
||||||
|
#include "x502api.h"
|
||||||
|
#include "e502api.h"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <csignal>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <fstream>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
struct Session { unsigned id; bool consumed = false; std::vector<uint32_t> input; };
|
||||||
|
unsigned sessions = 0;
|
||||||
|
void record(const char* name) { std::ofstream(std::getenv("RPH_SDK_LOG"), std::ios::app) << name << '\n'; }
|
||||||
|
Session& session(t_x502_hnd hnd) { return *reinterpret_cast<Session*>(hnd); }
|
||||||
|
void generate(Session& s) {
|
||||||
|
for (unsigned sweep = 0; sweep < 4; ++sweep) {
|
||||||
|
for (unsigned step = 0; step < 2; ++step) {
|
||||||
|
for (unsigned n = 0; n < 8; ++n) {
|
||||||
|
const bool high = n % 4 >= 2;
|
||||||
|
s.input.push_back(high ? 0 : 10000 * s.id);
|
||||||
|
s.input.push_back((1U << 13) | step | (high ? 2 : 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (unsigned n = 0; n < 4; ++n) { s.input.push_back(0); s.input.push_back(0); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
extern "C" int32_t E502_OpenUsb(t_x502_hnd hnd, const char *serial) { record("Open"); return 0; }
|
||||||
|
extern "C" int32_t X502_AsyncOutDig(t_x502_hnd hnd, uint32_t val, uint32_t msk) { return 0; }
|
||||||
|
extern "C" int32_t X502_Close(t_x502_hnd hnd) { record("Close"); return 0; }
|
||||||
|
extern "C" int32_t X502_Configure(t_x502_hnd hnd, uint32_t flags) { record("Configure"); return 0; }
|
||||||
|
extern "C" t_x502_hnd X502_Create(void) { record("Create"); auto* s = new Session{++sessions}; generate(*s); return reinterpret_cast<t_x502_hnd>(s); }
|
||||||
|
extern "C" int32_t X502_Free(t_x502_hnd hnd) { record("Free"); delete &session(hnd); return 0; }
|
||||||
|
extern "C" int32_t X502_GetAdcFreq(t_x502_hnd hnd, double *f_acq, double *f_frame) { *f_acq = 2000000; *f_frame = 1000000; return 0; }
|
||||||
|
extern "C" const char* X502_GetErrorString(int32_t err) { return "SDK contract fixture"; }
|
||||||
|
extern "C" int32_t X502_GetRecvReadyCount(t_x502_hnd hnd, uint32_t *rdy_cnt) { *rdy_cnt = session(hnd).input.size(); return 0; }
|
||||||
|
extern "C" int32_t X502_OutCycleLoadStart(t_x502_hnd hnd, uint32_t size) { record("OutCycleLoadStart"); return 0; }
|
||||||
|
extern "C" int32_t X502_OutCycleSetup(t_x502_hnd hnd, uint32_t flags) { if (flags != X502_OUT_CYCLE_FLAGS_WAIT_DONE) { return -1; } record("OutCycleSetup WAIT_DONE"); return 0; }
|
||||||
|
extern "C" int32_t X502_OutCycleStop(t_x502_hnd hnd, uint32_t flags) { if (flags != X502_OUT_CYCLE_FLAGS_WAIT_DONE) { return -1; } record("OutCycleStop WAIT_DONE"); return 0; }
|
||||||
|
extern "C" int32_t X502_PrepareData(t_x502_hnd hnd, const double* dac1, const double* dac2, const uint32_t* digout, uint32_t size, int32_t flags, uint32_t* out_buf) { record("PrepareData"); std::copy_n(digout, size, out_buf); return 0; }
|
||||||
|
extern "C" int32_t X502_ProcessData(t_x502_hnd hnd, const uint32_t* src, uint32_t size, uint32_t flags, double *adc_data, uint32_t *adc_data_size, uint32_t *din_data, uint32_t *din_data_size) { *adc_data_size = *din_data_size = size / 2;
|
||||||
|
for (unsigned n = 0; n < size / 2; ++n) {
|
||||||
|
adc_data[n] = static_cast<int32_t>(src[2 * n]); din_data[n] = src[2 * n + 1];
|
||||||
|
}
|
||||||
|
return 0; }
|
||||||
|
extern "C" int32_t X502_Recv(t_x502_hnd hnd, uint32_t* buf, uint32_t size, uint32_t tout) { auto& s = session(hnd); usleep(20000);
|
||||||
|
if (s.consumed) { raise(SIGTERM); return 0; }
|
||||||
|
if (size < s.input.size()) { return -1; }
|
||||||
|
std::copy(s.input.begin(), s.input.end(), buf); s.consumed = true;
|
||||||
|
return static_cast<int32_t>(s.input.size()); }
|
||||||
|
extern "C" int32_t X502_Send(t_x502_hnd hnd, const uint32_t* buf, uint32_t size, uint32_t tout) { record("Send"); return size; }
|
||||||
|
extern "C" int32_t X502_SetAdcFreq(t_x502_hnd hnd, double *f_acq, double *f_frame) { *f_acq = 2000000; if (f_frame) { *f_frame = 1000000; } return 0; }
|
||||||
|
extern "C" int32_t X502_SetDigInPullup(t_x502_hnd hnd, uint32_t pullups) { return 0; }
|
||||||
|
extern "C" int32_t X502_SetDinFreq(t_x502_hnd hnd, double *f_din) { return 0; }
|
||||||
|
extern "C" int32_t X502_SetLChannel(t_x502_hnd hnd, uint32_t lch, uint32_t phy_ch, uint32_t mode, uint32_t range, uint32_t avg) { return 0; }
|
||||||
|
extern "C" int32_t X502_SetLChannelCount(t_x502_hnd hnd, uint32_t lch_cnt) { return 0; }
|
||||||
|
extern "C" int32_t X502_SetMode(t_x502_hnd hnd, uint32_t mode) { return 0; }
|
||||||
|
extern "C" int32_t X502_SetOutFreq(t_x502_hnd hnd, double *f_dout) { return 0; }
|
||||||
|
extern "C" int32_t X502_SetRefFreq(t_x502_hnd hnd, uint32_t freq) { return 0; }
|
||||||
|
extern "C" int32_t X502_SetStreamBufSize(t_x502_hnd hnd, uint32_t ch, uint32_t size) { return 0; }
|
||||||
|
extern "C" int32_t X502_SetStreamStep(t_x502_hnd hnd, uint32_t dma_ch, uint32_t step) { return 0; }
|
||||||
|
extern "C" int32_t X502_SetSyncMode(t_x502_hnd hnd, uint32_t sync_mode) { return 0; }
|
||||||
|
extern "C" int32_t X502_SetSyncStartMode(t_x502_hnd hnd, uint32_t sync_start_mode) { return 0; }
|
||||||
|
extern "C" int32_t X502_StreamsDisable(t_x502_hnd hnd, uint32_t streams) { record("StreamsDisable"); return 0; }
|
||||||
|
extern "C" int32_t X502_StreamsEnable(t_x502_hnd hnd, uint32_t streams) { return 0; }
|
||||||
|
extern "C" int32_t X502_StreamsStart(t_x502_hnd hnd) { record("Start"); if (session(hnd).id == 4) { raise(SIGTERM); } return 0; }
|
||||||
|
extern "C" int32_t X502_StreamsStop(t_x502_hnd hnd) { record("StreamsStop"); return 0; }
|
||||||
+1
-1
@@ -181,7 +181,7 @@ else
|
|||||||
"${venv}/bin/python" -m pip install -r "${target}/requirements.txt"
|
"${venv}/bin/python" -m pip install -r "${target}/requirements.txt"
|
||||||
fi
|
fi
|
||||||
say "building the C++ part"
|
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"
|
echo "${archive##*/}" > "${target}/RELEASE"
|
||||||
trap - ERR
|
trap - ERR
|
||||||
|
|||||||
@@ -90,8 +90,9 @@ multi-device: `python_app/hardware_full/librevna_driver/protocol/frame.py`,
|
|||||||
Структура — `Protocol.hpp:186`: `uint32 ExtRefOuputFreq` плюс битовое поле
|
Структура — `Protocol.hpp:186`: `uint32 ExtRefOuputFreq` плюс битовое поле
|
||||||
`AutomaticSwitch` и `UseExternalRef`.
|
`AutomaticSwitch` и `UseExternalRef`.
|
||||||
|
|
||||||
Шлём `output_frequency_hz = 0` и флаг внешней опоры: выход `Ref Out` не
|
Шлём `output_frequency_hz = 0` (выход `Ref Out` не включаем) и `UseExternalRef` из
|
||||||
включаем, а устройству принудительно указываем использовать внешние 10 МГц.
|
`multi_device.force_external_reference` профиля; `AutomaticSwitch` всегда 0. Во всех
|
||||||
|
профилях сейчас `false`, то есть приборы работают от внутренней опоры.
|
||||||
Строит `build_reference_settings_payload` (`protocol.py:48`), отправляет
|
Строит `build_reference_settings_payload` (`protocol.py:48`), отправляет
|
||||||
`controller.py`.
|
`controller.py`.
|
||||||
|
|
||||||
|
|||||||
@@ -31,14 +31,14 @@ _SWEEP_FIELDS = (
|
|||||||
("radar.sweep.power_dbm", "_power_input"),
|
("radar.sweep.power_dbm", "_power_input"),
|
||||||
)
|
)
|
||||||
_ADC_FIELDS = (
|
_ADC_FIELDS = (
|
||||||
("radar.kamil_adc.project_dir", "_adc_project_dir_input"),
|
("radar.rph_radar.project_dir", "_adc_project_dir_input"),
|
||||||
("radar.kamil_adc.executable_path", "_adc_executable_path_input"),
|
("radar.rph_radar.executable_path", "_adc_executable_path_input"),
|
||||||
("radar.kamil_adc.tty_path", "_adc_tty_path_input"),
|
("radar.rph_radar.tty_path", "_adc_tty_path_input"),
|
||||||
("radar.kamil_adc.args", "_adc_args_input"),
|
("radar.rph_radar.args", "_adc_args_input"),
|
||||||
("radar.kamil_adc.env", "_adc_env_input"),
|
("radar.rph_radar.env", "_adc_env_input"),
|
||||||
("radar.kamil_adc.startup_timeout_s", "_adc_startup_timeout_s_input"),
|
("radar.rph_radar.startup_timeout_s", "_adc_startup_timeout_s_input"),
|
||||||
("radar.kamil_adc.sweep_timeout_s", "_adc_sweep_timeout_s_input"),
|
("radar.rph_radar.sweep_timeout_s", "_adc_sweep_timeout_s_input"),
|
||||||
("radar.kamil_adc.stop_timeout_s", "_adc_stop_timeout_s_input"),
|
("radar.rph_radar.stop_timeout_s", "_adc_stop_timeout_s_input"),
|
||||||
("radar.laser_control.enabled", "_optical_enabled_checkbox"),
|
("radar.laser_control.enabled", "_optical_enabled_checkbox"),
|
||||||
("radar.laser_control.port", "_optical_port_input"),
|
("radar.laser_control.port", "_optical_port_input"),
|
||||||
("radar.laser_control.mode", "_optical_mode_combo"),
|
("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.temp2", "_optical_manual_temp2_input"),
|
||||||
("radar.laser_control.manual.current1", "_optical_manual_current1_input"),
|
("radar.laser_control.manual.current1", "_optical_manual_current1_input"),
|
||||||
("radar.laser_control.manual.current2", "_optical_manual_current2_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_temp1", "_optical_static_temp1_input"),
|
||||||
("radar.laser_control.variation.static_temp2", "_optical_static_temp2_input"),
|
("radar.laser_control.variation.static_temp2", "_optical_static_temp2_input"),
|
||||||
("radar.laser_control.variation.static_current1", "_optical_static_current1_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
|
return Path(str(self._scalar("_save_path_input"))).expanduser(), str(self._scalar("_save_name_input")), prefix
|
||||||
|
|
||||||
def _read_radar(self, config: RunConfigModel) -> None:
|
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)
|
owner, name = _model_field(config, path)
|
||||||
original = getattr(owner, name)
|
original = getattr(owner, name)
|
||||||
value = self._scalar(widget_id)
|
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.history import build_processor_run_signature, build_run_history_signature
|
||||||
from python_app.gui.runtime.pipeline_pause import PipelinePause
|
from python_app.gui.runtime.pipeline_pause import PipelinePause
|
||||||
from python_app.gui.settings_defaults import env_enabled
|
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.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.collection_identity import collection_key, index_collections
|
||||||
from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection
|
from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection
|
||||||
@@ -331,7 +331,7 @@ class PipelineController:
|
|||||||
self._prepare_radar(config)
|
self._prepare_radar(config)
|
||||||
logger.info("Radar settings applied: start=%g Hz, stop=%g Hz, points=%s, ifbw=%g Hz, power=%g dBm",
|
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,
|
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)
|
config.radar.sweep.if_bandwidth_hz, config.radar.sweep.power_dbm)
|
||||||
except InterruptedError:
|
except InterruptedError:
|
||||||
self.operation_cancelled()
|
self.operation_cancelled()
|
||||||
@@ -344,9 +344,9 @@ class PipelineController:
|
|||||||
logger.info("Radar pre-configuration skipped (mock mode)")
|
logger.info("Radar pre-configuration skipped (mock mode)")
|
||||||
elif config.is_multi_device:
|
elif config.is_multi_device:
|
||||||
logger.info("Multi-device raw producer will configure all LibreVNA devices")
|
logger.info("Multi-device raw producer will configure all LibreVNA devices")
|
||||||
elif config.is_kamil_adc:
|
elif config.is_rph_radar:
|
||||||
applied = apply_kamil_adc_laser_control(config)
|
applied = apply_rph_radar_laser_control(config)
|
||||||
logger.info("Kamil ADC laser_control %s", "applied via Apply Radar" if applied else "disabled")
|
logger.info("RadioPhotonic radar (RPH) laser_control %s", "applied via Apply Radar" if applied else "disabled")
|
||||||
else:
|
else:
|
||||||
service = create_single_radar_service(config)
|
service = create_single_radar_service(config)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ class ProfileController:
|
|||||||
self.state.gui = profile.gui or default_gui_state_for_config(profile.run_config, self._project_root)
|
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)
|
_remember(self.state, path, self._session, self._log)
|
||||||
config, sweep = profile.run_config, profile.run_config.radar.sweep
|
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(
|
self._log.info(
|
||||||
f"Config profile saved: path={path}, combos={len(config.combos)}, "
|
f"Config profile saved: path={path}, combos={len(config.combos)}, "
|
||||||
f"sweep={sweep.start_hz:g}..{sweep.stop_hz:g} Hz, points={points}, "
|
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:
|
def sync_profile(self, combo_mode: str) -> None:
|
||||||
self.set_combo_mode(combo_mode)
|
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.vna_panel.setVisible(not adc)
|
||||||
self._radar.adc_panel.setVisible(adc)
|
self._radar.adc_panel.setVisible(adc)
|
||||||
self.sync_adc_controls()
|
self.sync_adc_controls()
|
||||||
@@ -79,7 +79,7 @@ class RadarSettingsController:
|
|||||||
|
|
||||||
def refresh_limits(self) -> bool:
|
def refresh_limits(self) -> bool:
|
||||||
config = self._defaults()
|
config = self._defaults()
|
||||||
if config.is_kamil_adc:
|
if config.is_rph_radar:
|
||||||
return self.apply_limits(None)
|
return self.apply_limits(None)
|
||||||
service = (LibreVnaService(serial=config.radar.serial or None) if config.is_multi_device
|
service = (LibreVnaService(serial=config.radar.serial or None) if config.is_multi_device
|
||||||
else create_single_radar_service(config))
|
else create_single_radar_service(config))
|
||||||
@@ -136,7 +136,10 @@ class RadarSettingsController:
|
|||||||
|
|
||||||
def sync_frequency_limits(self) -> bool:
|
def sync_frequency_limits(self) -> bool:
|
||||||
try:
|
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:
|
except ValueError:
|
||||||
return False
|
return False
|
||||||
changed = False
|
changed = False
|
||||||
|
|||||||
@@ -348,6 +348,7 @@ def build_processing_group(config: RunConfigModel, gui: GuiStateModel) -> Proces
|
|||||||
form.addRow(processing_mode_pages)
|
form.addRow(processing_mode_pages)
|
||||||
form.addRow(gpr_common_page)
|
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:
|
for binding in GUI_FIELDS:
|
||||||
if binding.path[0] == "processing":
|
if binding.path[0] == "processing":
|
||||||
widget = view.fields[binding.spec.widget]
|
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")
|
raise TypeError("Geometry is not a scalar GUI setting")
|
||||||
if binding.path[-1] in {"start_freq_mhz", "stop_freq_mhz"}:
|
if binding.path[-1] in {"start_freq_mhz", "stop_freq_mhz"}:
|
||||||
assert isinstance(widget, QDoubleSpinBox)
|
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"}:
|
if binding.spec.live in {"gpr_input_positions", "gpr_output_positions"}:
|
||||||
assert isinstance(widget, QLineEdit)
|
assert isinstance(widget, QLineEdit)
|
||||||
widget.setValidator(QRegularExpressionValidator(QRegularExpression(POSITION_CSV_PATTERN), widget))
|
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(vna_radar_settings_panel)
|
||||||
layout.addWidget(adc_radar_settings_panel)
|
layout.addWidget(adc_radar_settings_panel)
|
||||||
vna_radar_settings_panel.setVisible(not config.is_kamil_adc)
|
vna_radar_settings_panel.setVisible(not config.is_rph_radar)
|
||||||
adc_radar_settings_panel.setVisible(config.is_kamil_adc)
|
adc_radar_settings_panel.setVisible(config.is_rph_radar)
|
||||||
return RadarView(group=group, fields=view.fields,
|
return RadarView(group=group, fields=view.fields,
|
||||||
sweep_edits=(start_hz_input, stop_hz_input, points_input, ifbw_input, power_input),
|
sweep_edits=(start_hz_input, stop_hz_input, points_input, ifbw_input, power_input),
|
||||||
sweep_labels=radar_labels,
|
sweep_labels=radar_labels,
|
||||||
@@ -80,7 +80,7 @@ def _build_adc_settings_panel(view: SectionView, config: RunConfigModel):
|
|||||||
layout.setContentsMargins(0, 0, 0, 0)
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
layout.setSpacing(8)
|
layout.setSpacing(8)
|
||||||
|
|
||||||
adc = config.radar.kamil_adc
|
adc = config.radar.rph_radar
|
||||||
optical = config.radar.laser_control
|
optical = config.radar.laser_control
|
||||||
manual = optical.manual
|
manual = optical.manual
|
||||||
variation = optical.variation
|
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_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_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_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_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}"))
|
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(
|
optical_variation_panel = build_two_column_form_widget(
|
||||||
panel,
|
panel,
|
||||||
[
|
[
|
||||||
("Variation type", optical_variation_type_combo),
|
|
||||||
("Static temp 1", optical_static_temp1_input),
|
("Static temp 1", optical_static_temp1_input),
|
||||||
("Static temp 2", optical_static_temp2_input),
|
("Static temp 2", optical_static_temp2_input),
|
||||||
("Static current 1", optical_static_current1_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_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_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_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_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_max_value_input, optical_step_input, optical_time_step_input, optical_delay_time_input),
|
||||||
optical_manual_panel, optical_variation_panel)
|
optical_manual_panel, optical_variation_panel)
|
||||||
|
|||||||
@@ -60,18 +60,10 @@ def build_processor_run_signature(
|
|||||||
locator settings, live-file path and polling interval require a restart.
|
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)
|
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 (
|
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.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),
|
str(config.input_switch.driver_mode),
|
||||||
int(config.input_switch.positions),
|
int(config.input_switch.positions),
|
||||||
str(config.output_switch.driver_mode),
|
str(config.output_switch.driver_mode),
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
"""Kamil ADC reference-channel acquisition.
|
|
||||||
|
|
||||||
Layered into small, single-responsibility modules:
|
|
||||||
|
|
||||||
* :mod:`~python_app.hardware_full.kamil_adc.protocol` — TTY wire format → aligned
|
|
||||||
:class:`RawSweep` (pure, incremental parser).
|
|
||||||
* :mod:`~python_app.hardware_full.kamil_adc.processing` — reference-phase
|
|
||||||
frequency axis, amplitude normalization, crop + resample to a fixed grid (pure).
|
|
||||||
* :mod:`~python_app.hardware_full.kamil_adc.tty_reader` — background thread that
|
|
||||||
drains the TTY and publishes the latest sweep.
|
|
||||||
* :mod:`~python_app.hardware_full.kamil_adc.service` — collector process lifecycle
|
|
||||||
and ``acquire() -> SweepResult``.
|
|
||||||
* :mod:`~python_app.hardware_full.kamil_adc.laser` — pre-acquisition laser setup.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from python_app.hardware_full.kamil_adc.laser import apply_kamil_adc_laser_control
|
|
||||||
from python_app.hardware_full.kamil_adc.processing import (
|
|
||||||
KamilAdcProcessingParams,
|
|
||||||
KamilAdcSweepProcessor,
|
|
||||||
)
|
|
||||||
from python_app.hardware_full.kamil_adc.protocol import (
|
|
||||||
KamilAdcStreamParser,
|
|
||||||
RawSweep,
|
|
||||||
)
|
|
||||||
from python_app.hardware_full.kamil_adc.service import KamilAdcService
|
|
||||||
from python_app.hardware_full.kamil_adc.tty_reader import KamilAdcTtyReader
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"KamilAdcProcessingParams",
|
|
||||||
"KamilAdcService",
|
|
||||||
"KamilAdcStreamParser",
|
|
||||||
"KamilAdcSweepProcessor",
|
|
||||||
"KamilAdcTtyReader",
|
|
||||||
"RawSweep",
|
|
||||||
"apply_kamil_adc_laser_control",
|
|
||||||
]
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
"""Laser-controller setup applied before Kamil ADC acquisition.
|
|
||||||
|
|
||||||
This mirrors the legacy ``device_main`` command sequence: connect to the laser
|
|
||||||
controller, reset it, and apply either manual or variation mode per
|
|
||||||
``radar.laser_control``. The wire protocol is unchanged from the standalone tool;
|
|
||||||
only its home moved into the project.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from python_app.models.run_config_schema import RunConfigModel
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def apply_kamil_adc_laser_control(config: RunConfigModel) -> bool:
|
|
||||||
"""Apply configured laser settings via the device_main command sequence.
|
|
||||||
|
|
||||||
Returns ``True`` when settings were applied, ``False`` when laser control is
|
|
||||||
disabled. The controller is always disconnected before returning.
|
|
||||||
"""
|
|
||||||
laser = config.radar.laser_control
|
|
||||||
if not laser.enabled:
|
|
||||||
logger.debug("Kamil ADC laser control disabled; skipping")
|
|
||||||
return False
|
|
||||||
|
|
||||||
_validate_laser_control_config(config)
|
|
||||||
|
|
||||||
from python_app.hardware_full.laser_control.controller import (
|
|
||||||
DEVICE_MAIN_MESSAGE_ID,
|
|
||||||
LaserController,
|
|
||||||
)
|
|
||||||
from python_app.hardware_full.laser_control.models import VariationType
|
|
||||||
|
|
||||||
controller = LaserController(
|
|
||||||
port=laser.port,
|
|
||||||
pi_coeff1_p=laser.pi_coeff1_p,
|
|
||||||
pi_coeff1_i=laser.pi_coeff1_i,
|
|
||||||
pi_coeff2_p=laser.pi_coeff2_p,
|
|
||||||
pi_coeff2_i=laser.pi_coeff2_i,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
controller.connect()
|
|
||||||
controller.reset()
|
|
||||||
mode = laser.mode.strip().lower()
|
|
||||||
logger.info("Applying Kamil ADC laser control in %s mode", mode)
|
|
||||||
if mode == "manual":
|
|
||||||
manual = laser.manual
|
|
||||||
controller.set_manual_mode(
|
|
||||||
temp1=manual.temp1,
|
|
||||||
temp2=manual.temp2,
|
|
||||||
current1=manual.current1,
|
|
||||||
current2=manual.current2,
|
|
||||||
message_id=DEVICE_MAIN_MESSAGE_ID,
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
if mode == "variation":
|
|
||||||
variation = laser.variation
|
|
||||||
try:
|
|
||||||
variation_type = VariationType[variation.variation_type]
|
|
||||||
except KeyError as exc:
|
|
||||||
raise ValueError(
|
|
||||||
f"Unsupported radar.laser_control.variation.variation_type: {variation.variation_type}"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
controller.set_manual_mode(
|
|
||||||
temp1=variation.static_temp1,
|
|
||||||
temp2=variation.static_temp2,
|
|
||||||
current1=variation.static_current1,
|
|
||||||
current2=variation.static_current2,
|
|
||||||
message_id=DEVICE_MAIN_MESSAGE_ID,
|
|
||||||
)
|
|
||||||
controller.start_variation(
|
|
||||||
variation_type=variation_type,
|
|
||||||
params={
|
|
||||||
"static_temp1": variation.static_temp1,
|
|
||||||
"static_temp2": variation.static_temp2,
|
|
||||||
"static_current1": variation.static_current1,
|
|
||||||
"static_current2": variation.static_current2,
|
|
||||||
"min_value": variation.min_value,
|
|
||||||
"max_value": variation.max_value,
|
|
||||||
"step": variation.step,
|
|
||||||
"time_step": variation.time_step,
|
|
||||||
"delay_time": variation.delay_time,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
_write_variation_session(variation)
|
|
||||||
return True
|
|
||||||
raise RuntimeError(f"Unsupported laser_control mode: {laser.mode}")
|
|
||||||
finally:
|
|
||||||
controller.disconnect()
|
|
||||||
|
|
||||||
|
|
||||||
def _write_variation_session(variation) -> None:
|
|
||||||
"""Freeze the variation's static temperature targets for the checker.
|
|
||||||
|
|
||||||
Best-effort: a failure to write the session snapshot must never abort the
|
|
||||||
acquisition setup, so any error is logged and swallowed.
|
|
||||||
"""
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
try:
|
|
||||||
from python_app.hardware_full.laser_control.monitoring.session import (
|
|
||||||
LaserVariationSession,
|
|
||||||
)
|
|
||||||
|
|
||||||
LaserVariationSession(
|
|
||||||
variation_type=variation.variation_type,
|
|
||||||
target_temp1=variation.static_temp1,
|
|
||||||
target_temp2=variation.static_temp2,
|
|
||||||
tolerance_c=variation.temp_tolerance_c,
|
|
||||||
started_at_iso=datetime.now().isoformat(timespec="seconds"),
|
|
||||||
).save()
|
|
||||||
logger.debug("Wrote laser variation session snapshot for the temperature checker")
|
|
||||||
except Exception: # noqa: BLE001 — session snapshot is auxiliary, never fatal
|
|
||||||
logger.warning("Failed to write laser variation session snapshot", exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_laser_control_config(config: RunConfigModel) -> None:
|
|
||||||
laser = config.radar.laser_control
|
|
||||||
if not laser.port:
|
|
||||||
raise ValueError("radar.laser_control.port is required when laser_control is enabled")
|
|
||||||
mode = laser.mode.strip().lower()
|
|
||||||
if mode not in {"manual", "variation"}:
|
|
||||||
raise ValueError("radar.laser_control.mode must be 'manual' or 'variation'")
|
|
||||||
if mode == "variation" and not laser.variation.variation_type:
|
|
||||||
raise ValueError("radar.laser_control.variation.variation_type is required")
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
"""Wire protocol for the Kamil ADC collector's TTY stream.
|
|
||||||
|
|
||||||
The collector publishes a stream of 8-byte little-endian frames, four 16-bit
|
|
||||||
words each: ``[marker, step, ch1, ch2]`` (``ch1``/``ch2`` signed). Three frame
|
|
||||||
kinds appear in ``do8_freq_ref`` mode:
|
|
||||||
|
|
||||||
* **Sweep boundary** — ``marker, 0xFFFF, 0xFFFF, 0xFFFF``. Delimits sweeps.
|
|
||||||
* **Main point** — ``0x000A, step, I, Q``. One complex main sample at ``step``.
|
|
||||||
* **Reference point** — ``0x00A8, step, I, Q``. The reference sample paired with
|
|
||||||
the main sample of the same ``step`` (emitted only where the DI8 loopback
|
|
||||||
settled, so reference points are sparser than main points).
|
|
||||||
|
|
||||||
:class:`KamilAdcStreamParser` consumes raw bytes incrementally and yields one
|
|
||||||
:class:`RawSweep` per completed sweep. Main and reference points are aligned by
|
|
||||||
step index; only steps carrying *both* survive (a step needs its reference for
|
|
||||||
the frequency axis and its main for the signal). Anything other than the three
|
|
||||||
known frame kinds is a protocol violation and raises — the reader fails fast and
|
|
||||||
lets the supervisor relaunch a clean collector rather than silently resyncing.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import struct
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
from numpy.typing import NDArray
|
|
||||||
|
|
||||||
# Wire-format constants.
|
|
||||||
FRAME_BYTES = 8
|
|
||||||
MAIN_MARKER = 0x000A
|
|
||||||
REFERENCE_MARKER = 0x00A8
|
|
||||||
# Combo tag — ``0x00C0, input_pos, output_pos, dirty``. Emitted by the switch-aware
|
|
||||||
# collector right after a sweep boundary to label the upcoming sweep with the RF
|
|
||||||
# switch combination it was captured under (``dirty != 0`` means the sweep straddled
|
|
||||||
# a switch transition and must be dropped). Absent in the standalone/calibration
|
|
||||||
# collector, where every sweep carries no combo (``combo is None``).
|
|
||||||
COMBO_MARKER = 0x00C0
|
|
||||||
_BOUNDARY_STEP = 0xFFFF
|
|
||||||
|
|
||||||
# marker (u16), step (u16), ch1 (i16), ch2 (i16) — point frames carry signed I/Q.
|
|
||||||
_FRAME = struct.Struct("<HHhh")
|
|
||||||
# The last three words of a boundary frame are all 0xFFFF; a real step is
|
|
||||||
# 1..0xFFFE, so this tail unambiguously marks a sweep boundary regardless of the
|
|
||||||
# (profile-dependent) start marker.
|
|
||||||
_BOUNDARY_TAIL = b"\xff\xff\xff\xff\xff\xff"
|
|
||||||
# Frame-alignment anchor: the phase-profile sweep-boundary frame.
|
|
||||||
_START_FRAME = struct.pack("<HHHH", MAIN_MARKER, 0xFFFF, 0xFFFF, 0xFFFF)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class RawSweep:
|
|
||||||
"""One sweep's main and reference samples, aligned and ordered by step.
|
|
||||||
|
|
||||||
``steps`` is strictly ascending; ``main`` and ``reference`` are the complex
|
|
||||||
samples at those steps. All three arrays share the same length.
|
|
||||||
"""
|
|
||||||
|
|
||||||
steps: NDArray[np.int32]
|
|
||||||
main: NDArray[np.complex64]
|
|
||||||
reference: NDArray[np.complex64]
|
|
||||||
combo: tuple[int, int] | None = None
|
|
||||||
dirty: bool = False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def size(self) -> int:
|
|
||||||
return int(self.steps.size)
|
|
||||||
|
|
||||||
|
|
||||||
class KamilAdcStreamParser:
|
|
||||||
"""Incremental, frame-aligning parser turning the TTY byte stream into sweeps.
|
|
||||||
|
|
||||||
Usage: call :meth:`feed` with each chunk of bytes; it returns the list of
|
|
||||||
sweeps completed by that chunk (usually zero or one). The parser is stateful
|
|
||||||
but holds no I/O and is cheap to unit-test.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__slots__ = ("_buffer", "_aligned", "_main", "_reference", "_pending_combo", "_pending_dirty")
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._buffer = bytearray()
|
|
||||||
self._aligned = False
|
|
||||||
self._main: dict[int, complex] = {}
|
|
||||||
self._reference: dict[int, complex] = {}
|
|
||||||
# Combo tag for the sweep currently being accumulated (set by the combo
|
|
||||||
# frame right after each boundary; ``None`` in non-switch collector modes).
|
|
||||||
self._pending_combo: tuple[int, int] | None = None
|
|
||||||
self._pending_dirty = False
|
|
||||||
|
|
||||||
def feed(self, data: bytes) -> list[RawSweep]:
|
|
||||||
"""Append ``data`` and return any sweeps completed by it."""
|
|
||||||
self._buffer.extend(data)
|
|
||||||
if not self._aligned and not self._align():
|
|
||||||
return []
|
|
||||||
|
|
||||||
sweeps: list[RawSweep] = []
|
|
||||||
buffer = self._buffer
|
|
||||||
while len(buffer) >= FRAME_BYTES:
|
|
||||||
frame = bytes(buffer[:FRAME_BYTES])
|
|
||||||
del buffer[:FRAME_BYTES]
|
|
||||||
if frame[2:] == _BOUNDARY_TAIL:
|
|
||||||
sweep = self._take_sweep()
|
|
||||||
if sweep is not None:
|
|
||||||
sweeps.append(sweep)
|
|
||||||
continue
|
|
||||||
marker, step, real, imag = _FRAME.unpack(frame)
|
|
||||||
if marker == MAIN_MARKER:
|
|
||||||
self._main[step] = complex(real, imag)
|
|
||||||
elif marker == REFERENCE_MARKER:
|
|
||||||
self._reference[step] = complex(real, imag)
|
|
||||||
elif marker == COMBO_MARKER:
|
|
||||||
# step = input_pos, real = output_pos, imag = dirty flag.
|
|
||||||
self._pending_combo = (int(step), int(real))
|
|
||||||
self._pending_dirty = imag != 0
|
|
||||||
else:
|
|
||||||
raise ValueError(
|
|
||||||
f"Kamil ADC protocol violation: unexpected frame marker 0x{marker:04x}"
|
|
||||||
)
|
|
||||||
return sweeps
|
|
||||||
|
|
||||||
def reset(self) -> None:
|
|
||||||
"""Drop all buffered state (e.g. after the collector is relaunched)."""
|
|
||||||
self._buffer.clear()
|
|
||||||
self._aligned = False
|
|
||||||
self._main.clear()
|
|
||||||
self._reference.clear()
|
|
||||||
self._pending_combo = None
|
|
||||||
self._pending_dirty = False
|
|
||||||
|
|
||||||
def _align(self) -> bool:
|
|
||||||
"""Discard pre-roll up to and including the first sweep boundary.
|
|
||||||
|
|
||||||
Returns ``True`` once frame-aligned. Keeps a short tail so a boundary
|
|
||||||
split across two feeds can still be found on the next chunk.
|
|
||||||
"""
|
|
||||||
index = self._buffer.find(_START_FRAME)
|
|
||||||
if index < 0:
|
|
||||||
if len(self._buffer) >= FRAME_BYTES:
|
|
||||||
del self._buffer[: -(FRAME_BYTES - 1)]
|
|
||||||
return False
|
|
||||||
del self._buffer[: index + FRAME_BYTES]
|
|
||||||
self._main.clear()
|
|
||||||
self._reference.clear()
|
|
||||||
self._pending_combo = None
|
|
||||||
self._pending_dirty = False
|
|
||||||
self._aligned = True
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _take_sweep(self) -> RawSweep | None:
|
|
||||||
"""Assemble the buffered points into a sweep and reset for the next one."""
|
|
||||||
shared = sorted(self._main.keys() & self._reference.keys())
|
|
||||||
main = self._main
|
|
||||||
reference = self._reference
|
|
||||||
combo = self._pending_combo
|
|
||||||
dirty = self._pending_dirty
|
|
||||||
self._main = {}
|
|
||||||
self._reference = {}
|
|
||||||
# The next sweep's combo is set by its own combo frame (right after this
|
|
||||||
# boundary); clear so a sweep without one reports combo=None rather than
|
|
||||||
# inheriting a stale tag.
|
|
||||||
self._pending_combo = None
|
|
||||||
self._pending_dirty = False
|
|
||||||
if not shared:
|
|
||||||
return None
|
|
||||||
return RawSweep(
|
|
||||||
steps=np.asarray(shared, dtype=np.int32),
|
|
||||||
main=np.asarray([main[step] for step in shared], dtype=np.complex64),
|
|
||||||
reference=np.asarray([reference[step] for step in shared], dtype=np.complex64),
|
|
||||||
combo=combo,
|
|
||||||
dirty=dirty,
|
|
||||||
)
|
|
||||||
@@ -1,3 +1 @@
|
|||||||
"""Vendored laser-control package used by the Kamil ADC hardware path."""
|
"""Vendored laser-control package used by the RadioPhotonic radar (RPH) hardware path."""
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
|
||||||
|
|||||||
@@ -9,43 +9,8 @@ laser control board.
|
|||||||
BAUDRATE = 115200
|
BAUDRATE = 115200
|
||||||
SERIAL_TIMEOUT_SEC = 1.0
|
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
|
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)
|
# ---- Physical / hardware constants (from device_conversion.py)
|
||||||
|
|
||||||
VREF = 2.5 # Reference voltage, Volts
|
VREF = 2.5 # Reference voltage, Volts
|
||||||
@@ -60,26 +25,13 @@ R6 = 56000 # Ohm
|
|||||||
RREF = 10 # Current-setting resistor, Ohm
|
RREF = 10 # Current-setting resistor, Ohm
|
||||||
# (@1550 nm – 28.7 Ohm; @840 nm – 10 Ohm)
|
# (@1550 nm – 28.7 Ohm; @840 nm – 10 Ohm)
|
||||||
|
|
||||||
# External thermistor divider resistors
|
# Thermistor Steinhart–Hart B-coefficient
|
||||||
R7 = 22000 # Ohm
|
|
||||||
R8 = 22000 # Ohm
|
|
||||||
R9 = 5100 # Ohm
|
|
||||||
R10 = 180000 # Ohm
|
|
||||||
|
|
||||||
# Thermistor Steinhart–Hart B-coefficient (internal / external)
|
|
||||||
BETA_INTERNAL = 3900 # K
|
BETA_INTERNAL = 3900 # K
|
||||||
BETA_EXTERNAL = 3455 # K
|
|
||||||
T0_K = 298 # Kelvin (25 °C reference)
|
T0_K = 298 # Kelvin (25 °C reference)
|
||||||
R0 = 10000 # Ohm (thermistor nominal at 25 °C)
|
R0 = 10000 # Ohm (thermistor nominal at 25 °C)
|
||||||
|
|
||||||
# ADC resolution
|
# DAC resolution
|
||||||
ADC_BITS_16 = 65535 # 2^16 - 1
|
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)
|
# ---- 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_MIN_MA = 0.002 # Minimum current variation step, mA
|
||||||
CURRENT_STEP_MAX_MA = 0.5 # Maximum 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 parameter limits
|
||||||
TIME_STEP_MIN_US = 20 # Minimum time step, microseconds
|
TIME_STEP_MIN_US = 20 # Minimum time step, microseconds
|
||||||
TIME_STEP_MAX_US = 100 # Maximum time step, microseconds
|
TIME_STEP_MAX_US = 100 # Maximum time step, microseconds
|
||||||
|
|||||||
@@ -1,355 +1,60 @@
|
|||||||
"""
|
"""Board commands with mandatory acknowledgements."""
|
||||||
Main laser controller for the laser control module.
|
|
||||||
|
|
||||||
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
|
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 .constants import WAIT_AFTER_SEND_SEC
|
||||||
|
from .exceptions import CommunicationError
|
||||||
|
from .protocol import Protocol
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
DEFAULT_PI_P = 2560
|
||||||
# Default PI regulator coefficients (match firmware defaults)
|
DEFAULT_PI_I = 128
|
||||||
DEFAULT_PI_P = 2560 # 10 * 256
|
|
||||||
DEFAULT_PI_I = 128 # 0.5 * 256
|
|
||||||
DEVICE_MAIN_MESSAGE_ID = 0x00FF
|
DEVICE_MAIN_MESSAGE_ID = 0x00FF
|
||||||
|
|
||||||
|
|
||||||
class LaserController:
|
class LaserController:
|
||||||
"""
|
"""Control constant setpoints or LD1 current variation through one serial port."""
|
||||||
High-level controller for the dual laser board.
|
|
||||||
|
|
||||||
Usage example::
|
def __init__(self, port: str,
|
||||||
|
pi_coeff1_p: int = DEFAULT_PI_P, pi_coeff1_i: int = DEFAULT_PI_I,
|
||||||
ctrl = LaserController(port='/dev/ttyUSB0')
|
pi_coeff2_p: int = DEFAULT_PI_P, pi_coeff2_i: int = DEFAULT_PI_I):
|
||||||
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)``.
|
|
||||||
"""
|
|
||||||
self._protocol = Protocol(port)
|
self._protocol = Protocol(port)
|
||||||
self._pi1_p = pi_coeff1_p
|
self._pi = dict(pi_coeff1_p=pi_coeff1_p, pi_coeff1_i=pi_coeff1_i,
|
||||||
self._pi1_i = pi_coeff1_i
|
pi_coeff2_p=pi_coeff2_p, pi_coeff2_i=pi_coeff2_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 -------------------------------------------------------
|
|
||||||
|
|
||||||
def connect(self) -> bool:
|
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()
|
self._protocol.connect()
|
||||||
logger.info("Connected to laser controller on port %s",
|
logger.info("Connected to laser controller on port %s", self._protocol.port)
|
||||||
self._protocol._port_name or "auto")
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def disconnect(self) -> None:
|
def disconnect(self) -> None:
|
||||||
"""Close the serial port gracefully."""
|
|
||||||
self._protocol.disconnect()
|
self._protocol.disconnect()
|
||||||
logger.info("Disconnected from laser controller")
|
logger.info("Disconnected from laser controller")
|
||||||
|
|
||||||
@property
|
def set_manual_mode(self, temp1: float, temp2: float, current1: float, current2: float,
|
||||||
def is_connected(self) -> bool:
|
message_id: int) -> None:
|
||||||
"""True if the serial port is open."""
|
manual = dict(temp1=temp1, temp2=temp2, current1=current1, current2=current2)
|
||||||
return self._protocol.is_connected
|
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 start_variation(self, params: dict) -> None:
|
||||||
|
self._send_and_read_state(Protocol.encode_task_enable(
|
||||||
def set_manual_mode(
|
**params, pi_coeff1_p=self._pi["pi_coeff1_p"], pi_coeff1_i=self._pi["pi_coeff1_i"],
|
||||||
self,
|
pi_coeff2_p=self._pi["pi_coeff2_p"], pi_coeff2_i=self._pi["pi_coeff2_i"],
|
||||||
temp1: float,
|
))
|
||||||
temp2: float,
|
logger.info("LD1 current variation started: %s", params)
|
||||||
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 reset(self) -> None:
|
def reset(self) -> None:
|
||||||
"""Send a hardware reset command to the device."""
|
self._send_and_read_state(b"\x22\x22")
|
||||||
cmd = Protocol.encode_default_enable()
|
|
||||||
self._send_and_read_state(cmd)
|
|
||||||
logger.info("Device reset command sent")
|
logger.info("Device reset command sent")
|
||||||
|
|
||||||
# ---- Internal helpers -------------------------------------------------
|
def _send(self, command: bytes) -> None:
|
||||||
|
self._protocol.send_raw(command)
|
||||||
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)
|
|
||||||
time.sleep(WAIT_AFTER_SEND_SEC)
|
time.sleep(WAIT_AFTER_SEND_SEC)
|
||||||
|
|
||||||
def _send_and_read_state(self, cmd: bytes) -> int:
|
def _send_and_read_state(self, command: bytes) -> None:
|
||||||
"""Send command and read the 2-byte STATE response the device always returns.
|
self._send(command)
|
||||||
|
state = Protocol.decode_state(self._protocol.receive_raw(2))
|
||||||
Commands DECODE_ENABLE, TASK_ENABLE and DEFAULT_ENABLE each trigger a
|
if state:
|
||||||
STATE reply from the firmware. If we don't consume those bytes here,
|
raise CommunicationError(f"Device rejected command: STATE 0x{state:04x}")
|
||||||
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
|
|
||||||
|
|||||||
@@ -5,14 +5,7 @@ values used by the device firmware, using the hardware's bridge/divider formulas
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import math
|
import math
|
||||||
from .constants import (
|
from .constants import ADC_BITS_16, BETA_INTERNAL, R0, R1, R3, R4, R5, R6, RREF, T0_K, VREF
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def temp_c_to_n(temp_c: float) -> int:
|
def temp_c_to_n(temp_c: float) -> int:
|
||||||
@@ -34,38 +27,6 @@ def temp_c_to_n(temp_c: float) -> int:
|
|||||||
return n
|
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:
|
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 = int(ADC_BITS_16 / 2000 * RREF * current_ma)
|
||||||
n = max(0, min(ADC_BITS_16, n))
|
n = max(0, min(ADC_BITS_16, n))
|
||||||
return 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
|
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):
|
class CommunicationError(LaserControlError):
|
||||||
"""Base exception for communication errors."""
|
"""Base exception for communication errors."""
|
||||||
pass
|
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):
|
class PortBusyError(CommunicationError):
|
||||||
"""Raised when the serial port is already held by another program.
|
"""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):
|
class ProtocolError(CommunicationError):
|
||||||
"""Exception raised for protocol-level errors."""
|
"""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.
|
"""Serial transport and little-endian commands for the dual-laser board."""
|
||||||
|
|
||||||
Encodes commands to wire bytes, decodes device responses, and manages the
|
|
||||||
serial port connection to the laser control board.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import errno
|
import errno
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
import struct
|
||||||
from enum import IntEnum
|
from functools import reduce
|
||||||
from datetime import datetime
|
from operator import xor
|
||||||
|
|
||||||
import serial
|
import serial
|
||||||
import serial.tools.list_ports
|
|
||||||
|
|
||||||
from .constants import (
|
from .constants import BAUDRATE, SERIAL_TIMEOUT_SEC
|
||||||
BAUDRATE, SERIAL_TIMEOUT_SEC,
|
from .conversions import current_ma_to_n, temp_c_to_n
|
||||||
GET_DATA_TOTAL_LENGTH,
|
from .validators import validate_manual, validate_variation
|
||||||
SEND_PARAMS_TOTAL_LENGTH,
|
from .exceptions import CommunicationError, PortBusyError, ProtocolError
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class TaskType(IntEnum):
|
def _command(header: int, *words: int) -> bytes:
|
||||||
MANUAL = 0x00
|
"""Append XOR checksum; header is excluded, every word is uint16 LE."""
|
||||||
CHANGE_CURRENT_LD1 = 0x01
|
if any(not isinstance(word, int) or not 0 <= word <= 65535 for word in words):
|
||||||
CHANGE_CURRENT_LD2 = 0x02
|
raise ValueError("Laser command words must be uint16 integers")
|
||||||
CHANGE_TEMPERATURE_LD1 = 0x03
|
return struct.pack(f"<{len(words) + 2}H", header, *words, reduce(xor, words, 0))
|
||||||
CHANGE_TEMPERATURE_LD2 = 0x04
|
|
||||||
|
|
||||||
|
|
||||||
# ---- 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:
|
class Protocol:
|
||||||
"""
|
"""Exclusive serial connection and the commands used by the application."""
|
||||||
Encodes commands and decodes responses for the laser control board.
|
|
||||||
|
|
||||||
Can also manage a serial port connection when port is provided.
|
def __init__(self, port: str):
|
||||||
"""
|
self.port = port
|
||||||
|
self._serial: serial.Serial | None = None
|
||||||
def __init__(self, port: Optional[str] = None):
|
|
||||||
self._port_name = port
|
|
||||||
self._serial: Optional[serial.Serial] = None
|
|
||||||
|
|
||||||
# ---- Connection management
|
# ---- Connection management
|
||||||
|
|
||||||
def connect(self) -> None:
|
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
|
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
|
laser controller at a time. This is required because the board is a single
|
||||||
@@ -162,7 +45,7 @@ class Protocol:
|
|||||||
"""
|
"""
|
||||||
if self.is_connected:
|
if self.is_connected:
|
||||||
return
|
return
|
||||||
port = self._port_name or self._detect_port()
|
port = self.port
|
||||||
try:
|
try:
|
||||||
self._serial = serial.Serial(
|
self._serial = serial.Serial(
|
||||||
port=port,
|
port=port,
|
||||||
@@ -194,13 +77,6 @@ class Protocol:
|
|||||||
def is_connected(self) -> bool:
|
def is_connected(self) -> bool:
|
||||||
return self._serial is not None and self._serial.is_open
|
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
|
# ---- Raw I/O
|
||||||
|
|
||||||
def send_raw(self, data: bytes) -> None:
|
def send_raw(self, data: bytes) -> None:
|
||||||
@@ -213,199 +89,45 @@ class Protocol:
|
|||||||
raise CommunicationError("Serial port is not connected")
|
raise CommunicationError("Serial port is not connected")
|
||||||
return self._serial.read(length)
|
return self._serial.read(length)
|
||||||
|
|
||||||
# ---- Command encoders -----------------------------------------------
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def encode_decode_enable(
|
def encode_decode_enable(
|
||||||
temp1: float,
|
temp1: float, temp2: float, current1: float, current2: float,
|
||||||
temp2: float,
|
pi_coeff1_p: int, pi_coeff1_i: int, pi_coeff2_p: int, pi_coeff2_i: int,
|
||||||
current1: float,
|
|
||||||
current2: float,
|
|
||||||
pi_coeff1_p: int,
|
|
||||||
pi_coeff1_i: int,
|
|
||||||
pi_coeff2_p: int,
|
|
||||||
pi_coeff2_i: int,
|
|
||||||
message_id: int,
|
message_id: int,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""
|
"""DECODE_ENABLE: constant currents, temperatures and PI coefficients."""
|
||||||
Build DECODE_ENABLE command (0x1111).
|
validate_manual(temp1, temp2, current1, current2)
|
||||||
|
# 0x37ff enables both channels and their regulators; SD recording is off.
|
||||||
Sets temperature and current setpoints for both lasers.
|
return _command(
|
||||||
Returns 30-byte bytearray.
|
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,
|
||||||
if current1 < 0 or current2 < 0:
|
current_ma_to_n(current1), current_ma_to_n(current2),
|
||||||
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)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def encode_task_enable(
|
def encode_task_enable(
|
||||||
task_type: TaskType,
|
static_temp1: float, static_temp2: float, static_current1: float, static_current2: float,
|
||||||
static_temp1: float,
|
min_value: float, max_value: float, step: float, time_step: int, delay_time: int,
|
||||||
static_temp2: float,
|
pi_coeff1_p: int = 1, pi_coeff1_i: int = 1, pi_coeff2_p: int = 1, pi_coeff2_i: int = 1,
|
||||||
static_current1: float,
|
variation_type: str = "CHANGE_CURRENT_LD1",
|
||||||
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,
|
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""
|
"""TASK_ENABLE: sweep LD1 current while LD2 and both temperatures stay fixed."""
|
||||||
Build TASK_ENABLE command (0x7777).
|
if variation_type != "CHANGE_CURRENT_LD1":
|
||||||
|
raise ValueError("radar.laser_control.variation.variation_type must be CHANGE_CURRENT_LD1")
|
||||||
Starts a measurement task (current or temperature variation).
|
validate_manual(static_temp1, static_temp2, static_current1, static_current2)
|
||||||
Returns 32-byte bytearray.
|
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 not isinstance(task_type, TaskType):
|
if delta == 0 or low >= high:
|
||||||
try:
|
raise ValueError("Current variation range and step must be representable by the DAC")
|
||||||
task_type = TaskType(task_type)
|
return _command(
|
||||||
except ValueError:
|
0x7777, 0x37ff, 1, low, high, delta, int(time_step * 100),
|
||||||
raise ValueError(f"Invalid task_type: {task_type}")
|
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,
|
||||||
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
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def decode_state(data: bytes) -> int:
|
def decode_state(data: bytes) -> int:
|
||||||
"""
|
"""STATE is a two-byte error bitmask; zero acknowledges the command."""
|
||||||
Decode a 2-byte STATE response from the device.
|
if len(data) != 2:
|
||||||
|
raise ProtocolError(f"Expected 2 STATE bytes, got {len(data)}")
|
||||||
Returns:
|
return int.from_bytes(data, "little")
|
||||||
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.")
|
|
||||||
|
|||||||
@@ -1,257 +1,33 @@
|
|||||||
"""
|
"""Physical limits checked before command encoding or any board reset."""
|
||||||
Parameter validation for laser control module.
|
|
||||||
|
|
||||||
Validates all input parameters against physical constraints
|
|
||||||
and protocol limits before sending to device.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import math
|
import math
|
||||||
from typing import Dict, Any, Tuple
|
|
||||||
|
|
||||||
from .constants import (
|
from .constants import (
|
||||||
TEMP_MIN_C, TEMP_MAX_C,
|
TEMP_MIN_C, TEMP_MAX_C, CURRENT_MIN_MA, CURRENT_MAX_MA,
|
||||||
CURRENT_MIN_MA, CURRENT_MAX_MA,
|
|
||||||
CURRENT_STEP_MIN_MA, CURRENT_STEP_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 (
|
from .exceptions import ValidationError
|
||||||
ValidationError,
|
|
||||||
TemperatureOutOfRangeError,
|
|
||||||
CurrentOutOfRangeError,
|
|
||||||
InvalidParameterError,
|
|
||||||
)
|
|
||||||
from .models import VariationType
|
|
||||||
|
|
||||||
|
|
||||||
class ParameterValidator:
|
def _range(value: float, name: str, low: float, high: float) -> None:
|
||||||
"""Validates all input parameters for the laser controller."""
|
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_manual(temp1: float, temp2: float, current1: float, current2: float) -> None:
|
||||||
def validate_temperature(value: Any, param_name: str) -> float:
|
for name, value in (("temp1", temp1), ("temp2", temp2)):
|
||||||
"""
|
_range(value, name, TEMP_MIN_C, TEMP_MAX_C)
|
||||||
Validate a laser temperature value.
|
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:
|
def validate_variation(min_value: float, max_value: float, step: float,
|
||||||
Validated temperature as float.
|
time_step: int, delay_time: int) -> None:
|
||||||
|
for name, value in (("min_value", min_value), ("max_value", max_value)):
|
||||||
Raises:
|
_range(value, name, CURRENT_MIN_MA, CURRENT_MAX_MA)
|
||||||
InvalidParameterError: If value is not a valid number.
|
if min_value >= max_value:
|
||||||
TemperatureOutOfRangeError: If value is outside [TEMP_MIN_C, TEMP_MAX_C].
|
raise ValidationError("min_value must be less than max_value")
|
||||||
"""
|
_range(step, "step", CURRENT_STEP_MIN_MA, CURRENT_STEP_MAX_MA)
|
||||||
value = ParameterValidator._check_numeric(value, param_name)
|
_range(time_step, "time_step (us)", TIME_STEP_MIN_US, TIME_STEP_MAX_US)
|
||||||
if value < TEMP_MIN_C or value > TEMP_MAX_C:
|
_range(delay_time, "delay_time (ms)", DELAY_TIME_MIN_MS, DELAY_TIME_MAX_MS)
|
||||||
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'),
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class MultiDeviceVnaController:
|
|||||||
self,
|
self,
|
||||||
master_serial_number: str,
|
master_serial_number: str,
|
||||||
slave_serial_numbers: Sequence[str] = (),
|
slave_serial_numbers: Sequence[str] = (),
|
||||||
force_external_reference: bool = True,
|
force_external_reference: bool = False,
|
||||||
switch_substage_count: int = 1,
|
switch_substage_count: int = 1,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Open all configured devices and initialize controller state.
|
"""Open all configured devices and initialize controller state.
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class MultiDeviceLibreVnaService:
|
|||||||
|
|
||||||
master_serial: str
|
master_serial: str
|
||||||
slave_serials: list[str]
|
slave_serials: list[str]
|
||||||
force_external_reference: bool = True
|
force_external_reference: bool = False
|
||||||
recovery_attempts: int = 3
|
recovery_attempts: int = 3
|
||||||
# Stimulus switch positions stepped through WITHIN one frequency point. 1 keeps the
|
# 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
|
# stock 2-output matrix; 2 doubles the outputs (4) without a second sweep, at the
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""L-Card acquisition, reference-phase processing and laser configuration."""
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Apply constant laser settings or LD1 current variation before ADC acquisition."""
|
||||||
|
|
||||||
|
from dataclasses import asdict
|
||||||
|
|
||||||
|
from python_app.hardware_full.laser_control.controller import DEVICE_MAIN_MESSAGE_ID, LaserController
|
||||||
|
from python_app.hardware_full.laser_control.protocol import Protocol
|
||||||
|
from python_app.models.run_config_schema import RunConfigModel
|
||||||
|
|
||||||
|
|
||||||
|
def apply_rph_radar_laser_control(config: RunConfigModel) -> bool:
|
||||||
|
"""Validate every command before connecting/resetting; leave a started sweep running."""
|
||||||
|
laser = config.radar.laser_control
|
||||||
|
if not laser.enabled:
|
||||||
|
return False
|
||||||
|
if not laser.port:
|
||||||
|
raise ValueError("radar.laser_control.port is required when laser_control is enabled")
|
||||||
|
mode = laser.mode.strip().lower()
|
||||||
|
if mode not in {"manual", "variation"}:
|
||||||
|
raise ValueError("radar.laser_control.mode must be 'manual' or 'variation'")
|
||||||
|
pi = {name: getattr(laser, name) for name in ("pi_coeff1_p", "pi_coeff1_i", "pi_coeff2_p", "pi_coeff2_i")}
|
||||||
|
manual = (asdict(laser.manual) if mode == "manual" else
|
||||||
|
{name: getattr(laser.variation, "static_" + name) for name in ("temp1", "temp2", "current1", "current2")})
|
||||||
|
variation = asdict(laser.variation)
|
||||||
|
Protocol.encode_decode_enable(**manual, **pi, message_id=DEVICE_MAIN_MESSAGE_ID)
|
||||||
|
if mode == "variation":
|
||||||
|
Protocol.encode_task_enable(**variation, **pi)
|
||||||
|
|
||||||
|
controller = LaserController(port=laser.port, **pi)
|
||||||
|
try:
|
||||||
|
controller.connect()
|
||||||
|
controller.reset()
|
||||||
|
controller.set_manual_mode(**manual, message_id=DEVICE_MAIN_MESSAGE_ID)
|
||||||
|
if mode == "variation":
|
||||||
|
controller.start_variation(params=variation)
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
controller.disconnect()
|
||||||
+20
-85
@@ -1,8 +1,8 @@
|
|||||||
"""Signal processing for the Kamil ADC reference-channel acquisition.
|
"""Signal processing for the RadioPhotonic radar (RPH) reference-channel acquisition.
|
||||||
|
|
||||||
Each sweep arrives as two aligned complex arrays — the *main* signal and a
|
Each sweep arrives as two aligned complex arrays — the *main* signal and a
|
||||||
*reference* signal — sampled at the same step indices (see
|
*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:
|
comparable S21 trace is a fixed three-stage pipeline:
|
||||||
|
|
||||||
1. **Frequency axis from the reference phase.** The reference arm has a constant
|
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 __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import replace
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from numpy.typing import NDArray
|
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
|
# A reference sample whose magnitude falls to (effectively) zero carries no usable
|
||||||
# amplitude or phase; such points are dropped before normalization rather than
|
# 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
|
# 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
|
# 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
|
# 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
|
_PHASE_BRANCH_PERIOD_RAD = 2.0 * np.pi
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
class RphRadarSweepProcessor:
|
||||||
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:
|
|
||||||
"""Turns aligned (main, reference) sweeps into S21 traces on a fixed grid.
|
"""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
|
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.
|
sweep, so all traces this processor emits share one identical frequency axis.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__slots__ = ("_params", "_grid_hz", "_previous_anchor_rad")
|
def __init__(self, config: RphRadarModel) -> None:
|
||||||
|
validate_rph_radar_model(config)
|
||||||
def __init__(self, params: KamilAdcProcessingParams) -> None:
|
self._calibration = replace(config.phase_calibration)
|
||||||
self._params = params
|
self._band = replace(config.band)
|
||||||
self._grid_hz = np.linspace(
|
self._hz_per_rad = ((self._calibration.freq1_hz - self._calibration.freq0_hz)
|
||||||
params.band_start_hz, params.band_stop_hz, params.band_points, dtype=np.float64
|
/ (self._calibration.phase1_rad - self._calibration.phase0_rad))
|
||||||
)
|
self._grid_hz = np.linspace(self._band.start_hz, self._band.stop_hz, self._band.points)
|
||||||
# Absolute (branch-tracked) reference phase of the last ACCEPTED sweep's
|
self.grid_hz = self._grid_hz.astype(np.float32)
|
||||||
# first sample, carried across sweeps so a +/-pi anchor wrap can be undone.
|
self.grid_hz.setflags(write=False)
|
||||||
# ``None`` until the first sweep is accepted; reset by building a new
|
|
||||||
# processor (i.e. on reconfigure).
|
|
||||||
self._previous_anchor_rad: float | None = None
|
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:
|
def reference_frequency_axis(self, reference: np.ndarray) -> np.ndarray:
|
||||||
"""Map a reference signal's absolute unwrapped phase to frequency (Hz).
|
"""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:
|
def _phase_to_frequency(self, phase: np.ndarray) -> np.ndarray:
|
||||||
"""Apply the affine ``phase -> frequency`` calibration law."""
|
"""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]:
|
def _align_phase_branch(self, phase: np.ndarray) -> tuple[np.ndarray, float]:
|
||||||
"""Undo a stray +/-pi anchor wrap by unwrapping the anchor across sweeps.
|
"""Undo a stray +/-pi anchor wrap by unwrapping the anchor across sweeps.
|
||||||
@@ -182,7 +117,7 @@ class KamilAdcSweepProcessor:
|
|||||||
reference_anchor = (
|
reference_anchor = (
|
||||||
self._previous_anchor_rad
|
self._previous_anchor_rad
|
||||||
if self._previous_anchor_rad is not None
|
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)
|
turns = round((reference_anchor - anchor) / _PHASE_BRANCH_PERIOD_RAD)
|
||||||
if turns:
|
if turns:
|
||||||
@@ -213,7 +148,7 @@ class KamilAdcSweepProcessor:
|
|||||||
with np.errstate(divide="ignore", invalid="ignore"):
|
with np.errstate(divide="ignore", invalid="ignore"):
|
||||||
s21 = main / reference_amplitude
|
s21 = main / reference_amplitude
|
||||||
usable = (
|
usable = (
|
||||||
(reference_amplitude > self._params.reference_amplitude_floor)
|
(reference_amplitude > _REFERENCE_AMPLITUDE_FLOOR)
|
||||||
& np.isfinite(freqs)
|
& np.isfinite(freqs)
|
||||||
& np.isfinite(s21.real)
|
& np.isfinite(s21.real)
|
||||||
& np.isfinite(s21.imag)
|
& np.isfinite(s21.imag)
|
||||||
@@ -232,7 +167,7 @@ class KamilAdcSweepProcessor:
|
|||||||
|
|
||||||
# Reject sweeps that do not span the whole band: interpolating past the
|
# Reject sweeps that do not span the whole band: interpolating past the
|
||||||
# measured edge would inject flat, non-physical points.
|
# 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
|
return None
|
||||||
|
|
||||||
# The sweep is accepted: commit its branch-aligned anchor so the next
|
# The sweep is accepted: commit its branch-aligned anchor so the next
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Whole-sweep RPH1 packets from the collector's PTY (little endian).
|
||||||
|
|
||||||
|
The 48-byte header carries payload size, source sweep/cycle IDs, monotonic capture
|
||||||
|
window, input/output (65535 for untagged) and dirty flag. Payload points are
|
||||||
|
8-byte ``marker, step, I, Q`` frames: A for main and A8 for reference.
|
||||||
|
Only complete queued packets may be dropped; a packet already being written is
|
||||||
|
always finished. Thus overflow cannot splice two physical sweeps together.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import struct
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from numpy.typing import NDArray
|
||||||
|
|
||||||
|
MAIN_MARKER = 0x000A
|
||||||
|
REFERENCE_MARKER = 0x00A8
|
||||||
|
_HEADER = struct.Struct("<4sIQQQQHHI")
|
||||||
|
_POINT = struct.Struct("<HHhh")
|
||||||
|
_MAX_PAYLOAD_BYTES = 65534 * 2 * _POINT.size
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RawSweep:
|
||||||
|
steps: NDArray[np.int32]
|
||||||
|
main: NDArray[np.complex64]
|
||||||
|
reference: NDArray[np.complex64]
|
||||||
|
source_id: int
|
||||||
|
cycle_id: int
|
||||||
|
capture_start_ns: int
|
||||||
|
capture_end_ns: int
|
||||||
|
combo: tuple[int, int] | None = None
|
||||||
|
dirty: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def size(self) -> int:
|
||||||
|
return int(self.steps.size)
|
||||||
|
|
||||||
|
|
||||||
|
class RphRadarStreamParser:
|
||||||
|
"""Incremental packet parser; corrupt headers or point markers fail closed."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._buffer = bytearray()
|
||||||
|
self._aligned = False
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self._buffer.clear()
|
||||||
|
self._aligned = False
|
||||||
|
|
||||||
|
def feed(self, data: bytes) -> list[RawSweep]:
|
||||||
|
self._buffer.extend(data)
|
||||||
|
if not self._aligned:
|
||||||
|
index = self._buffer.find(b"RPH1")
|
||||||
|
if index < 0:
|
||||||
|
del self._buffer[:-3]
|
||||||
|
return []
|
||||||
|
del self._buffer[:index]
|
||||||
|
self._aligned = True
|
||||||
|
sweeps = []
|
||||||
|
while len(self._buffer) >= _HEADER.size:
|
||||||
|
magic, size, source_id, cycle_id, start, end, inp, out, flags = _HEADER.unpack_from(self._buffer)
|
||||||
|
if (magic != b"RPH1" or size > _MAX_PAYLOAD_BYTES or size % _POINT.size
|
||||||
|
or not source_id or not cycle_id or not 0 < start <= end
|
||||||
|
or flags & ~1 or (inp == 65535) != (out == 65535)):
|
||||||
|
raise ValueError("RadioPhotonic radar (RPH) protocol violation: invalid sweep header")
|
||||||
|
packet_size = _HEADER.size + size
|
||||||
|
if len(self._buffer) < packet_size:
|
||||||
|
break
|
||||||
|
main: dict[int, complex] = {}
|
||||||
|
reference: dict[int, complex] = {}
|
||||||
|
for marker, step, real, imag in _POINT.iter_unpack(self._buffer[_HEADER.size:packet_size]):
|
||||||
|
if marker not in (MAIN_MARKER, REFERENCE_MARKER) or not 0 < step < 65535:
|
||||||
|
raise ValueError("RadioPhotonic radar (RPH) protocol violation: invalid point frame")
|
||||||
|
target = main if marker == MAIN_MARKER else reference
|
||||||
|
if step in target:
|
||||||
|
raise ValueError("RadioPhotonic radar (RPH) protocol violation: duplicate point")
|
||||||
|
target[step] = complex(real, imag)
|
||||||
|
del self._buffer[:packet_size]
|
||||||
|
shared = sorted(main.keys() & reference.keys())
|
||||||
|
if shared:
|
||||||
|
sweeps.append(RawSweep(
|
||||||
|
np.asarray(shared, dtype=np.int32),
|
||||||
|
np.asarray([main[step] for step in shared], dtype=np.complex64),
|
||||||
|
np.asarray([reference[step] for step in shared], dtype=np.complex64),
|
||||||
|
source_id, cycle_id, start, end,
|
||||||
|
None if inp == 65535 else (inp, out), bool(flags & 1),
|
||||||
|
))
|
||||||
|
return sweeps
|
||||||
+109
-119
@@ -1,14 +1,15 @@
|
|||||||
"""Service that launches the Kamil ADC collector and serves processed sweeps.
|
"""Service that launches the RadioPhotonic radar (RPH) collector and serves processed sweeps.
|
||||||
|
|
||||||
Owns the lifecycle of the external collector process and its TTY reader, and maps
|
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
|
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 __future__ import annotations
|
||||||
|
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -20,23 +21,21 @@ import time
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from python_app.hardware_full.kamil_adc.processing import (
|
from python_app.hardware_full.rph_radar.processing import RphRadarSweepProcessor
|
||||||
KamilAdcProcessingParams,
|
from python_app.hardware_full.rph_radar.protocol import RawSweep
|
||||||
KamilAdcSweepProcessor,
|
from python_app.hardware_full.rph_radar.tty_reader import (
|
||||||
)
|
RphRadarTtyReader,
|
||||||
from python_app.hardware_full.kamil_adc.protocol import RawSweep
|
|
||||||
from python_app.hardware_full.kamil_adc.tty_reader import (
|
|
||||||
KamilAdcTtyReader,
|
|
||||||
raise_if_process_exited,
|
raise_if_process_exited,
|
||||||
)
|
)
|
||||||
from python_app.hardware_full.librevna_driver.models import SweepResult
|
from python_app.hardware_full.librevna_driver.models import SweepResult
|
||||||
from python_app.models.dataset_model import ComboKey
|
from python_app.models.dataset_model import ComboKey
|
||||||
from python_app.models.run_config_schema import RadarSweepModel, RunConfigModel
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Project root, used to resolve relative collector paths (e.g.
|
# 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]
|
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||||
# Default home of the proprietary L-Card runtime libraries the collector loads
|
# 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.
|
# 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
|
# brief window to release the device cleanly before escalating to SIGKILL. Caps
|
||||||
# the configured stop_timeout_s so a stop can never hang.
|
# the configured stop_timeout_s so a stop can never hang.
|
||||||
_STOP_KILL_GRACE_S = 0.5
|
_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)
|
@dataclass(slots=True)
|
||||||
class KamilAdcService:
|
class RphRadarService:
|
||||||
"""Launch the external Kamil ADC collector and serve its processed sweeps."""
|
"""Launch the external RadioPhotonic radar (RPH) collector and serve its processed sweeps."""
|
||||||
|
|
||||||
config: RunConfigModel
|
config: RunConfigModel
|
||||||
# When set, the collector is launched with ``config:<path>`` so it drives the RF
|
collector_driven: bool = False
|
||||||
# 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
|
|
||||||
_process: subprocess.Popen[bytes] | None = field(init=False, default=None, repr=False)
|
_process: subprocess.Popen[bytes] | None = field(init=False, default=None, repr=False)
|
||||||
_reader: KamilAdcTtyReader | None = field(init=False, default=None, repr=False)
|
_reader: RphRadarTtyReader | None = field(init=False, default=None, repr=False)
|
||||||
_processor: KamilAdcSweepProcessor | 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)
|
_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:
|
def __post_init__(self) -> None:
|
||||||
self._validate_config()
|
self._validate_config()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def command(self) -> list[str]:
|
def command(self) -> list[str]:
|
||||||
"""External collector command, including the generated ``tty:`` argument.
|
"""Pass the current typed settings once; the collector never rereads the profile."""
|
||||||
|
adc = self.config.radar.rph_radar
|
||||||
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
|
|
||||||
cmd = [str(self._resolve_executable()), *adc.args]
|
cmd = [str(self._resolve_executable()), *adc.args]
|
||||||
if self.switch_config_path is not None:
|
if self.collector_driven:
|
||||||
cmd.append(f"config:{self.switch_config_path}")
|
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}")
|
cmd.append(f"tty:{adc.tty_path}")
|
||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
@@ -94,51 +88,57 @@ class KamilAdcService:
|
|||||||
"""
|
"""
|
||||||
if self._reader is not None:
|
if self._reader is not None:
|
||||||
return
|
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:
|
try:
|
||||||
self._start_process()
|
self._start_process()
|
||||||
self._wait_for_tty(previous_tty_identity, stop_event=stop_event)
|
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()
|
reader.open()
|
||||||
self._reader = reader
|
self._reader = reader
|
||||||
logger.info("Kamil ADC service opened")
|
logger.info("RadioPhotonic radar (RPH) service opened")
|
||||||
except Exception:
|
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()
|
self.close()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
"""Stop the TTY reader and the external collector process."""
|
"""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:
|
if self._reader is not None:
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
self._reader.close()
|
self._reader.close()
|
||||||
self._reader = None
|
self._reader = None
|
||||||
self._stop_process()
|
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:
|
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
|
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
|
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(
|
adc = self.config.radar.rph_radar
|
||||||
KamilAdcProcessingParams.from_kamil_model(self.config.radar.kamil_adc)
|
self._processor = RphRadarSweepProcessor(adc)
|
||||||
)
|
|
||||||
self._rejected_count = 0
|
self._rejected_count = 0
|
||||||
params = self._processor.params
|
phase, band = adc.phase_calibration, adc.band
|
||||||
logger.debug(
|
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)",
|
"(%.6g rad -> %.6g Hz, %.6g rad -> %.6g Hz)",
|
||||||
params.band_start_hz, params.band_stop_hz, params.band_points,
|
band.start_hz, band.stop_hz, band.points,
|
||||||
params.phase0_rad, params.freq0_hz, params.phase1_rad, params.freq1_hz,
|
phase.phase0_rad, phase.freq0_hz, phase.phase1_rad, phase.freq1_hz,
|
||||||
)
|
)
|
||||||
|
|
||||||
def read_device_limits(self) -> dict[str, float | int]:
|
def read_device_limits(self) -> dict[str, float | int]:
|
||||||
"""Kamil ADC has no runtime-readable sweep-limit API."""
|
"""RadioPhotonic radar (RPH) has no runtime-readable sweep-limit API."""
|
||||||
raise RuntimeError("Kamil ADC device limits are not available")
|
raise RuntimeError("RadioPhotonic radar (RPH) device limits are not available")
|
||||||
|
|
||||||
def acquire(self, combo: tuple[int, int] | None = None) -> SweepResult:
|
def acquire(self, combo: tuple[int, int] | None = None) -> SweepResult:
|
||||||
"""Return the next sweep that covers the band, as S21 on the fixed grid.
|
"""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.
|
single newest sweep is returned regardless of combination.
|
||||||
"""
|
"""
|
||||||
if self._processor is None:
|
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:
|
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
|
process = self._process
|
||||||
if process is None or process.poll() is not None:
|
if process is None or process.poll() is not None:
|
||||||
return_code = None if process is None else process.poll()
|
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
|
grid = self._processor.grid_hz
|
||||||
points = int(grid.size)
|
deadline = time.monotonic() + self.config.radar.rph_radar.sweep_timeout_s
|
||||||
deadline = time.monotonic() + self.config.radar.kamil_adc.sweep_timeout_s
|
|
||||||
while True:
|
while True:
|
||||||
remaining_s = deadline - time.monotonic()
|
remaining_s = deadline - time.monotonic()
|
||||||
if remaining_s <= 0.0:
|
if remaining_s <= 0.0:
|
||||||
raise TimeoutError(
|
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, combo=combo, process=process)
|
||||||
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)
|
|
||||||
s21 = self._processor.process(raw.main, raw.reference)
|
s21 = self._processor.process(raw.main, raw.reference)
|
||||||
if s21 is not None:
|
if s21 is not None:
|
||||||
|
self.last_cycle_id = raw.cycle_id
|
||||||
return SweepResult(
|
return SweepResult(
|
||||||
x=grid.copy(),
|
x=grid,
|
||||||
traces={
|
traces={
|
||||||
"s21": s21,
|
"s21": s21,
|
||||||
},
|
},
|
||||||
|
capture_start_ns=raw.capture_start_ns,
|
||||||
|
capture_end_ns=raw.capture_end_ns,
|
||||||
)
|
)
|
||||||
self._log_rejected_sweep(raw)
|
self._log_rejected_sweep(raw)
|
||||||
|
|
||||||
def prepare_combo(self, combo: ComboKey) -> None:
|
def prepare_combo(self, combo: ComboKey) -> None:
|
||||||
"""Discard buffered sweeps that predate or straddle the physical switch."""
|
"""Start a fresh ADC session after switching; retain phase branch tracking."""
|
||||||
self.drain_after_switch()
|
if self._prepared_combo == combo:
|
||||||
|
|
||||||
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:
|
|
||||||
return
|
return
|
||||||
target = self._reader.published_count + max(1, int(sweeps))
|
self.close()
|
||||||
deadline = time.monotonic() + self.config.radar.kamil_adc.sweep_timeout_s
|
if self.config.runtime.settling_ms < 1:
|
||||||
while self._reader.published_count < target:
|
time.sleep(.001) # The caller already waited any configured settling.
|
||||||
if time.monotonic() > deadline:
|
self.open(stop_event=self._stop_event)
|
||||||
raise TimeoutError("Timed out draining Kamil ADC sweeps after a switch change")
|
self._prepared_combo = combo
|
||||||
raise_if_process_exited(self._process)
|
|
||||||
time.sleep(0.005)
|
|
||||||
|
|
||||||
def read_raw_sweep(self) -> RawSweep:
|
def read_raw_sweep(self) -> RawSweep:
|
||||||
"""Return the next raw (main, reference) sweep without any processing.
|
"""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.
|
unprocessed reference samples. Raises if the service is not open.
|
||||||
"""
|
"""
|
||||||
if self._reader is None:
|
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(
|
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,
|
process=self._process,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -234,34 +214,45 @@ class KamilAdcService:
|
|||||||
def _start_process(self) -> None:
|
def _start_process(self) -> None:
|
||||||
if self._process is not None and self._process.poll() is None:
|
if self._process is not None and self._process.poll() is None:
|
||||||
return
|
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._process = subprocess.Popen(
|
||||||
self.command,
|
self.command,
|
||||||
cwd=str(self._resolve_project_dir()),
|
cwd=str(self._resolve_project_dir()),
|
||||||
env=self._build_env(),
|
env=self._build_env(),
|
||||||
stdin=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL,
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
start_new_session=True,
|
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:
|
def _stop_process(self) -> None:
|
||||||
process = self._process
|
process = self._process
|
||||||
self._process = None
|
|
||||||
if process is None or process.poll() is not None:
|
if process is None or process.poll() is not None:
|
||||||
|
self._process = None
|
||||||
return
|
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
|
# 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.
|
# 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):
|
with suppress(ProcessLookupError):
|
||||||
os.killpg(process.pid, signal.SIGTERM)
|
os.killpg(process.pid, signal.SIGTERM)
|
||||||
try:
|
try:
|
||||||
process.wait(timeout=grace_s)
|
process.wait(timeout=grace_s)
|
||||||
|
self._process = None
|
||||||
return
|
return
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
pass
|
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):
|
with suppress(ProcessLookupError):
|
||||||
os.killpg(process.pid, signal.SIGKILL)
|
os.killpg(process.pid, signal.SIGKILL)
|
||||||
# close() must never raise: a collector wedged in uninterruptible I/O
|
# 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.
|
# window even after SIGKILL. Best-effort wait; the OS reaps it eventually.
|
||||||
with suppress(subprocess.TimeoutExpired):
|
with suppress(subprocess.TimeoutExpired):
|
||||||
process.wait(timeout=1.0)
|
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(
|
def _wait_for_tty(
|
||||||
self,
|
self,
|
||||||
@@ -276,22 +270,22 @@ class KamilAdcService:
|
|||||||
*,
|
*,
|
||||||
stop_event: threading.Event | None = None,
|
stop_event: threading.Event | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
adc = self.config.radar.kamil_adc
|
adc = self.config.radar.rph_radar
|
||||||
deadline = time.monotonic() + adc.startup_timeout_s
|
deadline = time.monotonic() + adc.startup_timeout_s
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
if stop_event is not None and stop_event.is_set():
|
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)
|
raise_if_process_exited(self._process)
|
||||||
identity = _tty_identity(adc.tty_path)
|
identity = _tty_identity(adc.tty_path)
|
||||||
if identity is not None and identity != previous_identity:
|
if identity is not None and identity != previous_identity:
|
||||||
return
|
return
|
||||||
if stop_event is not None:
|
if stop_event is not None:
|
||||||
if stop_event.wait(0.05):
|
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:
|
else:
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
raise TimeoutError(
|
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:
|
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:
|
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
|
return self._resolve_path(project_dir) if project_dir else _REPO_ROOT
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -318,7 +312,7 @@ class KamilAdcService:
|
|||||||
``LD_LIBRARY_PATH`` itself, the default L-Card library directory is
|
``LD_LIBRARY_PATH`` itself, the default L-Card library directory is
|
||||||
prepended so the collector's dlopen of libx502api/libe502api succeeds.
|
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()
|
env = os.environ.copy()
|
||||||
for key, value in adc.env.items():
|
for key, value in adc.env.items():
|
||||||
env[key] = os.path.expandvars(os.path.expanduser(value))
|
env[key] = os.path.expandvars(os.path.expanduser(value))
|
||||||
@@ -333,33 +327,29 @@ class KamilAdcService:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _validate_config(self) -> None:
|
def _validate_config(self) -> None:
|
||||||
if not self.config.is_kamil_adc:
|
if not self.config.is_rph_radar:
|
||||||
raise RuntimeError("KamilAdcService requires radar.model='kamil_adc'")
|
raise RuntimeError("RphRadarService requires radar.model='rph_radar'")
|
||||||
if self.config.radar.driver_mode != "native":
|
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:
|
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:
|
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):
|
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")
|
raise ValueError("radar.rph_radar.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")
|
|
||||||
|
|
||||||
project_dir = self._resolve_project_dir()
|
project_dir = self._resolve_project_dir()
|
||||||
if not project_dir.is_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()
|
executable_path = self._resolve_executable()
|
||||||
if not executable_path.is_file():
|
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):
|
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:
|
def _log_rejected_sweep(self, raw) -> None:
|
||||||
"""Log a band-coverage rejection (throttled) with the measured span."""
|
"""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:
|
if self._rejected_count != 1 and self._rejected_count % _REJECT_LOG_EVERY != 0:
|
||||||
return
|
return
|
||||||
assert self._processor is not None
|
assert self._processor is not None
|
||||||
params = self._processor.params
|
band = self.config.radar.rph_radar.band
|
||||||
try:
|
try:
|
||||||
freqs = self._processor.reference_frequency_axis(raw.reference)
|
freqs = self._processor.reference_frequency_axis(raw.reference)
|
||||||
covered = f"[{float(np.min(freqs)):.6g}, {float(np.max(freqs)):.6g}]"
|
covered = f"[{float(np.min(freqs)):.6g}, {float(np.max(freqs)):.6g}]"
|
||||||
except Exception: # noqa: BLE001 — diagnostics must never raise
|
except Exception: # noqa: BLE001 — diagnostics must never raise
|
||||||
covered = "<unavailable>"
|
covered = "<unavailable>"
|
||||||
logger.warning(
|
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.",
|
"[%.6g, %.6g] Hz (usable points=%d). Check phase_calibration/band.",
|
||||||
self._rejected_count, covered, params.band_start_hz, params.band_stop_hz, raw.size,
|
self._rejected_count, covered, band.start_hz, band.stop_hz, raw.size,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
+22
-59
@@ -1,8 +1,8 @@
|
|||||||
"""Background TTY reader publishing the latest completed Kamil ADC sweep.
|
"""Background TTY reader publishing the latest completed RadioPhotonic radar (RPH) sweep.
|
||||||
|
|
||||||
The collector emits sweeps continuously, faster than callers invoke
|
The collector emits sweeps continuously, faster than callers invoke
|
||||||
:meth:`KamilAdcService.acquire`. A daemon thread drains the device end of the
|
:meth:`RphRadarService.acquire`. A daemon thread drains the device end of the
|
||||||
TTY non-stop, feeds the bytes to a :class:`KamilAdcStreamParser`, and stores 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`
|
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
|
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
|
overwrites the previous unread value — by design, since consumers always want the
|
||||||
@@ -21,7 +21,7 @@ import subprocess
|
|||||||
import threading
|
import threading
|
||||||
import time
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -39,11 +39,11 @@ def raise_if_process_exited(process: subprocess.Popen[bytes] | None) -> None:
|
|||||||
return
|
return
|
||||||
return_code = process.poll()
|
return_code = process.poll()
|
||||||
if return_code is not None:
|
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)
|
@dataclass(slots=True)
|
||||||
class KamilAdcTtyReader:
|
class RphRadarTtyReader:
|
||||||
"""Daemon-thread TTY reader publishing the latest completed sweep."""
|
"""Daemon-thread TTY reader publishing the latest completed sweep."""
|
||||||
|
|
||||||
tty_path: str
|
tty_path: str
|
||||||
@@ -54,7 +54,7 @@ class KamilAdcTtyReader:
|
|||||||
_latest_sweep: RawSweep | None = field(init=False, default=None, repr=False)
|
_latest_sweep: RawSweep | None = field(init=False, default=None, repr=False)
|
||||||
# Latest clean sweep per switch combination, for the switch-aware collector.
|
# Latest clean sweep per switch combination, for the switch-aware collector.
|
||||||
# read_sweep() ignores this and serves the single newest sweep (calibration /
|
# 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)
|
_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)
|
_reader_error: Exception | None = field(init=False, default=None, repr=False)
|
||||||
_published_count: int = field(init=False, default=0, repr=False)
|
_published_count: int = field(init=False, default=0, repr=False)
|
||||||
@@ -71,7 +71,7 @@ class KamilAdcTtyReader:
|
|||||||
self._published_count = 0
|
self._published_count = 0
|
||||||
self._thread = threading.Thread(
|
self._thread = threading.Thread(
|
||||||
target=self._reader_loop,
|
target=self._reader_loop,
|
||||||
name=f"kamil-adc-tty-reader[{self.tty_path}]",
|
name=f"rph-radar-tty-reader[{self.tty_path}]",
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
@@ -81,18 +81,18 @@ class KamilAdcTtyReader:
|
|||||||
fd, self._fd = self._fd, None
|
fd, self._fd = self._fd, None
|
||||||
os.close(fd)
|
os.close(fd)
|
||||||
raise
|
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:
|
def close(self) -> None:
|
||||||
"""Stop the reader thread and close the TTY descriptor."""
|
"""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()
|
self._stop_event.set()
|
||||||
with self._mailbox_cv:
|
with self._mailbox_cv:
|
||||||
self._mailbox_cv.notify_all()
|
self._mailbox_cv.notify_all()
|
||||||
if self._thread is not None:
|
if self._thread is not None:
|
||||||
self._thread.join(timeout=1.0)
|
self._thread.join(timeout=1.0)
|
||||||
if self._thread.is_alive():
|
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
|
self._thread = None
|
||||||
if self._fd is not None:
|
if self._fd is not None:
|
||||||
try:
|
try:
|
||||||
@@ -113,6 +113,7 @@ class KamilAdcTtyReader:
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
timeout_s: float,
|
timeout_s: float,
|
||||||
|
combo: tuple[int, int] | None = None,
|
||||||
process: subprocess.Popen[bytes] | None = None,
|
process: subprocess.Popen[bytes] | None = None,
|
||||||
) -> RawSweep:
|
) -> RawSweep:
|
||||||
"""Wait for and return the next published sweep.
|
"""Wait for and return the next published sweep.
|
||||||
@@ -122,52 +123,19 @@ class KamilAdcTtyReader:
|
|||||||
error caught by the reader thread.
|
error caught by the reader thread.
|
||||||
"""
|
"""
|
||||||
if self._thread is None:
|
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)
|
deadline = time.monotonic() + float(timeout_s)
|
||||||
with self._mailbox_cv:
|
with self._mailbox_cv:
|
||||||
while True:
|
while True:
|
||||||
if self._stop_event.is_set():
|
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
|
# Deliver a pending sweep first: if the reader both published a
|
||||||
# sweep and then died, the consumer still sees the good data and
|
# sweep and then died, the consumer still sees the good data and
|
||||||
# only meets the error on the next call.
|
# only meets the error on the next call.
|
||||||
if self._latest_sweep is not None:
|
sweep = self._latest_sweep if combo is None else self._combo_slots.pop(combo, 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)
|
|
||||||
if sweep is not None:
|
if sweep is not None:
|
||||||
|
if combo is None:
|
||||||
|
self._latest_sweep = None
|
||||||
return sweep
|
return sweep
|
||||||
if self._reader_error is not None:
|
if self._reader_error is not None:
|
||||||
raise self._reader_error
|
raise self._reader_error
|
||||||
@@ -175,18 +143,13 @@ class KamilAdcTtyReader:
|
|||||||
remaining_s = deadline - time.monotonic()
|
remaining_s = deadline - time.monotonic()
|
||||||
if remaining_s <= 0.0:
|
if remaining_s <= 0.0:
|
||||||
raise TimeoutError(
|
raise TimeoutError(
|
||||||
f"Timed out waiting for Kamil ADC sweep for combo {combo} "
|
f"Timed out waiting for RadioPhotonic radar (RPH) sweep after {float(timeout_s):.3f}s"
|
||||||
f"after {float(timeout_s):.3f}s"
|
|
||||||
)
|
)
|
||||||
self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s))
|
self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s))
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Reader-thread internals
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _reader_loop(self) -> None:
|
def _reader_loop(self) -> None:
|
||||||
"""Drain the TTY, parse sweeps, and publish each completed one until stop."""
|
"""Drain the TTY, parse sweeps, and publish each completed one until stop."""
|
||||||
parser = KamilAdcStreamParser()
|
parser = RphRadarStreamParser()
|
||||||
try:
|
try:
|
||||||
while not self._stop_event.is_set():
|
while not self._stop_event.is_set():
|
||||||
chunk = self._read_available()
|
chunk = self._read_available()
|
||||||
@@ -195,7 +158,7 @@ class KamilAdcTtyReader:
|
|||||||
for sweep in parser.feed(chunk):
|
for sweep in parser.feed(chunk):
|
||||||
self._publish_sweep(sweep)
|
self._publish_sweep(sweep)
|
||||||
except Exception as exc: # noqa: BLE001 — surfaced to the consumer via read_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)
|
self._publish_error(exc)
|
||||||
|
|
||||||
def _read_available(self) -> bytes:
|
def _read_available(self) -> bytes:
|
||||||
@@ -220,9 +183,9 @@ class KamilAdcTtyReader:
|
|||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
if exc.errno in {errno.EAGAIN, errno.EWOULDBLOCK}:
|
if exc.errno in {errno.EAGAIN, errno.EWOULDBLOCK}:
|
||||||
return b""
|
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:
|
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
|
return chunk
|
||||||
|
|
||||||
def _publish_sweep(self, sweep: RawSweep) -> None:
|
def _publish_sweep(self, sweep: RawSweep) -> None:
|
||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from typing import Protocol
|
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_driver.models import SweepResult
|
||||||
from python_app.hardware_full.librevna_service import LibreVnaService
|
from python_app.hardware_full.librevna_service import LibreVnaService
|
||||||
from python_app.models.dataset_model import ComboKey
|
from python_app.models.dataset_model import ComboKey
|
||||||
@@ -55,9 +55,9 @@ def create_single_radar_service(config: RunConfigModel) -> SingleRadarService:
|
|||||||
return PointTargetMockService(config)
|
return PointTargetMockService(config)
|
||||||
return LibreVnaService(serial=config.radar.serial or None)
|
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":
|
if config.radar.driver_mode != "native":
|
||||||
raise RuntimeError("Kamil ADC requires radar.driver_mode='native'")
|
raise RuntimeError("RadioPhotonic radar (RPH) requires radar.driver_mode='native'")
|
||||||
return KamilAdcService(config)
|
return RphRadarService(config)
|
||||||
|
|
||||||
raise RuntimeError(f"Unsupported single-radar model: {model}")
|
raise RuntimeError(f"Unsupported single-radar model: {model}")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
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_payload.get("locator_server", run_payload.get("locator")),
|
||||||
"run.locator_server",
|
"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")
|
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")
|
laser_control_payload = _as_dict(radar_payload.get("laser_control"), "radar.laser_control")
|
||||||
|
|
||||||
model.radar.model = _read_str(radar_payload, "model", model.radar.model)
|
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",
|
"stimulus_switch_positions",
|
||||||
model.radar.multi_device.stimulus_switch_positions,
|
model.radar.multi_device.stimulus_switch_positions,
|
||||||
)
|
)
|
||||||
model.radar.kamil_adc.project_dir = _read_str(
|
model.radar.rph_radar.project_dir = _read_str(
|
||||||
kamil_adc_payload, "project_dir", model.radar.kamil_adc.project_dir
|
rph_radar_payload, "project_dir", model.radar.rph_radar.project_dir
|
||||||
)
|
)
|
||||||
model.radar.kamil_adc.executable_path = _read_str(
|
model.radar.rph_radar.executable_path = _read_str(
|
||||||
kamil_adc_payload, "executable_path", model.radar.kamil_adc.executable_path
|
rph_radar_payload, "executable_path", model.radar.rph_radar.executable_path
|
||||||
)
|
)
|
||||||
model.radar.kamil_adc.tty_path = _read_str(
|
model.radar.rph_radar.tty_path = _read_str(
|
||||||
kamil_adc_payload, "tty_path", model.radar.kamil_adc.tty_path
|
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.rph_radar.args = _load_string_list(rph_radar_payload, "args", "radar.rph_radar")
|
||||||
model.radar.kamil_adc.env = _load_string_dict(kamil_adc_payload, "env", "radar.kamil_adc")
|
model.radar.rph_radar.env = _load_string_dict(rph_radar_payload, "env", "radar.rph_radar")
|
||||||
model.radar.kamil_adc.startup_timeout_s = _read_float(
|
model.radar.rph_radar.startup_timeout_s = _read_float(
|
||||||
kamil_adc_payload, "startup_timeout_s", model.radar.kamil_adc.startup_timeout_s
|
rph_radar_payload, "startup_timeout_s", model.radar.rph_radar.startup_timeout_s
|
||||||
)
|
)
|
||||||
model.radar.kamil_adc.sweep_timeout_s = _read_float(
|
model.radar.rph_radar.sweep_timeout_s = _read_float(
|
||||||
kamil_adc_payload, "sweep_timeout_s", model.radar.kamil_adc.sweep_timeout_s
|
rph_radar_payload, "sweep_timeout_s", model.radar.rph_radar.sweep_timeout_s
|
||||||
)
|
)
|
||||||
model.radar.kamil_adc.stop_timeout_s = _read_float(
|
model.radar.rph_radar.stop_timeout_s = _read_float(
|
||||||
kamil_adc_payload, "stop_timeout_s", model.radar.kamil_adc.stop_timeout_s
|
rph_radar_payload, "stop_timeout_s", model.radar.rph_radar.stop_timeout_s
|
||||||
)
|
)
|
||||||
phase_calibration_payload = _as_dict(
|
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.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.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.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)
|
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_payload = _as_dict(rph_radar_payload.get("band"), "radar.rph_radar.band")
|
||||||
band = model.radar.kamil_adc.band
|
band = model.radar.rph_radar.band
|
||||||
band.start_hz = _read_float(band_payload, "start_hz", band.start_hz)
|
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.stop_hz = _read_float(band_payload, "stop_hz", band.stop_hz)
|
||||||
band.points = _read_int(band_payload, "points", band.points)
|
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(
|
model.radar.laser_control.variation.delay_time = _read_int(
|
||||||
laser_variation_payload, "delay_time", model.radar.laser_control.variation.delay_time
|
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(port1_payload, model.output_switch)
|
||||||
load_switch_payload(port2_payload, model.input_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]:
|
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()
|
model.ensure_combos()
|
||||||
sweep_payload = {
|
radar = asdict(model.radar)
|
||||||
"start_hz": model.radar.sweep.start_hz,
|
sweep = radar["sweep"]
|
||||||
"stop_hz": model.radar.sweep.stop_hz,
|
sweep["stimulus_power_dbm"] = sweep.pop("power_dbm")
|
||||||
"if_bandwidth_hz": model.radar.sweep.if_bandwidth_hz,
|
if model.is_rph_radar:
|
||||||
"stimulus_power_dbm": model.radar.sweep.power_dbm,
|
sweep.pop("points")
|
||||||
"dwell_us": model.radar.sweep.dwell_us,
|
preprocess = asdict(model.preprocess)
|
||||||
}
|
preprocess["notch"]["bands_hz"] = [list(band) for band in model.preprocess.notch.bands_hz]
|
||||||
if not model.is_kamil_adc:
|
|
||||||
sweep_payload["points"] = model.radar.sweep.points
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"radar": {
|
"radar": radar,
|
||||||
"model": model.radar.model,
|
"switches": {"port1": asdict(model.output_switch), "port2": asdict(model.input_switch)},
|
||||||
"serial": model.radar.serial,
|
"control_button": asdict(model.control_button),
|
||||||
"driver_mode": model.radar.driver_mode,
|
"logging": asdict(model.logging),
|
||||||
"multi_device": {
|
"run": {**asdict(model.runtime), "combos": [asdict(combo) for combo in model.combos]},
|
||||||
"slave_serials": list(model.radar.multi_device.slave_serials),
|
"preprocess": preprocess,
|
||||||
"force_external_reference": model.radar.multi_device.force_external_reference,
|
"gpr": asdict(model.gpr),
|
||||||
"recovery_attempts": model.radar.multi_device.recovery_attempts,
|
"rings": asdict(model.rings),
|
||||||
"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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -45,7 +45,7 @@ class RadarMultiDeviceModel:
|
|||||||
"""Multi-device LibreVNA topology settings."""
|
"""Multi-device LibreVNA topology settings."""
|
||||||
|
|
||||||
slave_serials: list[str] = field(default_factory=list)
|
slave_serials: list[str] = field(default_factory=list)
|
||||||
force_external_reference: bool = True
|
force_external_reference: bool = False
|
||||||
recovery_attempts: int = 3
|
recovery_attempts: int = 3
|
||||||
output_switch_positions: int = 1 # 1 = свитча нет
|
output_switch_positions: int = 1 # 1 = свитча нет
|
||||||
input_switch_positions: int = 1 # 1 = свитча нет
|
input_switch_positions: int = 1 # 1 = свитча нет
|
||||||
@@ -56,7 +56,7 @@ class RadarMultiDeviceModel:
|
|||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class KamilAdcPhaseCalibrationModel:
|
class RphRadarPhaseCalibrationModel:
|
||||||
"""Affine law mapping the reference signal's unwrapped phase to frequency.
|
"""Affine law mapping the reference signal's unwrapped phase to frequency.
|
||||||
|
|
||||||
Two fixed anchor points ``(phase0_rad, freq0_hz)`` and ``(phase1_rad, freq1_hz)``
|
Two fixed anchor points ``(phase0_rad, freq0_hz)`` and ``(phase1_rad, freq1_hz)``
|
||||||
@@ -73,7 +73,7 @@ class KamilAdcPhaseCalibrationModel:
|
|||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class KamilAdcBandModel:
|
class RphRadarBandModel:
|
||||||
"""Fixed frequency window every sweep is cropped to and resampled onto.
|
"""Fixed frequency window every sweep is cropped to and resampled onto.
|
||||||
|
|
||||||
Each sweep is resampled onto ``linspace(start_hz, stop_hz, points)`` so all
|
Each sweep is resampled onto ``linspace(start_hz, stop_hz, points)`` so all
|
||||||
@@ -88,8 +88,8 @@ class KamilAdcBandModel:
|
|||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class KamilAdcModel:
|
class RphRadarModel:
|
||||||
"""External Kamil ADC acquisition process settings."""
|
"""External RadioPhotonic radar (RPH) acquisition process settings."""
|
||||||
|
|
||||||
project_dir: str = ""
|
project_dir: str = ""
|
||||||
executable_path: str = ""
|
executable_path: str = ""
|
||||||
@@ -99,10 +99,10 @@ class KamilAdcModel:
|
|||||||
startup_timeout_s: float = 5.0
|
startup_timeout_s: float = 5.0
|
||||||
sweep_timeout_s: float = 5.0
|
sweep_timeout_s: float = 5.0
|
||||||
stop_timeout_s: float = 2.0
|
stop_timeout_s: float = 2.0
|
||||||
phase_calibration: KamilAdcPhaseCalibrationModel = field(
|
phase_calibration: RphRadarPhaseCalibrationModel = field(
|
||||||
default_factory=KamilAdcPhaseCalibrationModel
|
default_factory=RphRadarPhaseCalibrationModel
|
||||||
)
|
)
|
||||||
band: KamilAdcBandModel = field(default_factory=KamilAdcBandModel)
|
band: RphRadarBandModel = field(default_factory=RphRadarBandModel)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -129,14 +129,11 @@ class LaserVariationModeModel:
|
|||||||
step: float = 0.1
|
step: float = 0.1
|
||||||
time_step: int = 20
|
time_step: int = 20
|
||||||
delay_time: int = 3
|
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)
|
@dataclass(slots=True)
|
||||||
class LaserControlModel:
|
class LaserControlModel:
|
||||||
"""Laser-control board settings applied before Kamil ADC acquisition."""
|
"""Laser-control board settings applied before RadioPhotonic radar (RPH) acquisition."""
|
||||||
|
|
||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
port: str = ""
|
port: str = ""
|
||||||
@@ -158,7 +155,7 @@ class RadarModel:
|
|||||||
driver_mode: str = "mock"
|
driver_mode: str = "mock"
|
||||||
sweep: RadarSweepModel = field(default_factory=RadarSweepModel)
|
sweep: RadarSweepModel = field(default_factory=RadarSweepModel)
|
||||||
multi_device: RadarMultiDeviceModel = field(default_factory=RadarMultiDeviceModel)
|
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)
|
laser_control: LaserControlModel = field(default_factory=LaserControlModel)
|
||||||
|
|
||||||
|
|
||||||
@@ -337,7 +334,7 @@ class RunConfigModel:
|
|||||||
|
|
||||||
LIBREVNA_MODEL = "librevna"
|
LIBREVNA_MODEL = "librevna"
|
||||||
LIBREVNA_MULTI_MODEL = "librevna_multi"
|
LIBREVNA_MULTI_MODEL = "librevna_multi"
|
||||||
KAMIL_ADC_MODEL = "kamil_adc"
|
RPH_RADAR_MODEL = "rph_radar"
|
||||||
MULTI_DEVICE_INPUT_POSITIONS = 4
|
MULTI_DEVICE_INPUT_POSITIONS = 4
|
||||||
MULTI_DEVICE_OUTPUT_POSITIONS = 2
|
MULTI_DEVICE_OUTPUT_POSITIONS = 2
|
||||||
# Stimulus ports the master itself owns. The virtual output axis is this times the
|
# 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)
|
return self.build_full_combos(self.input_switch.positions, self.output_switch.positions)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_kamil_adc(self) -> bool:
|
def is_rph_radar(self) -> bool:
|
||||||
"""Return whether this config targets the external Kamil ADC acquisition path."""
|
"""Return whether this config targets the external RadioPhotonic radar (RPH) acquisition path."""
|
||||||
return self.radar.model == self.KAMIL_ADC_MODEL
|
return self.radar.model == self.RPH_RADAR_MODEL
|
||||||
|
|
||||||
def radar_key(self) -> str:
|
def radar_key(self) -> str:
|
||||||
"""Stable hardware/sweep identity used to find saved preprocess sets."""
|
"""Stable hardware/sweep identity used to find saved preprocess sets."""
|
||||||
radar, sweep = self.radar, self.radar.sweep
|
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 = [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)
|
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 (
|
return (
|
||||||
f"{radar.model}_{serial}"
|
f"{radar.model}_{serial}"
|
||||||
f"_st{_format_float_for_key(sweep.start_hz)}_sp{_format_float_for_key(sweep.stop_hz)}"
|
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"_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:
|
def apply_device_model_constraints(self) -> None:
|
||||||
"""Apply only required wire-format constraints for the selected device model."""
|
"""Apply only required wire-format constraints for the selected device model."""
|
||||||
if not self.is_multi_device:
|
if not self.is_multi_device:
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from python_app.models.run_config_schema import (
|
|||||||
GprModel,
|
GprModel,
|
||||||
GprRxGeometryModel,
|
GprRxGeometryModel,
|
||||||
GprTxGeometryModel,
|
GprTxGeometryModel,
|
||||||
|
RphRadarModel,
|
||||||
RadarSweepModel,
|
RadarSweepModel,
|
||||||
RingEndpointModel,
|
RingEndpointModel,
|
||||||
RunConfigModel,
|
RunConfigModel,
|
||||||
@@ -129,12 +130,15 @@ def validate_acquisition_config(config: RunConfigModel) -> None:
|
|||||||
if mode not in ("mock", "native"):
|
if mode not in ("mock", "native"):
|
||||||
raise ValueError("radar.driver_mode must be mock or native")
|
raise ValueError("radar.driver_mode must be mock or native")
|
||||||
if config.radar.model not in (
|
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}")
|
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")
|
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 (
|
for switch, physical_positions in (
|
||||||
(config.input_switch, config.matrix_input_switch_positions),
|
(config.input_switch, config.matrix_input_switch_positions),
|
||||||
(config.output_switch, config.matrix_output_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:
|
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")
|
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.
|
# RAW3/PRP3 header, frequency/S21 arrays and capture windows.
|
||||||
minimum = 44 + len(config.combos) * (28 + 12 * points)
|
minimum = 44 + len(config.combos) * (28 + 12 * points)
|
||||||
for name in ("raw", "raw_tap", "preprocessed", "preprocessed_tap"):
|
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")
|
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:
|
def validate_sweep_model(sweep: RadarSweepModel) -> None:
|
||||||
"""Validate radar sweep bounds (point count and frequency span).
|
"""Validate radar sweep bounds (point count and frequency span).
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,7 @@ logger = logging.getLogger(__name__)
|
|||||||
_LOG_MAX_BYTES = 8 * 1024 * 1024
|
_LOG_MAX_BYTES = 8 * 1024 * 1024
|
||||||
# Per-process force-kill deadline used on stop; each child gets its own window.
|
# Per-process force-kill deadline used on stop; each child gets its own window.
|
||||||
_STOP_GRACE_SECONDS = 2.0
|
_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)
|
@dataclass(slots=True)
|
||||||
@@ -143,9 +140,6 @@ class ProcessSupervisor:
|
|||||||
"""Start required pipeline binaries and wait until they are ready."""
|
"""Start required pipeline binaries and wait until they are ready."""
|
||||||
if self.is_running():
|
if self.is_running():
|
||||||
raise RuntimeError("Acquisition processes are already 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(
|
logger.info(
|
||||||
"Starting pipeline from config %s (allow_clean_acquisition_exit=%s)",
|
"Starting pipeline from config %s (allow_clean_acquisition_exit=%s)",
|
||||||
@@ -197,7 +191,6 @@ class ProcessSupervisor:
|
|||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
"""Stop acquisition-side processes, keep processor process intact."""
|
"""Stop acquisition-side processes, keep processor process intact."""
|
||||||
self._stop_processes(["acquisition", "data_preprocessor"])
|
self._stop_processes(["acquisition", "data_preprocessor"])
|
||||||
self._kill_kamil_collectors()
|
|
||||||
|
|
||||||
def stop_acquisition(self) -> None:
|
def stop_acquisition(self) -> None:
|
||||||
"""Stop acquisition process only."""
|
"""Stop acquisition process only."""
|
||||||
@@ -210,7 +203,6 @@ class ProcessSupervisor:
|
|||||||
def stop_all(self) -> None:
|
def stop_all(self) -> None:
|
||||||
"""Stop all managed processes."""
|
"""Stop all managed processes."""
|
||||||
self._stop_processes(["acquisition", "data_preprocessor", "data_processor"])
|
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:
|
def _spawn(self, name: str, command: list[str], *, allow_clean_exit: bool) -> None:
|
||||||
"""Spawn one process unless same process is already alive.
|
"""Spawn one process unless same process is already alive.
|
||||||
@@ -339,34 +331,6 @@ class ProcessSupervisor:
|
|||||||
except (ProcessLookupError, PermissionError):
|
except (ProcessLookupError, PermissionError):
|
||||||
pass
|
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
|
@staticmethod
|
||||||
def _log_abnormal_stop_exit(process: ManagedProcess) -> None:
|
def _log_abnormal_stop_exit(process: ManagedProcess) -> None:
|
||||||
"""Log a process that self-exited abnormally around stop time.
|
"""Log a process that self-exited abnormally around stop time.
|
||||||
@@ -542,9 +506,6 @@ class ProcessSupervisor:
|
|||||||
atomic_write_text(self._pidfile_path, "")
|
atomic_write_text(self._pidfile_path, "")
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
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:
|
def _is_stale_pipeline_pid(self, pid: int) -> bool:
|
||||||
"""Return whether `pid` still runs one of our pipeline binaries/scripts.
|
"""Return whether `pid` still runs one of our pipeline binaries/scripts.
|
||||||
@@ -555,7 +516,7 @@ class ProcessSupervisor:
|
|||||||
native_names = {"data_processor", "data_preprocessor"}
|
native_names = {"data_processor", "data_preprocessor"}
|
||||||
producer_modules = {
|
producer_modules = {
|
||||||
"python_app.scripts.matrix_raw_producer",
|
"python_app.scripts.matrix_raw_producer",
|
||||||
"python_app.scripts.kamil_adc_raw_producer",
|
"python_app.scripts.rph_radar_raw_producer",
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
raw = (Path("/proc") / str(pid) / "cmdline").read_bytes()
|
raw = (Path("/proc") / str(pid) / "cmdline").read_bytes()
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ def acquisition_command(config_path: Path) -> list[str]:
|
|||||||
producers = {
|
producers = {
|
||||||
"librevna": "matrix_raw_producer",
|
"librevna": "matrix_raw_producer",
|
||||||
"librevna_multi": "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:
|
if model not in producers:
|
||||||
raise ValueError(f"Unsupported radar model: {model}")
|
raise ValueError(f"Unsupported radar model: {model}")
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
"""Standalone laser temperature checker process.
|
|
||||||
|
|
||||||
Reads the target setpoints frozen at variation start and tails the JSONL readings
|
|
||||||
channel produced by the monitor. For every reading it validates both lasers and
|
|
||||||
prints a console warning whenever a measured temperature drifts from its target by
|
|
||||||
more than the tolerance (default 0.03 °C). Never touches the serial port, so it is
|
|
||||||
fully independent of the monitor and can be started/stopped at any time.
|
|
||||||
|
|
||||||
Example::
|
|
||||||
|
|
||||||
python -m python_app.scripts.laser_temp_checker
|
|
||||||
python -m python_app.scripts.laser_temp_checker --session s.json --readings r.jsonl
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import logging
|
|
||||||
import signal
|
|
||||||
import threading
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from python_app.hardware_full.laser_control.monitoring import (
|
|
||||||
DEFAULT_READINGS_PATH,
|
|
||||||
DEFAULT_SESSION_PATH,
|
|
||||||
LaserTemperatureChecker,
|
|
||||||
LaserVariationSession,
|
|
||||||
ReadingReader,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger("laser_temp_checker")
|
|
||||||
|
|
||||||
_POLL_INTERVAL_S = 0.2
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser(description="Validate laser temperature against setpoints")
|
|
||||||
parser.add_argument("--session", type=Path, default=DEFAULT_SESSION_PATH,
|
|
||||||
help="Session snapshot with target setpoints + tolerance")
|
|
||||||
parser.add_argument("--readings", type=Path, default=DEFAULT_READINGS_PATH,
|
|
||||||
help="JSONL readings channel to tail")
|
|
||||||
parser.add_argument("--tolerance", type=float, default=None,
|
|
||||||
help="Override tolerance in °C (default: from session)")
|
|
||||||
parser.add_argument("--reminder-every", type=int, default=0,
|
|
||||||
help="Repeat a warning every N readings while off target (0=off)")
|
|
||||||
parser.add_argument("--from-start", action="store_true",
|
|
||||||
help="Validate the whole readings file, not just new lines")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
||||||
|
|
||||||
session = LaserVariationSession.load(args.session)
|
|
||||||
if args.tolerance is not None:
|
|
||||||
session.tolerance_c = args.tolerance
|
|
||||||
checker = LaserTemperatureChecker.from_session(session, reminder_every=args.reminder_every)
|
|
||||||
logger.info(
|
|
||||||
"Checking against T1=%.3f T2=%.3f °C, tolerance ±%.3f °C (%s)",
|
|
||||||
session.target_temp1, session.target_temp2, session.tolerance_c, session.variation_type,
|
|
||||||
)
|
|
||||||
|
|
||||||
reader = ReadingReader(args.readings, from_start=args.from_start)
|
|
||||||
stop_event = threading.Event()
|
|
||||||
|
|
||||||
def request_stop(_signum: int, _frame: object) -> None:
|
|
||||||
stop_event.set()
|
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, request_stop)
|
|
||||||
signal.signal(signal.SIGTERM, request_stop)
|
|
||||||
|
|
||||||
while not stop_event.is_set():
|
|
||||||
for reading in reader.poll():
|
|
||||||
checker.process(reading)
|
|
||||||
stop_event.wait(_POLL_INTERVAL_S)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
"""Standalone laser temperature monitor process.
|
|
||||||
|
|
||||||
Owns the laser serial port, (optionally) starts a current-variation task, then
|
|
||||||
polls the board once per sweep and appends each reading to a JSONL channel that
|
|
||||||
the temperature checker tails. Runs until SIGINT/SIGTERM.
|
|
||||||
|
|
||||||
Examples::
|
|
||||||
|
|
||||||
# Start LD1 current variation from a run config, then monitor:
|
|
||||||
python -m python_app.scripts.laser_temp_monitor --config run_config.json --start
|
|
||||||
|
|
||||||
# Monitor a variation that is already running:
|
|
||||||
python -m python_app.scripts.laser_temp_monitor --config run_config.json
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import logging
|
|
||||||
import signal
|
|
||||||
import threading
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from python_app.hardware_full.laser_control.controller import (
|
|
||||||
DEVICE_MAIN_MESSAGE_ID,
|
|
||||||
LaserController,
|
|
||||||
)
|
|
||||||
from python_app.hardware_full.laser_control.exceptions import PortBusyError
|
|
||||||
from python_app.hardware_full.laser_control.models import VariationType
|
|
||||||
from python_app.hardware_full.laser_control.monitoring import (
|
|
||||||
DEFAULT_READINGS_PATH,
|
|
||||||
DEFAULT_SESSION_PATH,
|
|
||||||
LaserTemperatureMonitor,
|
|
||||||
LaserVariationSession,
|
|
||||||
ReadingWriter,
|
|
||||||
resolve_period_s,
|
|
||||||
)
|
|
||||||
from python_app.models.run_config_schema import RunConfigModel
|
|
||||||
|
|
||||||
logger = logging.getLogger("laser_temp_monitor")
|
|
||||||
|
|
||||||
|
|
||||||
def _start_variation(controller: LaserController, variation) -> None:
|
|
||||||
"""Send the CHANGE_CURRENT_LD1 task and freeze the session snapshot."""
|
|
||||||
controller.reset()
|
|
||||||
controller.set_manual_mode(
|
|
||||||
temp1=variation.static_temp1,
|
|
||||||
temp2=variation.static_temp2,
|
|
||||||
current1=variation.static_current1,
|
|
||||||
current2=variation.static_current2,
|
|
||||||
message_id=DEVICE_MAIN_MESSAGE_ID,
|
|
||||||
)
|
|
||||||
controller.start_variation(
|
|
||||||
variation_type=VariationType[variation.variation_type],
|
|
||||||
params={
|
|
||||||
"static_temp1": variation.static_temp1,
|
|
||||||
"static_temp2": variation.static_temp2,
|
|
||||||
"static_current1": variation.static_current1,
|
|
||||||
"static_current2": variation.static_current2,
|
|
||||||
"min_value": variation.min_value,
|
|
||||||
"max_value": variation.max_value,
|
|
||||||
"step": variation.step,
|
|
||||||
"time_step": variation.time_step,
|
|
||||||
"delay_time": variation.delay_time,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser(description="Poll laser temperature once per sweep")
|
|
||||||
parser.add_argument("--config", required=True, type=Path, help="Path to run_config.json")
|
|
||||||
parser.add_argument("--readings", type=Path, default=DEFAULT_READINGS_PATH,
|
|
||||||
help="JSONL readings channel to append to")
|
|
||||||
parser.add_argument("--session", type=Path, default=DEFAULT_SESSION_PATH,
|
|
||||||
help="Session snapshot path (written with --start)")
|
|
||||||
parser.add_argument("--strategy", default="computed",
|
|
||||||
help="'computed' (per sweep) or 'interval:<ms>'")
|
|
||||||
parser.add_argument("--start", action="store_true",
|
|
||||||
help="Send CHANGE_CURRENT_LD1 before monitoring")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
||||||
|
|
||||||
config = RunConfigModel.load_from_path(args.config)
|
|
||||||
laser = config.radar.laser_control
|
|
||||||
variation = laser.variation
|
|
||||||
if variation.variation_type != "CHANGE_CURRENT_LD1":
|
|
||||||
logger.warning(
|
|
||||||
"Only CHANGE_CURRENT_LD1 is supported by firmware; got %s",
|
|
||||||
variation.variation_type,
|
|
||||||
)
|
|
||||||
|
|
||||||
period_s = resolve_period_s(
|
|
||||||
args.strategy,
|
|
||||||
min_value=variation.min_value,
|
|
||||||
max_value=variation.max_value,
|
|
||||||
step=variation.step,
|
|
||||||
time_step_us=variation.time_step,
|
|
||||||
delay_time_ms=variation.delay_time,
|
|
||||||
)
|
|
||||||
|
|
||||||
stop_event = threading.Event()
|
|
||||||
|
|
||||||
def request_stop(_signum: int, _frame: object) -> None:
|
|
||||||
stop_event.set()
|
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, request_stop)
|
|
||||||
signal.signal(signal.SIGTERM, request_stop)
|
|
||||||
|
|
||||||
controller = LaserController(
|
|
||||||
port=laser.port or None,
|
|
||||||
pi_coeff1_p=laser.pi_coeff1_p,
|
|
||||||
pi_coeff1_i=laser.pi_coeff1_i,
|
|
||||||
pi_coeff2_p=laser.pi_coeff2_p,
|
|
||||||
pi_coeff2_i=laser.pi_coeff2_i,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
controller.connect()
|
|
||||||
except PortBusyError as exc:
|
|
||||||
# Expected, benign conflict: the manual-control UI (or another monitor)
|
|
||||||
# already owns the port. Exit cleanly with guidance, not a traceback.
|
|
||||||
logger.error("%s", exc)
|
|
||||||
return 2
|
|
||||||
try:
|
|
||||||
if args.start:
|
|
||||||
_start_variation(controller, variation)
|
|
||||||
LaserVariationSession(
|
|
||||||
variation_type=variation.variation_type,
|
|
||||||
target_temp1=variation.static_temp1,
|
|
||||||
target_temp2=variation.static_temp2,
|
|
||||||
tolerance_c=variation.temp_tolerance_c,
|
|
||||||
started_at_iso=datetime.now().isoformat(timespec="seconds"),
|
|
||||||
).save(args.session)
|
|
||||||
logger.info("Started CHANGE_CURRENT_LD1 and wrote session %s", args.session)
|
|
||||||
|
|
||||||
with ReadingWriter(args.readings) as writer:
|
|
||||||
monitor = LaserTemperatureMonitor(
|
|
||||||
controller=controller, writer=writer, period_s=period_s
|
|
||||||
)
|
|
||||||
logger.info("Monitoring to %s (period=%.3fs)", args.readings, period_s)
|
|
||||||
monitor.run(stop_event)
|
|
||||||
finally:
|
|
||||||
controller.disconnect()
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Measure only RPH reference phase anchors using the current acquisition settings.
|
||||||
|
|
||||||
|
Start the laser with Apply Radar and stop acquisition before running this tool.
|
||||||
|
No laser commands are sent. --apply replaces only phase0_rad and phase1_rad;
|
||||||
|
frequency anchors, band, collector settings and JSON formatting are preserved.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from python_app.file_io import atomic_write_text
|
||||||
|
from python_app.hardware_full.rph_radar.service import RphRadarService
|
||||||
|
from python_app.models.run_config_schema import RunConfigModel
|
||||||
|
|
||||||
|
|
||||||
|
def _value_span(text: str, path: tuple[str, ...]) -> tuple[int, int]:
|
||||||
|
"""Locate a unique JSON member without reserializing its neighbours."""
|
||||||
|
decoder = json.JSONDecoder()
|
||||||
|
def skip_space(index: int) -> int:
|
||||||
|
while text[index] in " \t\r\n":
|
||||||
|
index += 1
|
||||||
|
return index
|
||||||
|
|
||||||
|
start, end = 0, len(text)
|
||||||
|
for member in path:
|
||||||
|
start = skip_space(start)
|
||||||
|
if text[start] != "{":
|
||||||
|
raise ValueError(f"Expected JSON object before {member}")
|
||||||
|
cursor, found = start + 1, None
|
||||||
|
while True:
|
||||||
|
cursor = skip_space(cursor)
|
||||||
|
if text[cursor] == "}":
|
||||||
|
break
|
||||||
|
key, cursor = decoder.raw_decode(text, cursor)
|
||||||
|
cursor = skip_space(cursor) + 1 # colon; JSON already validated
|
||||||
|
cursor = skip_space(cursor)
|
||||||
|
_, next_cursor = decoder.raw_decode(text, cursor)
|
||||||
|
if key == member:
|
||||||
|
if found is not None:
|
||||||
|
raise ValueError(f"Duplicate JSON member: {member}")
|
||||||
|
found = (cursor, next_cursor)
|
||||||
|
cursor = skip_space(next_cursor)
|
||||||
|
if text[cursor] == "}":
|
||||||
|
break
|
||||||
|
cursor += 1 # comma
|
||||||
|
if found is None:
|
||||||
|
raise ValueError(f"Profile must contain explicit {'.'.join(path)}")
|
||||||
|
start, end = found
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
def calibrate(config: RunConfigModel, sweeps: int, warmup: int) -> dict[str, float]:
|
||||||
|
"""Median endpoints after aligning each sweep to the preceding phase branch."""
|
||||||
|
if sweeps < 1 or warmup < 0:
|
||||||
|
raise ValueError("sweeps must be positive and warmup nonnegative")
|
||||||
|
if not config.is_rph_radar or config.radar.driver_mode != "native":
|
||||||
|
raise ValueError("Calibration requires a native rph_radar profile")
|
||||||
|
previous = config.radar.rph_radar.phase_calibration.phase0_rad
|
||||||
|
endpoints = []
|
||||||
|
service = RphRadarService(config)
|
||||||
|
try:
|
||||||
|
service.open()
|
||||||
|
for index in range(warmup + sweeps):
|
||||||
|
raw = service.read_raw_sweep()
|
||||||
|
if index < warmup:
|
||||||
|
continue
|
||||||
|
reference = np.asarray(raw.reference, dtype=np.complex128)
|
||||||
|
if (raw.dirty or reference.ndim != 1 or reference.size < 2
|
||||||
|
or not np.all(np.isfinite(reference)) or np.any(np.abs(reference) <= 1e-9)):
|
||||||
|
raise ValueError("Invalid reference sweep; phase calibration was not changed")
|
||||||
|
phase = np.unwrap(np.angle(reference))
|
||||||
|
phase += round((previous - float(phase[0])) / (2 * np.pi)) * (2 * np.pi)
|
||||||
|
if abs(float(phase[-1] - phase[0])) <= 1e-9:
|
||||||
|
raise ValueError("Degenerate reference phase span")
|
||||||
|
endpoints.append((phase[0], phase[-1]))
|
||||||
|
previous = float(phase[0])
|
||||||
|
finally:
|
||||||
|
service.close()
|
||||||
|
phase0, phase1 = np.median(endpoints, axis=0)
|
||||||
|
if abs(float(phase1 - phase0)) <= 1e-9:
|
||||||
|
raise ValueError("Degenerate median phase span")
|
||||||
|
return {"phase0_rad": float(phase0), "phase1_rad": float(phase1)}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Calibrate RadioPhotonic radar (RPH) phase anchors")
|
||||||
|
parser.add_argument("--config", required=True, type=Path)
|
||||||
|
parser.add_argument("--sweeps", type=int, default=200)
|
||||||
|
parser.add_argument("--warmup", type=int, default=10)
|
||||||
|
parser.add_argument("--apply", action="store_true", help="Write only the two phase anchors")
|
||||||
|
args = parser.parse_args()
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
||||||
|
original = args.config.read_bytes()
|
||||||
|
text = original.decode("utf-8")
|
||||||
|
config = RunConfigModel.from_dict(json.loads(text))
|
||||||
|
fields = ("phase0_rad", "phase1_rad")
|
||||||
|
spans = {name: _value_span(text, ("radar", "rph_radar", "phase_calibration", name)) for name in fields}
|
||||||
|
result = calibrate(config, args.sweeps, args.warmup)
|
||||||
|
print(json.dumps(result, allow_nan=False))
|
||||||
|
if args.apply:
|
||||||
|
for name, (start, end) in sorted(spans.items(), key=lambda item: item[1][0], reverse=True):
|
||||||
|
text = text[:start] + json.dumps(result[name], allow_nan=False) + text[end:]
|
||||||
|
if args.config.read_bytes() != original:
|
||||||
|
raise RuntimeError("Profile changed during capture; refusing to overwrite it")
|
||||||
|
atomic_write_text(args.config, text, durable=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+31
-32
@@ -1,4 +1,4 @@
|
|||||||
"""Raw acquisition producer for the external Kamil ADC radar mode."""
|
"""Raw acquisition producer for RadioPhotonic radar (RPH)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ import time
|
|||||||
|
|
||||||
import numpy as np
|
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.hardware_full.switch_service import SwitchService, select_and_settle
|
||||||
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
|
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
|
||||||
from python_app.models.run_config_schema import RunConfigModel
|
from python_app.models.run_config_schema import RunConfigModel
|
||||||
@@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
def _open_radar_with_retry(
|
def _open_radar_with_retry(
|
||||||
config: RunConfigModel,
|
config: RunConfigModel,
|
||||||
radar: KamilAdcService,
|
radar: RphRadarService,
|
||||||
input_switch: SwitchService | None,
|
input_switch: SwitchService | None,
|
||||||
output_switch: SwitchService | None,
|
output_switch: SwitchService | None,
|
||||||
stop_requested: threading.Event,
|
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."""
|
"""Reopen the ADC and Python-owned switches, cleaning partial opens before retry."""
|
||||||
close_devices(input_switch, output_switch, radar)
|
close_devices(input_switch, output_switch, radar)
|
||||||
|
|
||||||
def attempt() -> KamilAdcService:
|
def attempt() -> RphRadarService:
|
||||||
try:
|
try:
|
||||||
radar.open(stop_event=stop_requested)
|
radar.open(stop_event=stop_requested)
|
||||||
radar.configure(config.radar.sweep)
|
radar.configure(config.radar.sweep)
|
||||||
@@ -41,16 +41,16 @@ def _open_radar_with_retry(
|
|||||||
raise
|
raise
|
||||||
return radar
|
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:
|
def main() -> int:
|
||||||
"""Run producer process until config or signal requests exit."""
|
"""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")
|
config_path, config, stop_requested = load_producer_config("Publish RadioPhotonic radar (RPH) raw sweeps to SHM rings")
|
||||||
if not config.is_kamil_adc:
|
if not config.is_rph_radar:
|
||||||
raise RuntimeError("kamil_adc_raw_producer requires radar.model='kamil_adc'")
|
raise RuntimeError("rph_radar_raw_producer requires radar.model='rph_radar'")
|
||||||
logger.info(
|
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,
|
config_path, len(config.combos), config.runtime.continuous,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,24 +59,21 @@ def main() -> int:
|
|||||||
try:
|
try:
|
||||||
publisher = RawPublisher(config.rings, cleanup)
|
publisher = RawPublisher(config.rings, cleanup)
|
||||||
# Switch-aware mode: with native switches the collector drives the RF switches
|
# 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
|
# itself and tags each sweep with its combo and source cycle. Otherwise Python
|
||||||
# — no sweep lost at a switch boundary. The producer then only reads tagged
|
# owns the switches and starts a fresh collector session after each changed pair.
|
||||||
# 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.
|
|
||||||
collector_driven = (
|
collector_driven = (
|
||||||
config.input_switch.driver_mode == "native"
|
config.input_switch.driver_mode == "native"
|
||||||
and config.output_switch.driver_mode == "native"
|
and config.output_switch.driver_mode == "native"
|
||||||
)
|
)
|
||||||
radar = KamilAdcService(
|
radar = RphRadarService(
|
||||||
config,
|
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)
|
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)
|
output_switch = None if collector_driven else SwitchService.from_model(config.output_switch)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Kamil ADC switch control: %s",
|
"RadioPhotonic radar (RPH) switch control: %s",
|
||||||
"collector-driven (in-gap, lossless)" if collector_driven else "producer-driven",
|
"collector-driven" if collector_driven else "producer-driven",
|
||||||
)
|
)
|
||||||
|
|
||||||
if not _open_radar_with_retry(config, radar, input_switch, output_switch, stop_requested):
|
if not _open_radar_with_retry(config, radar, input_switch, output_switch, stop_requested):
|
||||||
@@ -85,8 +82,8 @@ def main() -> int:
|
|||||||
collection_id = 1
|
collection_id = 1
|
||||||
while not stop_requested.is_set():
|
while not stop_requested.is_set():
|
||||||
collection_start = time.monotonic()
|
collection_start = time.monotonic()
|
||||||
capture_start_ns = time.monotonic_ns()
|
|
||||||
traces: list[TraceData] = []
|
traces: list[TraceData] = []
|
||||||
|
cycle_id = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for combo in config.combos:
|
for combo in config.combos:
|
||||||
@@ -95,30 +92,30 @@ def main() -> int:
|
|||||||
if collector_driven:
|
if collector_driven:
|
||||||
# The collector already switched and tagged the sweep; just
|
# The collector already switched and tagged the sweep; just
|
||||||
# read the clean capture for this combination.
|
# read the clean capture for this combination.
|
||||||
sweep_start_ns = time.monotonic_ns()
|
|
||||||
sweep = radar.acquire(combo=(combo.input, combo.output))
|
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:
|
else:
|
||||||
if input_switch is None or output_switch is None:
|
if input_switch is None or output_switch is None:
|
||||||
raise RuntimeError("Producer-driven switches are not initialized")
|
raise RuntimeError("Producer-driven switches are not initialized")
|
||||||
if not select_and_settle(output_switch, input_switch, combo.output, combo.input,
|
if not select_and_settle(output_switch, input_switch, combo.output, combo.input,
|
||||||
config.runtime.settling_ms, wait=stop_requested.wait):
|
config.runtime.settling_ms, wait=stop_requested.wait):
|
||||||
break
|
break
|
||||||
# Stamped after switching and settling so the window covers
|
radar.prepare_combo(ComboKey(combo.input, combo.output))
|
||||||
# the sweep alone, not the dead time before it.
|
|
||||||
sweep_start_ns = time.monotonic_ns()
|
|
||||||
sweep = radar.acquire()
|
sweep = radar.acquire()
|
||||||
traces.append(
|
traces.append(
|
||||||
TraceData(
|
TraceData(
|
||||||
combo=ComboKey(input=combo.input, output=combo.output),
|
combo=ComboKey(input=combo.input, output=combo.output),
|
||||||
frequency_hz=np.asarray(sweep.x, dtype=np.float32),
|
frequency_hz=np.asarray(sweep.x, dtype=np.float32),
|
||||||
s21=np.asarray(sweep.trace("s21"), dtype=np.complex64),
|
s21=np.asarray(sweep.trace("s21"), dtype=np.complex64),
|
||||||
capture_start_ns=sweep_start_ns,
|
capture_start_ns=sweep.capture_start_ns,
|
||||||
capture_end_ns=time.monotonic_ns(),
|
capture_end_ns=sweep.capture_end_ns,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001 — reconnect forever, never give up
|
except Exception as exc: # noqa: BLE001 — reconnect forever, never give up
|
||||||
logger.warning(
|
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,
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
@@ -132,8 +129,10 @@ def main() -> int:
|
|||||||
if len(traces) != len(config.combos):
|
if len(traces) != len(config.combos):
|
||||||
if stop_requested.is_set():
|
if stop_requested.is_set():
|
||||||
break
|
break
|
||||||
|
if collector_driven:
|
||||||
|
continue # Latest-wins transport skipped part of a source cycle.
|
||||||
logger.warning(
|
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",
|
"reconnecting and waiting for the device",
|
||||||
len(traces),
|
len(traces),
|
||||||
len(config.combos),
|
len(config.combos),
|
||||||
@@ -143,11 +142,11 @@ def main() -> int:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
collection = SweepCollection(
|
collection = SweepCollection(
|
||||||
collection_id=collection_id,
|
collection_id=cycle_id if cycle_id is not None else collection_id,
|
||||||
monotonic_ns=time.monotonic_ns(),
|
monotonic_ns=time.monotonic_ns(),
|
||||||
traces=traces,
|
traces=traces,
|
||||||
capture_start_ns=capture_start_ns,
|
capture_start_ns=min(trace.capture_start_ns for trace in traces),
|
||||||
capture_end_ns=time.monotonic_ns(),
|
capture_end_ns=max(trace.capture_end_ns for trace in traces),
|
||||||
)
|
)
|
||||||
publisher.publish(collection)
|
publisher.publish(collection)
|
||||||
|
|
||||||
@@ -155,13 +154,13 @@ def main() -> int:
|
|||||||
break
|
break
|
||||||
collection_duration_s = time.monotonic() - collection_start
|
collection_duration_s = time.monotonic() - collection_start
|
||||||
if collection_id == 1 or collection_id % 20 == 0 or collection_duration_s > 2.0:
|
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
|
collection_id += 1
|
||||||
finally:
|
finally:
|
||||||
close_devices(output_switch, input_switch, radar)
|
close_devices(output_switch, input_switch, radar)
|
||||||
cleanup.close()
|
cleanup.close()
|
||||||
|
|
||||||
logger.info("Kamil ADC raw producer stopped")
|
logger.info("RadioPhotonic radar (RPH) raw producer stopped")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ def radar_config_filename_prefix(
|
|||||||
dataset's name carries the configuration it was captured with. ADC-mode radars have
|
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``.
|
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 = (
|
tokens = (
|
||||||
model_name,
|
model_name,
|
||||||
_format_frequency_span(sweep_start_hz, sweep_stop_hz),
|
_format_frequency_span(sweep_start_hz, sweep_stop_hz),
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ class ComboNavigationTest(unittest.TestCase):
|
|||||||
self.w._plots.draw(result)
|
self.w._plots.draw(result)
|
||||||
|
|
||||||
def test_next_button_draws_cached_combo_without_changing_acquisition(self) -> None:
|
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):
|
with self.subTest(model=model):
|
||||||
self.w._profile.state.config.radar.model = model
|
self.w._profile.state.config.radar.model = model
|
||||||
self._seed_bscan()
|
self._seed_bscan()
|
||||||
|
|||||||
@@ -63,4 +63,4 @@ printf 'updated environment' > "$(dirname -- "$0")/../state"
|
|||||||
self.assertTrue((target / 'run_config_examples/device.json.new').exists())
|
self.assertTrue((target / 'run_config_examples/device.json.new').exists())
|
||||||
self.assertEqual((target / 'python_app/runtime/gui_session_state.json').read_text(), 'session')
|
self.assertEqual((target / 'python_app/runtime/gui_session_state.json').read_text(), 'session')
|
||||||
self.assertFalse((target / 'python_app/runtime/logs').exists())
|
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")
|
_mock(pipeline._status).assert_called_with("Status: error")
|
||||||
self.assertFalse(pipeline.can_resume(expected_stop_seq))
|
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)
|
pipeline = _pipeline(self)
|
||||||
config = RunConfigModel()
|
config = RunConfigModel()
|
||||||
config.radar.model = "kamil_adc"
|
config.radar.model = "rph_radar"
|
||||||
config.apply_device_model_constraints()
|
config.apply_device_model_constraints()
|
||||||
_mock(pipeline._build_config).return_value = config
|
_mock(pipeline._build_config).return_value = config
|
||||||
pipeline.stop_all = mock.Mock()
|
pipeline.stop_all = mock.Mock()
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from unittest.mock import Mock, patch
|
|||||||
|
|
||||||
import numpy as np
|
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.laser_control.protocol import Protocol
|
||||||
from python_app.hardware_full.librevna_service import LibreVnaService
|
from python_app.hardware_full.librevna_service import LibreVnaService
|
||||||
from python_app.hardware_full.librevna_driver.api.vna import VNAController
|
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.run_config_schema import RadarSweepModel, RunConfigModel
|
||||||
from python_app.models.dataset_model import SweepCollection
|
from python_app.models.dataset_model import SweepCollection
|
||||||
from python_app.orchestration.shm.decoder import RAW_MAGIC, decode_trace_collection
|
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
|
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):
|
with self.subTest(combination=combination):
|
||||||
master, slave = pty.openpty()
|
master, slave = pty.openpty()
|
||||||
tty.setraw(slave)
|
tty.setraw(slave)
|
||||||
reader = KamilAdcTtyReader(os.ttyname(slave))
|
reader = RphRadarTtyReader(os.ttyname(slave))
|
||||||
errors = []
|
errors = []
|
||||||
started = threading.Event()
|
started = threading.Event()
|
||||||
reader.open()
|
reader.open()
|
||||||
@@ -260,10 +260,7 @@ class TtyLifecycleTest(unittest.TestCase):
|
|||||||
def wait():
|
def wait():
|
||||||
started.set()
|
started.set()
|
||||||
try:
|
try:
|
||||||
if combination is None:
|
reader.read_sweep(timeout_s=20, combo=combination)
|
||||||
reader.read_sweep(timeout_s=20)
|
|
||||||
else:
|
|
||||||
reader.read_sweep_for(combination, timeout_s=20)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
errors.append(exc)
|
errors.append(exc)
|
||||||
|
|
||||||
@@ -283,7 +280,7 @@ class TtyLifecycleTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_failed_reader_thread_start_closes_descriptor(self) -> None:
|
def test_failed_reader_thread_start_closes_descriptor(self) -> None:
|
||||||
read_fd, write_fd = os.pipe()
|
read_fd, write_fd = os.pipe()
|
||||||
reader = KamilAdcTtyReader("/dev/test")
|
reader = RphRadarTtyReader("/dev/test")
|
||||||
try:
|
try:
|
||||||
with patch("os.open", return_value=read_fd), patch("threading.Thread.start", side_effect=RuntimeError("thread failed")):
|
with patch("os.open", return_value=read_fd), patch("threading.Thread.start", side_effect=RuntimeError("thread failed")):
|
||||||
with self.assertRaises(RuntimeError):
|
with self.assertRaises(RuntimeError):
|
||||||
@@ -298,7 +295,7 @@ class TtyLifecycleTest(unittest.TestCase):
|
|||||||
|
|
||||||
class ProducerCleanupTest(unittest.TestCase):
|
class ProducerCleanupTest(unittest.TestCase):
|
||||||
def test_partial_ring_initialization_closes_first_writer(self) -> None:
|
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__):
|
with self.subTest(producer=module.__name__):
|
||||||
config = RunConfigModel()
|
config = RunConfigModel()
|
||||||
config.radar.model = model
|
config.radar.model = model
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
"""Tests for the Kamil ADC TTY wire-protocol parser."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import struct
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
from python_app.hardware_full.kamil_adc.protocol import (
|
|
||||||
COMBO_MARKER,
|
|
||||||
MAIN_MARKER,
|
|
||||||
REFERENCE_MARKER,
|
|
||||||
KamilAdcStreamParser,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _boundary() -> bytes:
|
|
||||||
return struct.pack("<HHHH", MAIN_MARKER, 0xFFFF, 0xFFFF, 0xFFFF)
|
|
||||||
|
|
||||||
|
|
||||||
def _combo(input_pos: int, output_pos: int, dirty: int = 0) -> bytes:
|
|
||||||
return struct.pack("<HHhh", COMBO_MARKER, input_pos, output_pos, dirty)
|
|
||||||
|
|
||||||
|
|
||||||
def _main(step: int, real: int, imag: int) -> bytes:
|
|
||||||
return struct.pack("<HHhh", MAIN_MARKER, step, real, imag)
|
|
||||||
|
|
||||||
|
|
||||||
def _reference(step: int, real: int, imag: int) -> bytes:
|
|
||||||
return struct.pack("<HHhh", REFERENCE_MARKER, step, real, imag)
|
|
||||||
|
|
||||||
|
|
||||||
class KamilAdcStreamParserTest(unittest.TestCase):
|
|
||||||
def test_pairs_main_and_reference_by_step(self) -> None:
|
|
||||||
parser = KamilAdcStreamParser()
|
|
||||||
stream = (
|
|
||||||
_boundary()
|
|
||||||
+ _main(1, 10, -1) + _reference(1, 100, 5)
|
|
||||||
+ _main(2, 20, -2) + _reference(2, 200, 6)
|
|
||||||
+ _boundary()
|
|
||||||
)
|
|
||||||
sweeps = parser.feed(stream)
|
|
||||||
self.assertEqual(len(sweeps), 1)
|
|
||||||
sweep = sweeps[0]
|
|
||||||
self.assertEqual(sweep.steps.tolist(), [1, 2])
|
|
||||||
self.assertEqual(sweep.main.tolist(), [complex(10, -1), complex(20, -2)])
|
|
||||||
self.assertEqual(sweep.reference.tolist(), [complex(100, 5), complex(200, 6)])
|
|
||||||
|
|
||||||
def test_keeps_only_steps_present_in_both_channels(self) -> None:
|
|
||||||
parser = KamilAdcStreamParser()
|
|
||||||
stream = (
|
|
||||||
_boundary()
|
|
||||||
+ _main(1, 10, 0) # main only -> dropped
|
|
||||||
+ _main(2, 20, 0) + _reference(2, 200, 0) # both -> kept
|
|
||||||
+ _reference(3, 300, 0) # reference only -> dropped
|
|
||||||
+ _boundary()
|
|
||||||
)
|
|
||||||
(sweep,) = parser.feed(stream)
|
|
||||||
self.assertEqual(sweep.steps.tolist(), [2])
|
|
||||||
self.assertEqual(sweep.main.tolist(), [complex(20, 0)])
|
|
||||||
self.assertEqual(sweep.reference.tolist(), [complex(200, 0)])
|
|
||||||
|
|
||||||
def test_orders_by_ascending_step_regardless_of_arrival(self) -> None:
|
|
||||||
parser = KamilAdcStreamParser()
|
|
||||||
stream = (
|
|
||||||
_boundary()
|
|
||||||
+ _main(3, 3, 0) + _reference(3, 30, 0)
|
|
||||||
+ _main(1, 1, 0) + _reference(1, 10, 0)
|
|
||||||
+ _main(2, 2, 0) + _reference(2, 20, 0)
|
|
||||||
+ _boundary()
|
|
||||||
)
|
|
||||||
(sweep,) = parser.feed(stream)
|
|
||||||
self.assertEqual(sweep.steps.tolist(), [1, 2, 3])
|
|
||||||
self.assertEqual(sweep.main.real.tolist(), [1, 2, 3])
|
|
||||||
|
|
||||||
def test_discards_preroll_before_first_boundary(self) -> None:
|
|
||||||
parser = KamilAdcStreamParser()
|
|
||||||
# Garbage + a partial point before the first real boundary must be skipped.
|
|
||||||
stream = (
|
|
||||||
_main(7, 7, 7) # pre-roll point (no preceding boundary) -> ignored
|
|
||||||
+ _boundary()
|
|
||||||
+ _main(1, 11, 0) + _reference(1, 1, 0)
|
|
||||||
+ _boundary()
|
|
||||||
)
|
|
||||||
(sweep,) = parser.feed(stream)
|
|
||||||
self.assertEqual(sweep.steps.tolist(), [1])
|
|
||||||
|
|
||||||
def test_handles_chunk_splits_across_frames(self) -> None:
|
|
||||||
parser = KamilAdcStreamParser()
|
|
||||||
stream = (
|
|
||||||
_boundary()
|
|
||||||
+ _main(1, 10, 0) + _reference(1, 100, 0)
|
|
||||||
+ _main(2, 20, 0) + _reference(2, 200, 0)
|
|
||||||
+ _boundary()
|
|
||||||
)
|
|
||||||
sweeps: list = []
|
|
||||||
# Feed one byte at a time to exercise reassembly across feed() calls.
|
|
||||||
for byte in stream:
|
|
||||||
sweeps.extend(parser.feed(bytes([byte])))
|
|
||||||
self.assertEqual(len(sweeps), 1)
|
|
||||||
self.assertEqual(sweeps[0].steps.tolist(), [1, 2])
|
|
||||||
|
|
||||||
def test_multiple_sweeps_in_one_feed(self) -> None:
|
|
||||||
parser = KamilAdcStreamParser()
|
|
||||||
stream = (
|
|
||||||
_boundary()
|
|
||||||
+ _main(1, 1, 0) + _reference(1, 10, 0)
|
|
||||||
+ _boundary()
|
|
||||||
+ _main(1, 2, 0) + _reference(1, 20, 0)
|
|
||||||
+ _boundary()
|
|
||||||
)
|
|
||||||
sweeps = parser.feed(stream)
|
|
||||||
self.assertEqual(len(sweeps), 2)
|
|
||||||
self.assertEqual(sweeps[0].main.real.tolist(), [1])
|
|
||||||
self.assertEqual(sweeps[1].main.real.tolist(), [2])
|
|
||||||
|
|
||||||
def test_empty_sweep_between_boundaries_is_skipped(self) -> None:
|
|
||||||
parser = KamilAdcStreamParser()
|
|
||||||
stream = _boundary() + _boundary() + _main(1, 5, 0) + _reference(1, 1, 0) + _boundary()
|
|
||||||
sweeps = parser.feed(stream)
|
|
||||||
self.assertEqual(len(sweeps), 1)
|
|
||||||
self.assertEqual(sweeps[0].steps.tolist(), [1])
|
|
||||||
|
|
||||||
def test_corrupt_marker_raises(self) -> None:
|
|
||||||
parser = KamilAdcStreamParser()
|
|
||||||
corrupt = struct.pack("<HHhh", 0x001A, 1, 5, 5) # unknown marker
|
|
||||||
with self.assertRaisesRegex(ValueError, "protocol violation"):
|
|
||||||
parser.feed(_boundary() + _main(1, 1, 0) + corrupt + _boundary())
|
|
||||||
|
|
||||||
def test_partial_trailing_frame_is_buffered(self) -> None:
|
|
||||||
parser = KamilAdcStreamParser()
|
|
||||||
self.assertEqual(parser.feed(_boundary() + _main(1, 1, 0)[:5]), [])
|
|
||||||
# Supply the rest of the frame plus its reference and the closing boundary.
|
|
||||||
rest = _main(1, 1, 0)[5:]
|
|
||||||
(sweep,) = parser.feed(rest + _reference(1, 9, 0) + _boundary())
|
|
||||||
self.assertEqual(sweep.steps.tolist(), [1])
|
|
||||||
|
|
||||||
def test_reset_clears_state(self) -> None:
|
|
||||||
parser = KamilAdcStreamParser()
|
|
||||||
parser.feed(_boundary() + _main(1, 1, 0))
|
|
||||||
parser.reset()
|
|
||||||
# After reset we must re-align on a fresh boundary before collecting.
|
|
||||||
sweeps = parser.feed(_main(9, 9, 0) + _boundary() + _main(1, 2, 0) + _reference(1, 2, 0) + _boundary())
|
|
||||||
self.assertEqual(len(sweeps), 1)
|
|
||||||
self.assertEqual(sweeps[0].main.real.tolist(), [2])
|
|
||||||
|
|
||||||
def test_untagged_sweep_has_no_combo(self) -> None:
|
|
||||||
parser = KamilAdcStreamParser()
|
|
||||||
(sweep,) = parser.feed(_boundary() + _main(1, 1, 0) + _reference(1, 9, 0) + _boundary())
|
|
||||||
self.assertIsNone(sweep.combo)
|
|
||||||
self.assertFalse(sweep.dirty)
|
|
||||||
|
|
||||||
def test_combo_tag_labels_following_sweep(self) -> None:
|
|
||||||
parser = KamilAdcStreamParser()
|
|
||||||
stream = (
|
|
||||||
_boundary() + _combo(1, 2)
|
|
||||||
+ _main(1, 10, 0) + _reference(1, 100, 0)
|
|
||||||
+ _boundary() + _combo(3, 0, dirty=1)
|
|
||||||
+ _main(1, 20, 0) + _reference(1, 200, 0)
|
|
||||||
+ _boundary()
|
|
||||||
)
|
|
||||||
first, second = parser.feed(stream)
|
|
||||||
self.assertEqual(first.combo, (1, 2))
|
|
||||||
self.assertFalse(first.dirty)
|
|
||||||
self.assertEqual(second.combo, (3, 0))
|
|
||||||
self.assertTrue(second.dirty)
|
|
||||||
|
|
||||||
def test_combo_not_carried_into_untagged_sweep(self) -> None:
|
|
||||||
parser = KamilAdcStreamParser()
|
|
||||||
stream = (
|
|
||||||
_boundary() + _combo(1, 1)
|
|
||||||
+ _main(1, 1, 0) + _reference(1, 1, 0)
|
|
||||||
+ _boundary() # next sweep has no combo frame
|
|
||||||
+ _main(2, 2, 0) + _reference(2, 2, 0)
|
|
||||||
+ _boundary()
|
|
||||||
)
|
|
||||||
first, second = parser.feed(stream)
|
|
||||||
self.assertEqual(first.combo, (1, 1))
|
|
||||||
self.assertIsNone(second.combo)
|
|
||||||
|
|
||||||
def test_dtypes(self) -> None:
|
|
||||||
parser = KamilAdcStreamParser()
|
|
||||||
(sweep,) = parser.feed(_boundary() + _main(1, 1, 2) + _reference(1, 3, 4) + _boundary())
|
|
||||||
self.assertEqual(sweep.main.dtype, np.complex64)
|
|
||||||
self.assertEqual(sweep.reference.dtype, np.complex64)
|
|
||||||
self.assertEqual(sweep.steps.dtype, np.int32)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,312 +0,0 @@
|
|||||||
"""Tests for the Kamil ADC TTY reader, config round-trip, and producer wiring."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import pty
|
|
||||||
import struct
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
import tty
|
|
||||||
import unittest
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
from python_app.hardware_full.kamil_adc import KamilAdcService, KamilAdcTtyReader
|
|
||||||
from python_app.hardware_full.kamil_adc.protocol import (
|
|
||||||
COMBO_MARKER,
|
|
||||||
MAIN_MARKER,
|
|
||||||
REFERENCE_MARKER,
|
|
||||||
)
|
|
||||||
from python_app.models.run_config_schema import RunConfigModel
|
|
||||||
from python_app.orchestration.process_supervisor import ProcessSupervisor
|
|
||||||
|
|
||||||
|
|
||||||
def _boundary() -> bytes:
|
|
||||||
return struct.pack("<HHHH", MAIN_MARKER, 0xFFFF, 0xFFFF, 0xFFFF)
|
|
||||||
|
|
||||||
|
|
||||||
def _main(step: int, real: int, imag: int) -> bytes:
|
|
||||||
return struct.pack("<HHhh", MAIN_MARKER, step, real, imag)
|
|
||||||
|
|
||||||
|
|
||||||
def _reference(step: int, real: int, imag: int) -> bytes:
|
|
||||||
return struct.pack("<HHhh", REFERENCE_MARKER, step, real, imag)
|
|
||||||
|
|
||||||
|
|
||||||
def _combo(input_pos: int, output_pos: int, dirty: int = 0) -> bytes:
|
|
||||||
return struct.pack("<HHhh", COMBO_MARKER, input_pos, output_pos, dirty)
|
|
||||||
|
|
||||||
|
|
||||||
class KamilAdcTtyReaderTest(unittest.TestCase):
|
|
||||||
"""End-to-end tests over a PTY exercising the background reader thread."""
|
|
||||||
|
|
||||||
def _open_pty_reader(self) -> tuple[int, int, KamilAdcTtyReader]:
|
|
||||||
master_fd, slave_fd = pty.openpty()
|
|
||||||
tty.setraw(slave_fd)
|
|
||||||
reader = KamilAdcTtyReader(os.ttyname(slave_fd))
|
|
||||||
reader.open()
|
|
||||||
return master_fd, slave_fd, reader
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _close(master_fd: int, slave_fd: int, reader: KamilAdcTtyReader) -> None:
|
|
||||||
try:
|
|
||||||
reader.close()
|
|
||||||
finally:
|
|
||||||
os.close(master_fd)
|
|
||||||
os.close(slave_fd)
|
|
||||||
|
|
||||||
def test_publishes_sweep_with_aligned_main_and_reference(self) -> None:
|
|
||||||
master_fd, slave_fd, reader = self._open_pty_reader()
|
|
||||||
try:
|
|
||||||
os.write(
|
|
||||||
master_fd,
|
|
||||||
_boundary()
|
|
||||||
+ _main(1, 10, -1) + _reference(1, 100, 5)
|
|
||||||
+ _main(2, -20, 2) + _reference(2, 200, 6)
|
|
||||||
+ _boundary(),
|
|
||||||
)
|
|
||||||
sweep = reader.read_sweep(timeout_s=1.0)
|
|
||||||
self.assertEqual(sweep.steps.tolist(), [1, 2])
|
|
||||||
self.assertEqual(sweep.main.tolist(), [complex(10, -1), complex(-20, 2)])
|
|
||||||
self.assertEqual(sweep.reference.tolist(), [complex(100, 5), complex(200, 6)])
|
|
||||||
finally:
|
|
||||||
self._close(master_fd, slave_fd, reader)
|
|
||||||
|
|
||||||
def test_variable_length_sweeps_are_allowed(self) -> None:
|
|
||||||
"""Unlike the old format, sweep length may vary — no locking, just resample later."""
|
|
||||||
master_fd, slave_fd, reader = self._open_pty_reader()
|
|
||||||
try:
|
|
||||||
# One complete sweep per write, read between, so delivery is deterministic
|
|
||||||
# (the reader publishes only the latest, overwriting unread sweeps).
|
|
||||||
os.write(master_fd, _boundary() + _main(1, 1, 0) + _reference(1, 9, 0) + _boundary())
|
|
||||||
first = reader.read_sweep(timeout_s=1.0)
|
|
||||||
self.assertEqual(first.steps.tolist(), [1])
|
|
||||||
|
|
||||||
os.write(
|
|
||||||
master_fd,
|
|
||||||
_main(1, 2, 0) + _reference(1, 8, 0)
|
|
||||||
+ _main(2, 3, 0) + _reference(2, 7, 0)
|
|
||||||
+ _boundary(),
|
|
||||||
)
|
|
||||||
second = reader.read_sweep(timeout_s=1.0)
|
|
||||||
self.assertEqual(second.steps.tolist(), [1, 2])
|
|
||||||
finally:
|
|
||||||
self._close(master_fd, slave_fd, reader)
|
|
||||||
|
|
||||||
def test_only_latest_sweep_is_published(self) -> None:
|
|
||||||
master_fd, slave_fd, reader = self._open_pty_reader()
|
|
||||||
try:
|
|
||||||
payload = (
|
|
||||||
_boundary() + _main(1, 1, 0) + _reference(1, 1, 0)
|
|
||||||
+ _boundary() + _main(1, 2, 0) + _reference(1, 2, 0)
|
|
||||||
+ _boundary() + _main(1, 3, 0) + _reference(1, 3, 0)
|
|
||||||
+ _boundary()
|
|
||||||
)
|
|
||||||
os.write(master_fd, payload)
|
|
||||||
deadline = time.monotonic() + 1.0
|
|
||||||
while time.monotonic() < deadline and reader.published_count < 3:
|
|
||||||
time.sleep(0.005)
|
|
||||||
self.assertGreaterEqual(reader.published_count, 3)
|
|
||||||
sweep = reader.read_sweep(timeout_s=1.0)
|
|
||||||
self.assertEqual(sweep.main.tolist(), [complex(3, 0)])
|
|
||||||
finally:
|
|
||||||
self._close(master_fd, slave_fd, reader)
|
|
||||||
|
|
||||||
def test_corrupt_frame_fails_fast(self) -> None:
|
|
||||||
master_fd, slave_fd, reader = self._open_pty_reader()
|
|
||||||
try:
|
|
||||||
corrupt = struct.pack("<HHhh", 0x001A, 1, 5, 5) # unknown marker
|
|
||||||
os.write(master_fd, _boundary() + _main(1, 10, -1) + corrupt + _boundary())
|
|
||||||
with self.assertRaises((ValueError, RuntimeError)):
|
|
||||||
reader.read_sweep(timeout_s=1.0)
|
|
||||||
finally:
|
|
||||||
self._close(master_fd, slave_fd, reader)
|
|
||||||
|
|
||||||
def test_no_completed_sweep_times_out(self) -> None:
|
|
||||||
master_fd, slave_fd, reader = self._open_pty_reader()
|
|
||||||
try:
|
|
||||||
os.write(master_fd, _boundary() + _main(1, 10, -1) + _reference(1, 1, 0))
|
|
||||||
with self.assertRaisesRegex(TimeoutError, "Timed out waiting for Kamil ADC sweep"):
|
|
||||||
reader.read_sweep(timeout_s=0.1)
|
|
||||||
finally:
|
|
||||||
self._close(master_fd, slave_fd, reader)
|
|
||||||
|
|
||||||
def test_read_sweep_for_demuxes_by_combo(self) -> None:
|
|
||||||
master_fd, slave_fd, reader = self._open_pty_reader()
|
|
||||||
try:
|
|
||||||
os.write(
|
|
||||||
master_fd,
|
|
||||||
_boundary() + _combo(0, 0) + _main(1, 11, 0) + _reference(1, 1, 0)
|
|
||||||
+ _boundary() + _combo(0, 1) + _main(1, 22, 0) + _reference(1, 1, 0)
|
|
||||||
+ _boundary(),
|
|
||||||
)
|
|
||||||
# Each combination is served from its own slot, regardless of order.
|
|
||||||
second = reader.read_sweep_for((0, 1), timeout_s=1.0)
|
|
||||||
self.assertEqual(second.main.real.tolist(), [22])
|
|
||||||
self.assertEqual(second.combo, (0, 1))
|
|
||||||
first = reader.read_sweep_for((0, 0), timeout_s=1.0)
|
|
||||||
self.assertEqual(first.main.real.tolist(), [11])
|
|
||||||
finally:
|
|
||||||
self._close(master_fd, slave_fd, reader)
|
|
||||||
|
|
||||||
def test_read_sweep_for_drops_dirty_and_takes_retake(self) -> None:
|
|
||||||
master_fd, slave_fd, reader = self._open_pty_reader()
|
|
||||||
try:
|
|
||||||
os.write(
|
|
||||||
master_fd,
|
|
||||||
# A dirty combo (0,1) sweep, then its clean re-take of the same combo.
|
|
||||||
_boundary() + _combo(0, 1, dirty=1) + _main(1, 99, 0) + _reference(1, 1, 0)
|
|
||||||
+ _boundary() + _combo(0, 1) + _main(1, 42, 0) + _reference(1, 1, 0)
|
|
||||||
+ _boundary(),
|
|
||||||
)
|
|
||||||
sweep = reader.read_sweep_for((0, 1), timeout_s=1.0)
|
|
||||||
# The dirty sweep (99) is dropped; only the clean re-take (42) is served.
|
|
||||||
self.assertEqual(sweep.main.real.tolist(), [42])
|
|
||||||
self.assertFalse(sweep.dirty)
|
|
||||||
finally:
|
|
||||||
self._close(master_fd, slave_fd, reader)
|
|
||||||
|
|
||||||
|
|
||||||
class KamilAdcConfigTest(unittest.TestCase):
|
|
||||||
def test_config_round_trip_preserves_kamil_sections(self) -> None:
|
|
||||||
payload = {
|
|
||||||
"radar": {
|
|
||||||
"model": "kamil_adc",
|
|
||||||
"serial": "kamil_adc",
|
|
||||||
"driver_mode": "native",
|
|
||||||
"kamil_adc": {
|
|
||||||
"project_dir": "",
|
|
||||||
"executable_path": "build/bin/kamil_adc_collector",
|
|
||||||
"tty_path": "/tmp/ttyADC_data",
|
|
||||||
"args": ["profile:phase", "do8_freq_ref"],
|
|
||||||
"env": {"ADC_ENV": "1"},
|
|
||||||
"startup_timeout_s": 7.0,
|
|
||||||
"sweep_timeout_s": 8.0,
|
|
||||||
"stop_timeout_s": 3.0,
|
|
||||||
"phase_calibration": {
|
|
||||||
"phase0_rad": 1.5,
|
|
||||||
"freq0_hz": 2_046_000_000.0,
|
|
||||||
"phase1_rad": 301.0,
|
|
||||||
"freq1_hz": 5_612_000_000.0,
|
|
||||||
},
|
|
||||||
"band": {
|
|
||||||
"start_hz": 2_100_000_000.0,
|
|
||||||
"stop_hz": 5_500_000_000.0,
|
|
||||||
"points": 1024,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"laser_control": {"enabled": True, "port": "/dev/ttyUSB0", "mode": "manual"},
|
|
||||||
"sweep": {"start_hz": 1.0, "stop_hz": 2.0, "points": 2},
|
|
||||||
},
|
|
||||||
"switches": {"port1": {"positions": 1}, "port2": {"positions": 1}},
|
|
||||||
}
|
|
||||||
|
|
||||||
encoded = RunConfigModel.from_dict(payload).to_dict()
|
|
||||||
kamil = encoded["radar"]["kamil_adc"]
|
|
||||||
|
|
||||||
self.assertEqual(encoded["radar"]["model"], "kamil_adc")
|
|
||||||
self.assertEqual(kamil["executable_path"], "build/bin/kamil_adc_collector")
|
|
||||||
self.assertEqual(kamil["args"], ["profile:phase", "do8_freq_ref"])
|
|
||||||
self.assertEqual(kamil["phase_calibration"]["phase0_rad"], 1.5)
|
|
||||||
self.assertEqual(kamil["phase_calibration"]["freq1_hz"], 5_612_000_000.0)
|
|
||||||
self.assertEqual(kamil["band"], {"start_hz": 2_100_000_000.0, "stop_hz": 5_500_000_000.0, "points": 1024})
|
|
||||||
|
|
||||||
def test_close_never_raises_when_collector_refuses_to_die(self) -> None:
|
|
||||||
"""close() must stay exception-safe even if a SIGKILL'd collector is not
|
|
||||||
reaped within the grace window (e.g. wedged in USB D-state)."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
config = RunConfigModel.from_dict(
|
|
||||||
{
|
|
||||||
"radar": {
|
|
||||||
"model": "kamil_adc",
|
|
||||||
"driver_mode": "native",
|
|
||||||
"kamil_adc": {
|
|
||||||
"project_dir": tmp_dir,
|
|
||||||
"executable_path": "/bin/sh", # any real executable
|
|
||||||
"tty_path": "/tmp/ttyADC_test",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"switches": {"port1": {"positions": 1}, "port2": {"positions": 1}},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
service = KamilAdcService(config)
|
|
||||||
|
|
||||||
process = mock.Mock(spec=subprocess.Popen, pid=2_000_000_000)
|
|
||||||
process.poll.return_value = None # always "alive"
|
|
||||||
process.wait.side_effect = subprocess.TimeoutExpired("kamil_adc_collector", timeout=3.0)
|
|
||||||
service._process = process
|
|
||||||
with mock.patch("python_app.hardware_full.kamil_adc.service.os.killpg"):
|
|
||||||
service.close() # must not raise
|
|
||||||
|
|
||||||
def test_drain_after_switch_waits_for_fresh_sweeps(self) -> None:
|
|
||||||
"""After a switch change, drain must skip the configured number of freshly
|
|
||||||
published sweeps before returning, so the next capture is post-switch."""
|
|
||||||
from python_app.hardware_full.kamil_adc import service as service_module
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
config = RunConfigModel.from_dict(
|
|
||||||
{
|
|
||||||
"radar": {
|
|
||||||
"model": "kamil_adc",
|
|
||||||
"driver_mode": "native",
|
|
||||||
"kamil_adc": {
|
|
||||||
"project_dir": tmp_dir,
|
|
||||||
"executable_path": "/bin/sh",
|
|
||||||
"tty_path": "/tmp/ttyADC_test",
|
|
||||||
"sweep_timeout_s": 5.0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"switches": {"port1": {"positions": 1}, "port2": {"positions": 1}},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
service = KamilAdcService(config)
|
|
||||||
reader = mock.Mock(spec=KamilAdcTtyReader, published_count=10)
|
|
||||||
service._reader = reader
|
|
||||||
service._process = mock.Mock(poll=mock.Mock(return_value=None))
|
|
||||||
|
|
||||||
# Each poll-sleep advances the published count, as the reader thread would.
|
|
||||||
def _advance(_seconds: float) -> None:
|
|
||||||
reader.published_count += 1
|
|
||||||
|
|
||||||
with mock.patch.object(service_module.time, "sleep", _advance):
|
|
||||||
service.drain_after_switch(sweeps=3)
|
|
||||||
|
|
||||||
# Started at 10, must have waited for at least 3 more sweeps.
|
|
||||||
self.assertGreaterEqual(reader.published_count, 13)
|
|
||||||
|
|
||||||
def test_drain_after_switch_is_noop_when_not_open(self) -> None:
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
config = RunConfigModel.from_dict(
|
|
||||||
{
|
|
||||||
"radar": {
|
|
||||||
"model": "kamil_adc",
|
|
||||||
"driver_mode": "native",
|
|
||||||
"kamil_adc": {
|
|
||||||
"project_dir": tmp_dir,
|
|
||||||
"executable_path": "/bin/sh",
|
|
||||||
"tty_path": "/tmp/ttyADC_test",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"switches": {"port1": {"positions": 1}, "port2": {"positions": 1}},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
KamilAdcService(config).drain_after_switch() # no reader → must not raise
|
|
||||||
|
|
||||||
def test_supervisor_selects_kamil_adc_producer(self) -> None:
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
config_path = Path(tmp_dir) / "run_config.json"
|
|
||||||
config_path.write_text(json.dumps({"radar": {"model": "kamil_adc"}}), encoding="utf-8")
|
|
||||||
command = ProcessSupervisor(Path("/repo"))._acquisition_command(config_path)
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
command,
|
|
||||||
[sys.executable, "-m", "python_app.scripts.kamil_adc_raw_producer", "--config", str(config_path)],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -8,9 +8,9 @@ import unittest
|
|||||||
from unittest.mock import Mock, patch
|
from 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.controller import DEVICE_MAIN_MESSAGE_ID, LaserController
|
||||||
from python_app.hardware_full.laser_control.models import VariationType
|
from python_app.hardware_full.laser_control.exceptions import CommunicationError, ValidationError
|
||||||
from python_app.hardware_full.laser_control.protocol import Protocol, TaskType
|
from python_app.hardware_full.laser_control.protocol import Protocol
|
||||||
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.models.run_config_schema import RunConfigModel
|
from python_app.models.run_config_schema import RunConfigModel
|
||||||
|
|
||||||
|
|
||||||
@@ -18,11 +18,6 @@ DEVICE_MAIN_MANUAL_HEX = "1111ff37ffa518ab000000000000000a8000000a8000ff003d2acc
|
|||||||
DEVICE_MAIN_CHANGE_CURRENT_LD1_HEX = (
|
DEVICE_MAIN_CHANGE_CURRENT_LD1_HEX = (
|
||||||
"7777ff3701003d2acc2c10008813ffa5cc2c18ab0a00000a8000000a8000b600"
|
"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:
|
class _FakeProtocol:
|
||||||
@@ -43,11 +38,11 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
|
|||||||
self.controller.connect.return_value = True
|
self.controller.connect.return_value = True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _kamil_config(laser_payload: dict[str, object]) -> RunConfigModel:
|
def _rph_config(laser_payload: dict[str, object]) -> RunConfigModel:
|
||||||
return RunConfigModel.from_dict(
|
return RunConfigModel.from_dict(
|
||||||
{
|
{
|
||||||
"radar": {
|
"radar": {
|
||||||
"model": "kamil_adc",
|
"model": "rph_radar",
|
||||||
"driver_mode": "native",
|
"driver_mode": "native",
|
||||||
"laser_control": laser_payload,
|
"laser_control": laser_payload,
|
||||||
}
|
}
|
||||||
@@ -71,7 +66,6 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_variation_commands_match_device_main_bytes(self) -> None:
|
def test_variation_commands_match_device_main_bytes(self) -> None:
|
||||||
ld1_command = Protocol.encode_task_enable(
|
ld1_command = Protocol.encode_task_enable(
|
||||||
task_type=TaskType.CHANGE_CURRENT_LD1,
|
|
||||||
static_temp1=28.0,
|
static_temp1=28.0,
|
||||||
static_temp2=28.9,
|
static_temp2=28.9,
|
||||||
static_current1=33.0,
|
static_current1=33.0,
|
||||||
@@ -81,50 +75,18 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
|
|||||||
step=0.05,
|
step=0.05,
|
||||||
time_step=50,
|
time_step=50,
|
||||||
delay_time=10,
|
delay_time=10,
|
||||||
message_id=DEVICE_MAIN_MESSAGE_ID,
|
|
||||||
pi_coeff1_p=2560,
|
pi_coeff1_p=2560,
|
||||||
pi_coeff1_i=128,
|
pi_coeff1_i=128,
|
||||||
pi_coeff2_p=2560,
|
pi_coeff2_p=2560,
|
||||||
pi_coeff2_i=128,
|
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(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:
|
def test_start_sequence_matches_device_main_order(self) -> None:
|
||||||
cleanup = ExitStack()
|
cleanup = ExitStack()
|
||||||
self.addCleanup(cleanup.close)
|
self.addCleanup(cleanup.close)
|
||||||
fake_protocol = _FakeProtocol()
|
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))
|
cleanup.enter_context(patch.object(controller, "_protocol", fake_protocol))
|
||||||
|
|
||||||
with patch("python_app.hardware_full.laser_control.controller.time.sleep", return_value=None):
|
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,
|
message_id=DEVICE_MAIN_MESSAGE_ID,
|
||||||
)
|
)
|
||||||
controller.start_variation(
|
controller.start_variation(
|
||||||
variation_type=VariationType.CHANGE_CURRENT_LD1,
|
|
||||||
params={
|
params={
|
||||||
"static_temp1": 28.0,
|
"static_temp1": 28.0,
|
||||||
"static_temp2": 28.9,
|
"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:
|
def test_apply_radar_variation_sequence_matches_device_main_order(self) -> None:
|
||||||
config = self._kamil_config(
|
config = self._rph_config(
|
||||||
{
|
{
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"port": "/dev/ttyUSB0",
|
"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):
|
with patch("python_app.hardware_full.rph_radar.laser.LaserController", return_value=self.controller):
|
||||||
applied = apply_kamil_adc_laser_control(config)
|
applied = apply_rph_radar_laser_control(config)
|
||||||
|
|
||||||
self.assertTrue(applied)
|
self.assertTrue(applied)
|
||||||
controller = self.controller
|
controller = self.controller
|
||||||
@@ -233,13 +168,12 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
# The variation handoff itself (type + params), not just call order, must be right.
|
# The variation handoff itself (type + params), not just call order, must be right.
|
||||||
variation_payload = controller.start_variation.call_args.kwargs
|
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"]["step"], 0.05)
|
||||||
self.assertEqual(variation_payload["params"]["min_value"], 33.0)
|
self.assertEqual(variation_payload["params"]["min_value"], 33.0)
|
||||||
self.assertEqual(variation_payload["params"]["max_value"], 35.0)
|
self.assertEqual(variation_payload["params"]["max_value"], 35.0)
|
||||||
|
|
||||||
def test_apply_radar_manual_mode_sets_manual_without_variation(self) -> None:
|
def test_apply_radar_manual_mode_sets_manual_without_variation(self) -> None:
|
||||||
config = self._kamil_config(
|
config = self._rph_config(
|
||||||
{
|
{
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"port": "/dev/ttyUSB0",
|
"port": "/dev/ttyUSB0",
|
||||||
@@ -247,27 +181,53 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
|
|||||||
"manual": {"temp1": 26.0, "temp2": 27.0, "current1": 30.0, "current2": 31.0},
|
"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):
|
with patch("python_app.hardware_full.rph_radar.laser.LaserController", return_value=self.controller):
|
||||||
applied = apply_kamil_adc_laser_control(config)
|
applied = apply_rph_radar_laser_control(config)
|
||||||
self.assertTrue(applied)
|
self.assertTrue(applied)
|
||||||
controller = self.controller
|
controller = self.controller
|
||||||
self.assertEqual([call[0] for call in controller.method_calls], ["connect", "reset", "set_manual_mode", "disconnect"])
|
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)
|
self.assertEqual(controller.set_manual_mode.call_args.kwargs["current1"], 30.0)
|
||||||
|
|
||||||
def test_apply_radar_unknown_variation_type_raises(self) -> None:
|
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",
|
{"enabled": True, "port": "/dev/ttyUSB0", "mode": "variation",
|
||||||
"variation": {"variation_type": "NOT_A_REAL_TYPE"}}
|
"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"):
|
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:
|
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:
|
with patch("python_app.hardware_full.rph_radar.laser.LaserController", return_value=self.controller) as factory:
|
||||||
applied = apply_kamil_adc_laser_control(config)
|
applied = apply_rph_radar_laser_control(config)
|
||||||
|
|
||||||
self.assertFalse(applied)
|
self.assertFalse(applied)
|
||||||
factory.assert_not_called()
|
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 PyQt6.QtWidgets import QApplication, QComboBox, QDoubleSpinBox, QStackedWidget
|
||||||
|
|
||||||
from python_app.gui.antenna_geometry_editor import AntennaGeometryEditor
|
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.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.gui_profile_schema import GuiStateModel
|
||||||
from python_app.models.dataset_model import SweepCollection
|
from python_app.models.dataset_model import SweepCollection
|
||||||
from python_app.models.run_config_schema import RunConfigModel
|
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)
|
editor = widget(section, f"_{mode}_{bound}_freq_mhz", QDoubleSpinBox)
|
||||||
self.assertEqual((editor.minimum(), editor.maximum(), editor.value()), (1, 9500, expected))
|
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:
|
def test_failed_live_write_restores_web_batch_and_desktop_changes(self) -> None:
|
||||||
window = isolated_window(self.addCleanup)
|
window = isolated_window(self.addCleanup)
|
||||||
window._pipeline.timer.stop()
|
window._pipeline.timer.stop()
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ from python_app.models.run_config_schema import RunConfigModel
|
|||||||
from python_app.workflows.neutral_preprocess import build_neutral_s21_sets, supports_neutral_preprocess_sets
|
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(
|
return RunConfigModel.from_dict(
|
||||||
{
|
{
|
||||||
"radar": {
|
"radar": {
|
||||||
"model": "kamil_adc",
|
"model": "rph_radar",
|
||||||
"kamil_adc": {
|
"rph_radar": {
|
||||||
"band": {"start_hz": 2_100_000_000.0, "stop_hz": 5_500_000_000.0, "points": points},
|
"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:
|
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)
|
expected_frequency = np.linspace(2_100_000_000.0, 5_500_000_000.0, 5).astype(np.float32)
|
||||||
self.assertEqual(len(calibration.traces), 2)
|
self.assertEqual(len(calibration.traces), 2)
|
||||||
@@ -46,22 +46,19 @@ class KamilAdcNeutralPreprocessTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_grid_matches_processor_grid(self) -> None:
|
def test_grid_matches_processor_grid(self) -> None:
|
||||||
"""The neutral axis must be byte-identical to the acquisition grid."""
|
"""The neutral axis must be byte-identical to the acquisition grid."""
|
||||||
from python_app.hardware_full.kamil_adc import (
|
from python_app.hardware_full.rph_radar.processing import RphRadarSweepProcessor
|
||||||
KamilAdcProcessingParams,
|
|
||||||
KamilAdcSweepProcessor,
|
|
||||||
)
|
|
||||||
|
|
||||||
config = _kamil_config(points=17)
|
config = _rph_config(points=17)
|
||||||
processor = KamilAdcSweepProcessor(KamilAdcProcessingParams.from_kamil_model(config.radar.kamil_adc))
|
processor = RphRadarSweepProcessor(config.radar.rph_radar)
|
||||||
calibration, _reference = build_neutral_s21_sets(config)
|
calibration, _reference = build_neutral_s21_sets(config)
|
||||||
np.testing.assert_array_equal(calibration.traces[0].frequency_hz, processor.grid_hz)
|
np.testing.assert_array_equal(calibration.traces[0].frequency_hz, processor.grid_hz)
|
||||||
|
|
||||||
def test_rejects_degenerate_band(self) -> None:
|
def test_rejects_degenerate_band(self) -> None:
|
||||||
with self.assertRaisesRegex(ValueError, "points must be >= 2"):
|
with self.assertRaisesRegex(ValueError, "band.points must be"):
|
||||||
build_neutral_s21_sets(_kamil_config(points=1))
|
build_neutral_s21_sets(_rph_config(points=1))
|
||||||
|
|
||||||
def test_dtypes_are_float32_and_complex64(self) -> None:
|
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]
|
trace = calibration.traces[0]
|
||||||
self.assertEqual(trace.frequency_hz.dtype, np.float32)
|
self.assertEqual(trace.frequency_hz.dtype, np.float32)
|
||||||
self.assertEqual(trace.s21.dtype, np.complex64)
|
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:
|
def test_calibration_s21_is_a_nonzero_divisor(self) -> None:
|
||||||
# The preprocessor divides measured/calibration, so calibration S21
|
# The preprocessor divides measured/calibration, so calibration S21
|
||||||
# must never be zero — that is the whole point of the '1+0j neutral' contract.
|
# 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:
|
for trace in calibration.traces:
|
||||||
self.assertTrue(bool(np.all(trace.s21 != 0)))
|
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"))
|
cmd = self.supervisor._acquisition_command(self._config_with_model("librevna_multi"))
|
||||||
self.assertEqual(cmd[:3], [sys.executable, "-m", "python_app.scripts.matrix_raw_producer"])
|
self.assertEqual(cmd[:3], [sys.executable, "-m", "python_app.scripts.matrix_raw_producer"])
|
||||||
|
|
||||||
def test_kamil_producer_for_kamil_adc(self) -> None:
|
def test_rph_producer_for_rph_radar(self) -> None:
|
||||||
cmd = self.supervisor._acquisition_command(self._config_with_model("kamil_adc"))
|
cmd = self.supervisor._acquisition_command(self._config_with_model("rph_radar"))
|
||||||
self.assertEqual(cmd[:3], [sys.executable, "-m", "python_app.scripts.kamil_adc_raw_producer"])
|
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:
|
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()
|
group_kill.assert_not_called()
|
||||||
kill.assert_called_once_with(123, signal.SIGTERM)
|
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")]
|
entries = [Path("/proc/123"), Path("/proc/456")]
|
||||||
cmdlines = [
|
cmdlines = [
|
||||||
b"bash\x00-c\x00echo kamil_adc_collector\x00",
|
b"bash\x00-c\x00echo rph_radar_collector\x00",
|
||||||
b"/project/build/bin/kamil_adc_collector\x00--config\x00config.json\x00",
|
b"/project/build/bin/rph_radar_collector\x00--config\x00config.json\x00",
|
||||||
]
|
]
|
||||||
with (
|
with (
|
||||||
mock.patch.object(Path, "iterdir", return_value=entries),
|
mock.patch.object(Path, "iterdir", return_value=entries),
|
||||||
mock.patch.object(Path, "read_bytes", side_effect=cmdlines),
|
mock.patch.object(Path, "read_bytes", side_effect=cmdlines),
|
||||||
mock.patch.object(ProcessSupervisor, "_signal_group") as signal_group,
|
mock.patch.object(ProcessSupervisor, "_signal_group") as signal_group,
|
||||||
):
|
):
|
||||||
self.supervisor._kill_kamil_collectors()
|
self.supervisor.stop_all()
|
||||||
signal_group.assert_called_once_with(456, signal.SIGKILL)
|
signal_group.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Phase-only calibration preserves acquisition settings and unrelated JSON bytes."""
|
||||||
|
|
||||||
|
from contextlib import redirect_stdout
|
||||||
|
from io import StringIO
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from python_app.models.run_config_schema import RunConfigModel
|
||||||
|
from python_app.scripts import rph_radar_calibrate as calibration
|
||||||
|
|
||||||
|
|
||||||
|
class RphCalibrationTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
logging_setup = patch.object(calibration.logging, "basicConfig")
|
||||||
|
logging_setup.start()
|
||||||
|
self.addCleanup(logging_setup.stop)
|
||||||
|
self.payload = {"radar": {"model": "rph_radar", "driver_mode": "native", "rph_radar": {
|
||||||
|
"executable_path": "/custom/collector", "args": ["duration_ms:123"],
|
||||||
|
"band": {"start_hz": 1.2e9, "stop_hz": 1.8e9, "points": 137},
|
||||||
|
"phase_calibration": {"phase0_rad": 3.456789, "phase1_rad": 9.456789,
|
||||||
|
"freq0_hz": 1e9, "freq1_hz": 2e9}}}, "note": "Пример: phase0_rad"}
|
||||||
|
self.config = RunConfigModel.from_dict(self.payload)
|
||||||
|
self.service = Mock()
|
||||||
|
|
||||||
|
def raw(self, phase: np.ndarray) -> SimpleNamespace:
|
||||||
|
return SimpleNamespace(reference=np.exp(1j * phase), dirty=False)
|
||||||
|
|
||||||
|
def test_apply_preserves_every_other_byte_and_uses_current_settings(self) -> None:
|
||||||
|
for indent, separators in ((None, (",", ":")), (2, (",", ": ")), ("\t", (",", " : "))):
|
||||||
|
original = json.dumps(self.payload, ensure_ascii=False, indent=indent, separators=separators).replace("\n", "\r\n").encode()
|
||||||
|
for apply in (False, True):
|
||||||
|
with self.subTest(indent=indent, apply=apply), TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "profile.json"
|
||||||
|
path.write_bytes(original)
|
||||||
|
self.service.read_raw_sweep.side_effect = [self.raw(np.array([0., 1.]))] + [
|
||||||
|
self.raw(np.linspace(start, start + 2., 20)) for start in (3.10, -3.10, 3.12)]
|
||||||
|
argv = ["calibrate", "--config", str(path), "--sweeps", "3", "--warmup", "1"]
|
||||||
|
with patch.object(calibration, "RphRadarService", return_value=self.service) as factory, \
|
||||||
|
patch("sys.argv", argv + (["--apply"] if apply else [])), redirect_stdout(StringIO()) as output:
|
||||||
|
self.assertEqual(calibration.main(), 0)
|
||||||
|
result = json.loads(output.getvalue())
|
||||||
|
self.assertAlmostEqual(result["phase0_rad"], 3.12)
|
||||||
|
self.assertAlmostEqual(result["phase1_rad"], 5.12)
|
||||||
|
expected = original
|
||||||
|
if apply:
|
||||||
|
expected = original.replace(b"3.456789", json.dumps(result["phase0_rad"]).encode()).replace(
|
||||||
|
b"9.456789", json.dumps(result["phase1_rad"]).encode())
|
||||||
|
self.assertEqual(path.read_bytes(), expected)
|
||||||
|
self.assertEqual(factory.call_args.args[0].to_dict(), self.config.to_dict())
|
||||||
|
self.assertEqual(self.service.read_raw_sweep.call_count, 4)
|
||||||
|
self.service.close.assert_called_once()
|
||||||
|
self.service.reset_mock()
|
||||||
|
|
||||||
|
def test_invalid_reference_never_produces_anchors_and_always_closes(self) -> None:
|
||||||
|
for reference in (np.array([]), np.array([1]), np.array([1, np.nan]),
|
||||||
|
np.array([1, np.inf]), np.array([0, 1]), np.ones(3), np.ones((2, 2))):
|
||||||
|
self.service.read_raw_sweep.return_value = SimpleNamespace(reference=reference, dirty=False)
|
||||||
|
with self.subTest(reference=reference), patch.object(calibration, "RphRadarService", return_value=self.service):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
calibration.calibrate(self.config, sweeps=1, warmup=0)
|
||||||
|
self.service.close.assert_called_once()
|
||||||
|
self.service.reset_mock()
|
||||||
|
|
||||||
|
def test_changed_profile_is_not_overwritten(self) -> None:
|
||||||
|
with TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "profile.json"
|
||||||
|
path.write_text(json.dumps(self.payload))
|
||||||
|
def change_profile(*_args):
|
||||||
|
path.write_bytes(b"external edit")
|
||||||
|
return {"phase0_rad": 3., "phase1_rad": 6.}
|
||||||
|
with patch.object(calibration, "calibrate", side_effect=change_profile), \
|
||||||
|
patch("sys.argv", ["calibrate", "--config", str(path), "--apply"]), redirect_stdout(StringIO()):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "changed during capture"):
|
||||||
|
calibration.main()
|
||||||
|
self.assertEqual(path.read_bytes(), b"external edit")
|
||||||
|
|
||||||
|
def test_missing_anchors_fail_before_capture(self) -> None:
|
||||||
|
del self.payload["radar"]["rph_radar"]["phase_calibration"]["phase0_rad"]
|
||||||
|
with TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "profile.json"
|
||||||
|
path.write_text(json.dumps(self.payload))
|
||||||
|
with patch.object(calibration, "calibrate") as capture, patch("sys.argv", ["calibrate", "--config", str(path)]):
|
||||||
|
with self.assertRaisesRegex(ValueError, "explicit.*phase0_rad"):
|
||||||
|
calibration.main()
|
||||||
|
capture.assert_not_called()
|
||||||
+10
-45
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for the Kamil ADC reference-channel signal processing.
|
"""Tests for the RadioPhotonic radar (RPH) reference-channel signal processing.
|
||||||
|
|
||||||
References are built from an explicit *unwrapped* phase ramp whose first sample
|
References are built from an explicit *unwrapped* phase ramp whose first sample
|
||||||
lies in ``(-pi, pi]`` and whose steps are below ``pi``, so that
|
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 __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import replace
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from python_app.hardware_full.kamil_adc.processing import (
|
from python_app.hardware_full.rph_radar.processing import RphRadarSweepProcessor
|
||||||
KamilAdcProcessingParams,
|
from python_app.models.run_config_schema import RphRadarModel, RphRadarBandModel, RphRadarPhaseCalibrationModel
|
||||||
KamilAdcSweepProcessor,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _reference(phase: np.ndarray, amplitude: float = 1000.0) -> np.ndarray:
|
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))
|
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):
|
class SweepProcessorTest(unittest.TestCase):
|
||||||
def _processor(self, **overrides: float) -> KamilAdcSweepProcessor:
|
def _processor(self, **phase: float) -> RphRadarSweepProcessor:
|
||||||
return KamilAdcSweepProcessor(replace(_CALIBRATION, **overrides))
|
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:
|
def test_grid_is_fixed_and_identical_across_calls(self) -> None:
|
||||||
processor = self._processor()
|
processor = self._processor()
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Collector packets preserve whole sweeps, source windows and pair identity."""
|
||||||
|
|
||||||
|
import struct
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from python_app.hardware_full.rph_radar.protocol import RphRadarStreamParser
|
||||||
|
|
||||||
|
|
||||||
|
def packet(*, source_id=1, cycle_id=1, start=100, end=200, combo=(1, 2), dirty=0, points=None):
|
||||||
|
if points is None:
|
||||||
|
points = [(0xA, 1, 10, -1), (0xA8, 1, 100, 5)]
|
||||||
|
payload = b"".join(struct.pack("<HHhh", *point) for point in points)
|
||||||
|
return struct.pack("<4sIQQQQHHI", b"RPH1", len(payload), source_id, cycle_id,
|
||||||
|
start, end, *combo, dirty) + payload
|
||||||
|
|
||||||
|
|
||||||
|
class RphRadarStreamParserTest(unittest.TestCase):
|
||||||
|
def test_fragmented_packets_keep_source_window_and_identity(self):
|
||||||
|
parser = RphRadarStreamParser()
|
||||||
|
sweeps = []
|
||||||
|
for byte in b"preroll" + packet(source_id=73, cycle_id=9):
|
||||||
|
sweeps.extend(parser.feed(bytes([byte])))
|
||||||
|
self.assertEqual(len(sweeps), 1)
|
||||||
|
sweep = sweeps[0]
|
||||||
|
self.assertEqual((sweep.source_id, sweep.cycle_id, sweep.capture_start_ns, sweep.capture_end_ns),
|
||||||
|
(73, 9, 100, 200))
|
||||||
|
self.assertEqual(sweep.combo, (1, 2))
|
||||||
|
self.assertEqual(sweep.main.tolist(), [10-1j])
|
||||||
|
self.assertEqual(sweep.reference.tolist(), [100+5j])
|
||||||
|
self.assertEqual(sweep.main.dtype, np.complex64)
|
||||||
|
|
||||||
|
def test_whole_packet_loss_does_not_merge_pairs(self):
|
||||||
|
parser = RphRadarStreamParser()
|
||||||
|
first, last = parser.feed(packet(source_id=1) + packet(source_id=3, combo=(2, 3),
|
||||||
|
points=[(0xA, 2, 20, 0), (0xA8, 2, 200, 0)]))
|
||||||
|
self.assertEqual((first.combo, first.steps.tolist()), ((1, 2), [1]))
|
||||||
|
self.assertEqual((last.combo, last.steps.tolist()), ((2, 3), [2]))
|
||||||
|
self.assertEqual(last.source_id, 3)
|
||||||
|
|
||||||
|
def test_aligns_both_channels_and_keeps_only_shared_steps(self):
|
||||||
|
parser = RphRadarStreamParser()
|
||||||
|
(sweep,) = parser.feed(packet(points=[(0xA, 3, 3, 0), (0xA8, 3, 30, 0),
|
||||||
|
(0xA, 1, 1, 0), (0xA8, 1, 10, 0), (0xA, 2, 2, 0)]))
|
||||||
|
self.assertEqual(sweep.steps.tolist(), [1, 3])
|
||||||
|
self.assertEqual(sweep.main.real.tolist(), [1, 3])
|
||||||
|
self.assertEqual(sweep.reference.real.tolist(), [10, 30])
|
||||||
|
|
||||||
|
def test_bad_window_size_flags_and_point_marker_fail_fast(self):
|
||||||
|
for encoded in (packet(start=0), packet(start=200, end=100), packet(dirty=2),
|
||||||
|
packet(source_id=0), packet(cycle_id=0),
|
||||||
|
packet(points=[(0x1A, 1, 1, 1)]),
|
||||||
|
b"RPH1" + struct.pack("<I", 0xFFFFFFFF) + bytes(40)):
|
||||||
|
with self.subTest(encoded=encoded), self.assertRaisesRegex(ValueError, "protocol"):
|
||||||
|
RphRadarStreamParser().feed(encoded)
|
||||||
|
|
||||||
|
def test_reset_discards_partial_packet(self):
|
||||||
|
parser = RphRadarStreamParser()
|
||||||
|
self.assertEqual(parser.feed(packet()[:53]), [])
|
||||||
|
parser.reset()
|
||||||
|
(sweep,) = parser.feed(packet(source_id=2, dirty=1, combo=(65535, 65535)))
|
||||||
|
self.assertEqual(sweep.source_id, 2)
|
||||||
|
self.assertIsNone(sweep.combo)
|
||||||
|
self.assertTrue(sweep.dirty)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
"""PTY integration, source timing, fresh captures and owned collector lifecycle."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import pty
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import tty
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from python_app.hardware_full.rph_radar.service import RphRadarService
|
||||||
|
from python_app.hardware_full.rph_radar.tty_reader import RphRadarTtyReader
|
||||||
|
from python_app.hardware_full.rph_radar.protocol import RphRadarStreamParser
|
||||||
|
from python_app.hardware_full.librevna_driver.models import SweepResult
|
||||||
|
from python_app.models.dataset_model import ComboKey
|
||||||
|
from python_app.models.run_config_schema import RunConfigModel
|
||||||
|
from python_app.orchestration.process_supervisor import ProcessSupervisor
|
||||||
|
from python_app.scripts import rph_radar_raw_producer as producer
|
||||||
|
from python_app.tests.test_rph_radar_protocol import packet
|
||||||
|
|
||||||
|
|
||||||
|
def config():
|
||||||
|
return RunConfigModel.from_dict({
|
||||||
|
"radar": {"model": "rph_radar", "driver_mode": "native", "rph_radar": {
|
||||||
|
"executable_path": "/bin/sh", "tty_path": "/tmp/rph-test"}},
|
||||||
|
"switches": {"port1": {"positions": 1}, "port2": {"positions": 1}},
|
||||||
|
"run": {"continuous": False, "combos": [{"input": 0, "output": 0}]},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class RphRadarTtyReaderTest(unittest.TestCase):
|
||||||
|
def test_pty_delivers_latest_complete_clean_sweeps_by_pair(self):
|
||||||
|
master, slave = pty.openpty()
|
||||||
|
tty.setraw(slave)
|
||||||
|
reader = RphRadarTtyReader(os.ttyname(slave))
|
||||||
|
reader.open()
|
||||||
|
try:
|
||||||
|
os.write(master, packet(source_id=1, combo=(0, 0))
|
||||||
|
+ packet(source_id=2, combo=(0, 1), dirty=1)
|
||||||
|
+ packet(source_id=3, combo=(0, 1)))
|
||||||
|
deadline = time.monotonic() + 1
|
||||||
|
while reader.published_count < 3 and time.monotonic() < deadline:
|
||||||
|
time.sleep(.005)
|
||||||
|
self.assertEqual(reader.read_sweep(timeout_s=1).source_id, 3)
|
||||||
|
self.assertEqual(reader.read_sweep(combo=(0, 0), timeout_s=1).source_id, 1)
|
||||||
|
self.assertEqual(reader.read_sweep(combo=(0, 1), timeout_s=1).source_id, 3)
|
||||||
|
with self.assertRaises(TimeoutError):
|
||||||
|
reader.read_sweep(combo=(0, 1), timeout_s=.01)
|
||||||
|
os.write(master, packet(start=0))
|
||||||
|
with self.assertRaisesRegex(ValueError, "protocol"):
|
||||||
|
reader.read_sweep(timeout_s=1)
|
||||||
|
finally:
|
||||||
|
reader.close()
|
||||||
|
os.close(master)
|
||||||
|
os.close(slave)
|
||||||
|
|
||||||
|
def test_new_source_session_rejects_backlog_despite_adc_clock_drift(self):
|
||||||
|
for settling_ms in (0, 2):
|
||||||
|
with self.subTest(settling_ms=settling_ms):
|
||||||
|
cfg = config()
|
||||||
|
cfg.runtime.settling_ms = settling_ms
|
||||||
|
service = RphRadarService(cfg)
|
||||||
|
# Physically this packet crossed the switch; a +50 ppm ADC clock
|
||||||
|
# after 1000 s puts its timestamp 44 ms past the host switch.
|
||||||
|
stale, fresh = RphRadarStreamParser().feed(
|
||||||
|
packet(source_id=9, start=1_044_000_000, end=1_064_000_000)
|
||||||
|
+ packet(source_id=1, cycle_id=1, start=1_100_000_000, end=1_120_000_000,
|
||||||
|
points=[(0xA, 1, 20, 0), (0xA8, 1, 100, 0)]))
|
||||||
|
old_reader = mock.Mock(read_sweep=mock.Mock(return_value=stale))
|
||||||
|
new_reader = mock.Mock(read_sweep=mock.Mock(return_value=fresh))
|
||||||
|
service._reader = old_reader
|
||||||
|
service._process = mock.Mock(poll=mock.Mock(return_value=None))
|
||||||
|
service._stop_event = threading.Event()
|
||||||
|
processor = mock.Mock(grid_hz=np.array([1.]), _previous_anchor_rad=3.3,
|
||||||
|
process=mock.Mock(side_effect=lambda main, reference: main))
|
||||||
|
service._processor = processor
|
||||||
|
|
||||||
|
def reopen(_service, *, stop_event):
|
||||||
|
old_reader.close.assert_called_once()
|
||||||
|
self.assertIs(stop_event, service._stop_event)
|
||||||
|
service._reader = new_reader
|
||||||
|
service._process = mock.Mock(poll=mock.Mock(return_value=None))
|
||||||
|
|
||||||
|
with mock.patch.object(RphRadarService, "open", autospec=True, side_effect=reopen) as opened, \
|
||||||
|
mock.patch("python_app.hardware_full.rph_radar.service.time.monotonic_ns", return_value=1_000_000_000), \
|
||||||
|
mock.patch("python_app.hardware_full.rph_radar.service.time.sleep") as sleep:
|
||||||
|
with mock.patch.object(RphRadarService, "_stop_process", side_effect=lambda: setattr(service._process, "poll", mock.Mock(return_value=0))):
|
||||||
|
service.prepare_combo(ComboKey(0, 0))
|
||||||
|
result = service.acquire()
|
||||||
|
np.testing.assert_array_equal(result.trace("s21"), [20+0j])
|
||||||
|
service.prepare_combo(ComboKey(0, 0))
|
||||||
|
opened.assert_called_once()
|
||||||
|
self.assertEqual(sleep.call_count, int(settling_ms == 0))
|
||||||
|
self.assertEqual((result.capture_start_ns, result.capture_end_ns), (1_100_000_000, 1_120_000_000))
|
||||||
|
self.assertIs(service._processor, processor)
|
||||||
|
self.assertEqual(processor._previous_anchor_rad, 3.3)
|
||||||
|
old_reader.read_sweep.assert_not_called()
|
||||||
|
|
||||||
|
def test_collector_output_is_captured_in_existing_log(self):
|
||||||
|
import io
|
||||||
|
service = RphRadarService(config())
|
||||||
|
process = mock.Mock(stdout=io.BytesIO(b"ADC disconnected\n"), poll=mock.Mock(return_value=1))
|
||||||
|
with mock.patch("python_app.hardware_full.rph_radar.service.subprocess.Popen", return_value=process) as launch:
|
||||||
|
with self.assertLogs("python_app.hardware_full.rph_radar.service", "INFO") as logs:
|
||||||
|
service._start_process()
|
||||||
|
service.close()
|
||||||
|
self.assertIn("ADC disconnected", " ".join(logs.output))
|
||||||
|
self.assertEqual(launch.call_args.kwargs["stdout"], subprocess.PIPE)
|
||||||
|
|
||||||
|
def test_producer_drains_switch_and_never_mixes_source_cycles(self):
|
||||||
|
for native in (False, True):
|
||||||
|
with self.subTest(collector_driven=native):
|
||||||
|
cfg = config()
|
||||||
|
cfg.input_switch.driver_mode = "native"
|
||||||
|
cfg.output_switch.driver_mode = "native" if native else "mock"
|
||||||
|
cfg.combos = cfg.build_full_combos(1, 2) if native else cfg.combos
|
||||||
|
radar, publisher = mock.Mock(), mock.Mock()
|
||||||
|
windows = iter(((1, 1, 5), (2, 6, 10), (3, 11, 15), (3, 16, 20)) if native
|
||||||
|
else ((1, 11, 20),))
|
||||||
|
|
||||||
|
def acquire(**_kwargs):
|
||||||
|
radar.last_cycle_id, start, end = next(windows)
|
||||||
|
return SweepResult(np.array([1., 2.]), {"s21": np.ones(2)},
|
||||||
|
capture_start_ns=start, capture_end_ns=end)
|
||||||
|
|
||||||
|
radar.acquire.side_effect = acquire
|
||||||
|
with mock.patch.object(producer, "load_producer_config", return_value=(Path("unused"), cfg, threading.Event())), \
|
||||||
|
mock.patch.object(producer, "RphRadarService", return_value=radar), \
|
||||||
|
mock.patch.object(producer, "RawPublisher", return_value=publisher), \
|
||||||
|
mock.patch.object(producer.SwitchService, "from_model"), \
|
||||||
|
mock.patch.object(producer, "_open_radar_with_retry", return_value=True), \
|
||||||
|
mock.patch.object(producer, "select_and_settle", return_value=True):
|
||||||
|
self.assertEqual(producer.main(), 0)
|
||||||
|
if not native:
|
||||||
|
calls = radar.mock_calls
|
||||||
|
self.assertLess(calls.index(mock.call.prepare_combo(ComboKey(0, 0))), calls.index(mock.call.acquire()))
|
||||||
|
publisher.publish.assert_called_once()
|
||||||
|
collection = publisher.publish.call_args.args[0]
|
||||||
|
self.assertEqual(collection.collection_id, 3 if native else 1)
|
||||||
|
self.assertEqual((collection.capture_start_ns, collection.capture_end_ns), (11, 20))
|
||||||
|
self.assertEqual(collection.traces[0].capture_start_ns, 11)
|
||||||
|
|
||||||
|
|
||||||
|
class RphRadarConfigTest(unittest.TestCase):
|
||||||
|
def test_collector_uses_current_settings_without_rereading_profile(self):
|
||||||
|
cfg = config()
|
||||||
|
cfg.runtime.settling_ms = 7
|
||||||
|
service = RphRadarService(cfg, collector_driven=True)
|
||||||
|
payload = next(arg.removeprefix("switching:") for arg in service.command if arg.startswith("switching:"))
|
||||||
|
switching = json.loads(payload)
|
||||||
|
self.assertEqual(switching["settling_ms"], 7)
|
||||||
|
self.assertEqual(switching["combos"], [[0, 0]])
|
||||||
|
self.assertEqual(switching["input"]["gpio_chip"], cfg.input_switch.gpio_chip)
|
||||||
|
self.assertFalse(any(arg.startswith("config:") for arg in service.command))
|
||||||
|
|
||||||
|
def test_config_round_trip_preserves_rph_sections(self) -> None:
|
||||||
|
payload = {
|
||||||
|
"radar": {
|
||||||
|
"model": "rph_radar",
|
||||||
|
"serial": "rph_radar",
|
||||||
|
"driver_mode": "native",
|
||||||
|
"rph_radar": {
|
||||||
|
"project_dir": "",
|
||||||
|
"executable_path": "build/bin/rph_radar_collector",
|
||||||
|
"tty_path": "/tmp/ttyADC_data",
|
||||||
|
"args": ["profile:phase", "do8_freq_ref"],
|
||||||
|
"env": {"ADC_ENV": "1"},
|
||||||
|
"startup_timeout_s": 7.0,
|
||||||
|
"sweep_timeout_s": 8.0,
|
||||||
|
"stop_timeout_s": 3.0,
|
||||||
|
"phase_calibration": {
|
||||||
|
"phase0_rad": 1.5,
|
||||||
|
"freq0_hz": 2_046_000_000.0,
|
||||||
|
"phase1_rad": 301.0,
|
||||||
|
"freq1_hz": 5_612_000_000.0,
|
||||||
|
},
|
||||||
|
"band": {
|
||||||
|
"start_hz": 2_100_000_000.0,
|
||||||
|
"stop_hz": 5_500_000_000.0,
|
||||||
|
"points": 1024,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"laser_control": {"enabled": True, "port": "/dev/ttyUSB0", "mode": "manual"},
|
||||||
|
"sweep": {"start_hz": 1.0, "stop_hz": 2.0, "points": 2},
|
||||||
|
},
|
||||||
|
"switches": {"port1": {"positions": 1}, "port2": {"positions": 1}},
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded = RunConfigModel.from_dict(payload).to_dict()
|
||||||
|
rph = encoded["radar"]["rph_radar"]
|
||||||
|
|
||||||
|
self.assertEqual(encoded["radar"]["model"], "rph_radar")
|
||||||
|
self.assertEqual(rph["executable_path"], "build/bin/rph_radar_collector")
|
||||||
|
self.assertEqual(rph["args"], ["profile:phase", "do8_freq_ref"])
|
||||||
|
self.assertEqual(rph["phase_calibration"]["phase0_rad"], 1.5)
|
||||||
|
self.assertEqual(rph["phase_calibration"]["freq1_hz"], 5_612_000_000.0)
|
||||||
|
self.assertEqual(rph["band"], {"start_hz": 2_100_000_000.0, "stop_hz": 5_500_000_000.0, "points": 1024})
|
||||||
|
|
||||||
|
def test_unreaped_collector_blocks_prepare_and_retry_before_touching_tty(self):
|
||||||
|
service = RphRadarService(config())
|
||||||
|
process = mock.Mock(spec=subprocess.Popen, pid=2_000_000_000)
|
||||||
|
process.poll.return_value = None
|
||||||
|
process.wait.side_effect = subprocess.TimeoutExpired("rph_radar_collector", timeout=.5)
|
||||||
|
service._process = process
|
||||||
|
with mock.patch("python_app.hardware_full.rph_radar.service.os.killpg"):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "did not stop"):
|
||||||
|
service.prepare_combo(ComboKey(0, 0))
|
||||||
|
service.close() # Producer retry closes again; still exception-safe.
|
||||||
|
with mock.patch("python_app.hardware_full.rph_radar.service._prepare_tty_path_for_collector") as unlink, \
|
||||||
|
mock.patch.object(RphRadarService, "_start_process") as start, \
|
||||||
|
mock.patch.object(RphRadarService, "_wait_for_tty"), \
|
||||||
|
mock.patch("python_app.hardware_full.rph_radar.service.RphRadarTtyReader"):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "did not stop"):
|
||||||
|
service.open()
|
||||||
|
unlink.assert_not_called()
|
||||||
|
start.assert_not_called()
|
||||||
|
self.assertIs(service._process, process)
|
||||||
|
process.poll.return_value = 0
|
||||||
|
service.open() # Recovery becomes possible only after actual exit.
|
||||||
|
start.assert_called_once()
|
||||||
|
service.close()
|
||||||
|
|
||||||
|
def test_supervisor_selects_rph_radar_producer(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
config_path = Path(tmp_dir) / "run_config.json"
|
||||||
|
config_path.write_text(json.dumps({"radar": {"model": "rph_radar"}}), encoding="utf-8")
|
||||||
|
command = ProcessSupervisor(Path("/repo"))._acquisition_command(config_path)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
command,
|
||||||
|
[sys.executable, "-m", "python_app.scripts.rph_radar_raw_producer", "--config", str(config_path)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -99,6 +99,17 @@ class StrictNumericTypingTest(unittest.TestCase):
|
|||||||
class StructuralTypingTest(unittest.TestCase):
|
class StructuralTypingTest(unittest.TestCase):
|
||||||
"""A present-but-wrong-shaped section fails loudly instead of being dropped."""
|
"""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:
|
def test_root_must_be_an_object(self) -> None:
|
||||||
for payload in (None, [], 'bad', 42):
|
for payload in (None, [], 'bad', 42):
|
||||||
with self.subTest(payload=payload), self.assertRaisesRegex(ValueError, 'root'):
|
with self.subTest(payload=payload), self.assertRaisesRegex(ValueError, 'root'):
|
||||||
@@ -112,6 +123,19 @@ class StructuralTypingTest(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class AcquisitionValidationTest(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:
|
def test_default_combo_expansion_is_bounded_before_allocation(self) -> None:
|
||||||
payload = _valid_payload()
|
payload = _valid_payload()
|
||||||
payload["switches"]["port1"]["positions"] = 65536
|
payload["switches"]["port1"]["positions"] = 65536
|
||||||
@@ -149,9 +173,31 @@ class AcquisitionValidationTest(unittest.TestCase):
|
|||||||
ring.slot_size_bytes = original
|
ring.slot_size_bytes = original
|
||||||
validate_acquisition_config(config)
|
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"):
|
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:
|
def test_matrix_validates_physical_switch_instead_of_virtual_axis(self) -> None:
|
||||||
config = self.config("librevna_multi")
|
config = self.config("librevna_multi")
|
||||||
|
|||||||
@@ -75,6 +75,37 @@ class RemoveLastHistoryTest(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class RuntimeSignatureTest(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:
|
def test_every_ring_endpoint_setting_requires_restart(self) -> None:
|
||||||
original = RunConfigModel()
|
original = RunConfigModel()
|
||||||
signature = build_processor_run_signature(original)
|
signature = build_processor_run_signature(original)
|
||||||
|
|||||||
@@ -76,8 +76,8 @@ class RadarConfigPrefixTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_adc_model_reports_adc_point_count(self) -> None:
|
def test_adc_model_reports_adc_point_count(self) -> None:
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
radar_config_filename_prefix("kamil_adc", 1e9, 6e9, 1, 0.0),
|
radar_config_filename_prefix("rph_radar", 1e9, 6e9, 1, 0.0),
|
||||||
"kamil_adc_1-6GHz_adc_0dBm",
|
"rph_radar_1-6GHz_adc_0dBm",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Applying these sets leaves measurements unchanged and satisfies pipeline asset
|
Applying these sets leaves measurements unchanged and satisfies pipeline asset
|
||||||
validation before physical calibration is available. The shortcut is enabled for
|
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
|
from __future__ import annotations
|
||||||
@@ -12,10 +12,7 @@ import time
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from python_app.hardware_full.kamil_adc import (
|
from python_app.hardware_full.rph_radar.processing import RphRadarSweepProcessor
|
||||||
KamilAdcProcessingParams,
|
|
||||||
KamilAdcSweepProcessor,
|
|
||||||
)
|
|
||||||
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
|
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
|
||||||
from python_app.models.run_config_schema import ComboModel, RunConfigModel
|
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:
|
def supports_neutral_preprocess_sets(config: RunConfigModel) -> bool:
|
||||||
"""Enable the shortcut only when the emitted frequency grid is verified."""
|
"""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"
|
config.radar.model == RunConfigModel.LIBREVNA_MODEL and config.radar.driver_mode == "mock"
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
def neutral_frequency_grid_hz(config: RunConfigModel) -> np.ndarray:
|
def neutral_frequency_grid_hz(config: RunConfigModel) -> np.ndarray:
|
||||||
"""Derive the neutral axis; the preprocessor verifies it against measured data."""
|
"""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.
|
# Single source of truth for the axis: the same grid the processor emits.
|
||||||
processor = KamilAdcSweepProcessor(
|
return RphRadarSweepProcessor(config.radar.rph_radar).grid_hz
|
||||||
KamilAdcProcessingParams.from_kamil_model(config.radar.kamil_adc)
|
|
||||||
)
|
|
||||||
return processor.grid_hz
|
|
||||||
|
|
||||||
points = int(config.radar.sweep.points)
|
points = int(config.radar.sweep.points)
|
||||||
if points < 1:
|
if points < 1:
|
||||||
|
|||||||
+2
-24
@@ -8,33 +8,12 @@
|
|||||||
"206930965532",
|
"206930965532",
|
||||||
"206930A15532"
|
"206930A15532"
|
||||||
],
|
],
|
||||||
"force_external_reference": true,
|
"force_external_reference": false,
|
||||||
"recovery_attempts": 3,
|
"recovery_attempts": 3,
|
||||||
"output_switch_positions": 1,
|
"output_switch_positions": 1,
|
||||||
"input_switch_positions": 1,
|
"input_switch_positions": 1,
|
||||||
"stimulus_switch_positions": 2
|
"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": {
|
"laser_control": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"port": "",
|
"port": "",
|
||||||
@@ -59,8 +38,7 @@
|
|||||||
"max_value": 35.0,
|
"max_value": 35.0,
|
||||||
"step": 0.1,
|
"step": 0.1,
|
||||||
"time_step": 20,
|
"time_step": 20,
|
||||||
"delay_time": 3,
|
"delay_time": 3
|
||||||
"temp_tolerance_c": 0.03
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sweep": {
|
"sweep": {
|
||||||
|
|||||||
@@ -8,16 +8,6 @@
|
|||||||
"force_external_reference": false,
|
"force_external_reference": false,
|
||||||
"recovery_attempts": 3
|
"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": {
|
"laser_control": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"port": "",
|
"port": "",
|
||||||
|
|||||||
@@ -8,19 +8,9 @@
|
|||||||
"20A1307D5532",
|
"20A1307D5532",
|
||||||
"2072306C5532"
|
"2072306C5532"
|
||||||
],
|
],
|
||||||
"force_external_reference": true,
|
"force_external_reference": false,
|
||||||
"recovery_attempts": 3
|
"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": {
|
"laser_control": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"port": "",
|
"port": "",
|
||||||
|
|||||||
@@ -8,19 +8,9 @@
|
|||||||
"206930965532",
|
"206930965532",
|
||||||
"206930A15532"
|
"206930A15532"
|
||||||
],
|
],
|
||||||
"force_external_reference": true,
|
"force_external_reference": false,
|
||||||
"recovery_attempts": 3
|
"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": {
|
"laser_control": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"port": "",
|
"port": "",
|
||||||
|
|||||||
@@ -1,40 +1,19 @@
|
|||||||
{
|
{
|
||||||
"radar": {
|
"radar": {
|
||||||
"model": "librevna_multi",
|
"model": "librevna_multi",
|
||||||
"serial": "206830985532",
|
"serial": "209E307A5532",
|
||||||
"driver_mode": "native",
|
"driver_mode": "native",
|
||||||
"multi_device": {
|
"multi_device": {
|
||||||
"slave_serials": [
|
"slave_serials": [
|
||||||
"206930965532",
|
"20A1307D5532",
|
||||||
"206930A15532"
|
"2072306C5532"
|
||||||
],
|
],
|
||||||
"force_external_reference": true,
|
"force_external_reference": false,
|
||||||
"recovery_attempts": 3,
|
"recovery_attempts": 3,
|
||||||
"output_switch_positions": 1,
|
"output_switch_positions": 1,
|
||||||
"input_switch_positions": 1,
|
"input_switch_positions": 1,
|
||||||
"stimulus_switch_positions": 2
|
"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": {
|
"laser_control": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"port": "",
|
"port": "",
|
||||||
@@ -59,17 +38,16 @@
|
|||||||
"max_value": 35.0,
|
"max_value": 35.0,
|
||||||
"step": 0.1,
|
"step": 0.1,
|
||||||
"time_step": 20,
|
"time_step": 20,
|
||||||
"delay_time": 3,
|
"delay_time": 3
|
||||||
"temp_tolerance_c": 0.03
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sweep": {
|
"sweep": {
|
||||||
"start_hz": 1000000.0,
|
"start_hz": 500000000.0,
|
||||||
"stop_hz": 6000000000.0,
|
"stop_hz": 6000000000.0,
|
||||||
|
"points": 1031,
|
||||||
"if_bandwidth_hz": 50000.0,
|
"if_bandwidth_hz": 50000.0,
|
||||||
"stimulus_power_dbm": -10.0,
|
|
||||||
"dwell_us": 2,
|
"dwell_us": 2,
|
||||||
"points": 201
|
"stimulus_power_dbm": -3.0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"switches": {
|
"switches": {
|
||||||
@@ -190,11 +168,15 @@
|
|||||||
"preprocess": {
|
"preprocess": {
|
||||||
"s21": {
|
"s21": {
|
||||||
"calibration": {
|
"calibration": {
|
||||||
"set_name": "smoke_cal3",
|
"set_name": "20260930_low_krot",
|
||||||
"bundle_path": ""
|
"bundle_path": ""
|
||||||
},
|
},
|
||||||
"reference": {
|
"reference": {
|
||||||
"set_name": "smoke_cal3",
|
"set_name": "new_cable_nul",
|
||||||
|
"bundle_path": ""
|
||||||
|
},
|
||||||
|
"object_calibration": {
|
||||||
|
"set_name": "",
|
||||||
"bundle_path": ""
|
"bundle_path": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -210,51 +192,51 @@
|
|||||||
"tx_geometry": [
|
"tx_geometry": [
|
||||||
{
|
{
|
||||||
"output_pos": 0,
|
"output_pos": 0,
|
||||||
"x_m": 0.905,
|
"x_m": -0.89,
|
||||||
"y_m": 0.0,
|
"y_m": 0.0,
|
||||||
"z_m": 0.0
|
"z_m": 0.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"output_pos": 1,
|
"output_pos": 1,
|
||||||
"x_m": -0.905,
|
"x_m": 0.89,
|
||||||
"y_m": 0.0,
|
"y_m": 0.0,
|
||||||
"z_m": 0.0
|
"z_m": 0.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"output_pos": 2,
|
"output_pos": 2,
|
||||||
"x_m": 0.905,
|
"x_m": -0.32,
|
||||||
"y_m": 0.0,
|
"y_m": 0.46,
|
||||||
"z_m": 0.0
|
"z_m": 0.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"output_pos": 3,
|
"output_pos": 3,
|
||||||
"x_m": -0.905,
|
"x_m": 0.32,
|
||||||
"y_m": 0.0,
|
"y_m": 0.46,
|
||||||
"z_m": 0.0
|
"z_m": 0.0
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"rx_geometry": [
|
"rx_geometry": [
|
||||||
{
|
{
|
||||||
"input_pos": 0,
|
"input_pos": 0,
|
||||||
"x_m": -0.18,
|
"x_m": 0.23,
|
||||||
"y_m": 0.0,
|
"y_m": 0.0,
|
||||||
"z_m": 0.0
|
"z_m": 0.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"input_pos": 1,
|
"input_pos": 1,
|
||||||
"x_m": 0.485,
|
"x_m": 0.53,
|
||||||
"y_m": 0.0,
|
"y_m": 0.3,
|
||||||
"z_m": 0.0
|
"z_m": 0.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"input_pos": 2,
|
"input_pos": 2,
|
||||||
"x_m": -0.49,
|
"x_m": -0.53,
|
||||||
"y_m": 0.0,
|
"y_m": 0.145,
|
||||||
"z_m": 0.0
|
"z_m": 0.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"input_pos": 3,
|
"input_pos": 3,
|
||||||
"x_m": 0.185,
|
"x_m": -0.23,
|
||||||
"y_m": 0.0,
|
"y_m": 0.0,
|
||||||
"z_m": 0.0
|
"z_m": 0.0
|
||||||
}
|
}
|
||||||
@@ -286,5 +268,117 @@
|
|||||||
"capacity": 50,
|
"capacity": 50,
|
||||||
"slot_size_bytes": 2097152
|
"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",
|
"206930965532",
|
||||||
"206930A15532"
|
"206930A15532"
|
||||||
],
|
],
|
||||||
"force_external_reference": true,
|
"force_external_reference": false,
|
||||||
"recovery_attempts": 3,
|
"recovery_attempts": 3,
|
||||||
"output_switch_positions": 1,
|
"output_switch_positions": 1,
|
||||||
"input_switch_positions": 1,
|
"input_switch_positions": 1,
|
||||||
"stimulus_switch_positions": 2
|
"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": {
|
"laser_control": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"port": "",
|
"port": "",
|
||||||
@@ -59,8 +38,7 @@
|
|||||||
"max_value": 35.0,
|
"max_value": 35.0,
|
||||||
"step": 0.1,
|
"step": 0.1,
|
||||||
"time_step": 20,
|
"time_step": 20,
|
||||||
"delay_time": 3,
|
"delay_time": 3
|
||||||
"temp_tolerance_c": 0.03
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sweep": {
|
"sweep": {
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"radar": {
|
"radar": {
|
||||||
"model": "kamil_adc",
|
"model": "rph_radar",
|
||||||
"serial": "kamil_adc",
|
"serial": "kamil_adc",
|
||||||
"driver_mode": "native",
|
"driver_mode": "native",
|
||||||
"multi_device": {
|
"multi_device": {
|
||||||
@@ -8,9 +8,9 @@
|
|||||||
"force_external_reference": false,
|
"force_external_reference": false,
|
||||||
"recovery_attempts": 3
|
"recovery_attempts": 3
|
||||||
},
|
},
|
||||||
"kamil_adc": {
|
"rph_radar": {
|
||||||
"project_dir": "",
|
"project_dir": "",
|
||||||
"executable_path": "build/bin/kamil_adc_collector",
|
"executable_path": "build/bin/rph_radar_collector",
|
||||||
"tty_path": "/tmp/ttyADC_data",
|
"tty_path": "/tmp/ttyADC_data",
|
||||||
"args": [
|
"args": [
|
||||||
"profile:phase",
|
"profile:phase",
|
||||||
@@ -8,16 +8,6 @@
|
|||||||
"force_external_reference": false,
|
"force_external_reference": false,
|
||||||
"recovery_attempts": 3
|
"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": {
|
"laser_control": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"port": "",
|
"port": "",
|
||||||
|
|||||||
@@ -8,16 +8,6 @@
|
|||||||
"force_external_reference": false,
|
"force_external_reference": false,
|
||||||
"recovery_attempts": 3
|
"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": {
|
"laser_control": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"port": "",
|
"port": "",
|
||||||
|
|||||||
@@ -286,11 +286,11 @@ EOF
|
|||||||
build_cpp_binaries() {
|
build_cpp_binaries() {
|
||||||
local jobs
|
local jobs
|
||||||
jobs="${BUILD_JOBS:-$(nproc)}"
|
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`).
|
# that model; all models share the core pipeline binaries (`all`).
|
||||||
local targets="all"
|
local targets="all"
|
||||||
if [[ "${RADAR_MODEL}" == "kamil_adc" ]]; then
|
if [[ "${RADAR_MODEL}" == "rph_radar" ]]; then
|
||||||
targets="all kamil_adc_collector"
|
targets="all rph_radar_collector"
|
||||||
fi
|
fi
|
||||||
if make -C "${PROJECT_ROOT}" -q ${targets} >/dev/null 2>&1; then
|
if make -C "${PROJECT_ROOT}" -q ${targets} >/dev/null 2>&1; then
|
||||||
echo "[start.sh] C++ binaries are up to date; skipping build."
|
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 \
|
||||||
/dev/shm/radar_preprocessed_tap \
|
/dev/shm/radar_preprocessed_tap \
|
||||||
/dev/shm/radar_results \
|
/dev/shm/radar_results \
|
||||||
/dev/shm/radar_raw_kamil_adc \
|
/dev/shm/radar_raw_rph_radar \
|
||||||
/dev/shm/radar_raw_tap_kamil_adc \
|
/dev/shm/radar_raw_tap_rph_radar \
|
||||||
/dev/shm/radar_preprocessed_kamil_adc \
|
/dev/shm/radar_preprocessed_rph_radar \
|
||||||
/dev/shm/radar_preprocessed_tap_kamil_adc \
|
/dev/shm/radar_preprocessed_tap_rph_radar \
|
||||||
/dev/shm/radar_results_kamil_adc \
|
/dev/shm/radar_results_rph_radar \
|
||||||
|| true
|
|| 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() {
|
run_gui() {
|
||||||
if [[ -n "${PROFILE_PATH}" ]]; then
|
if [[ -n "${PROFILE_PATH}" ]]; then
|
||||||
export RADAR_SYSTEM_PROFILE="${PROFILE_PATH}"
|
export RADAR_SYSTEM_PROFILE="${PROFILE_PATH}"
|
||||||
@@ -393,7 +385,7 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from python_app.orchestration.producer_selection import acquisition_command
|
from python_app.orchestration.producer_selection import acquisition_command
|
||||||
command = acquisition_command(Path(sys.argv[1]))
|
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}")"
|
' "${PROFILE_PATH:-${PROJECT_ROOT}/run_config.json}")"
|
||||||
required_bins="${required_bins} ${acquisition_binary}"
|
required_bins="${required_bins} ${acquisition_binary}"
|
||||||
for bin in ${required_bins}; do
|
for bin in ${required_bins}; do
|
||||||
@@ -431,7 +423,7 @@ main() {
|
|||||||
# LibreVNA uses Python/libusb; Python/C++ SHM needs libatomic.
|
# LibreVNA uses Python/libusb; Python/C++ SHM needs libatomic.
|
||||||
if ((HEADLESS == 0)); then
|
if ((HEADLESS == 0)); then
|
||||||
ensure_system_dependencies
|
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
|
fi
|
||||||
ensure_python_dependencies
|
ensure_python_dependencies
|
||||||
|
|
||||||
@@ -456,9 +448,6 @@ main() {
|
|||||||
if ((CLEAN_SHM == 1)); then
|
if ((CLEAN_SHM == 1)); then
|
||||||
cleanup_known_shm
|
cleanup_known_shm
|
||||||
fi
|
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
|
if ((PRODUCER_ONLY == 1)); then
|
||||||
run_producer_only
|
run_producer_only
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user