diff --git a/Makefile b/Makefile index e97f7c2..b25b138 100644 --- a/Makefile +++ b/Makefile @@ -63,64 +63,60 @@ PROCESSOR_SOURCES := \ data_acq_and_processing/processing/data_processor/src/main.cpp \ $(LOCATOR_SOURCES) -# Kamil ADC collector: standalone L-Card E-502 acquisition binary. It loads the +# RadioPhotonic radar (RPH) collector: application-owned L-Card E-502 acquisition binary. It loads the # proprietary libx502api/libe502api at runtime via dlopen, so it builds anywhere # from the vendored headers alone and only needs those libraries present at run -# time. Built on demand (not part of `all`) for radar.model == kamil_adc. -KAMIL_COLLECTOR_DIR := data_acq_and_processing/kamil_adc_collector -# The collector now also drives the RF switches itself (switch-aware mode), so it -# uses the common H7992 driver and run_config loader, which -# pull in the common config/ipc/locator headers and the vendored nlohmann/json. -KAMIL_COLLECTOR_INCLUDES := \ - -I$(KAMIL_COLLECTOR_DIR)/include \ - -I$(KAMIL_COLLECTOR_DIR)/vendor/lcard \ +# time. Built on demand (not part of `all`) for radar.model == rph_radar. +RPH_COLLECTOR_DIR := data_acq_and_processing/rph_radar_collector +# The collector drives H7992 switches from the validated JSON supplied by Python. +RPH_COLLECTOR_INCLUDES := \ + -I$(RPH_COLLECTOR_DIR)/include \ + -I$(RPH_COLLECTOR_DIR)/vendor/lcard \ -Idata_acq_and_processing/common_cpp/ipc/include \ -Idata_acq_and_processing/common_cpp/config/include \ -Idata_acq_and_processing/common_cpp/hardware/include \ -Idata_acq_and_processing/processing/locator/include \ -Idata_acq_and_processing/third_party -KAMIL_COLLECTOR_LDFLAGS := -pthread -ldl -lutil -# Collector sources, including the shared drivers/config loader it reuses. These -# are compiled into a private build/kamil/ tree (see rule below) so they never +RPH_COLLECTOR_LDFLAGS := -pthread -ldl -lutil +# Collector sources, including the shared H7992 driver. These +# are compiled into a private build/rph/ tree (see rule below) so they never # collide with pipeline objects, which use different include paths. -KAMIL_COLLECTOR_SOURCES := \ - $(KAMIL_COLLECTOR_DIR)/src/main.cpp \ - $(KAMIL_COLLECTOR_DIR)/src/tty_protocol_writer.cpp \ - $(KAMIL_COLLECTOR_DIR)/src/capture_file_writer.cpp \ - data_acq_and_processing/common_cpp/config/src/run_config.cpp \ - data_acq_and_processing/common_cpp/ipc/src/shared_types.cpp \ +RPH_COLLECTOR_SOURCES := \ + $(RPH_COLLECTOR_DIR)/src/main.cpp \ + $(RPH_COLLECTOR_DIR)/src/tty_protocol_writer.cpp \ data_acq_and_processing/common_cpp/hardware/src/h7992_minimal_driver.cpp PREPROCESSOR_OBJS := $(addprefix $(BUILD_DIR)/,$(COMMON_SOURCES:.cpp=.o) $(PREPROC_SOURCES:.cpp=.o)) DATA_PROCESSOR_OBJS := $(addprefix $(BUILD_DIR)/,$(COMMON_SOURCES:.cpp=.o) $(PROCESSOR_SOURCES:.cpp=.o)) -KAMIL_BUILD_DIR := $(BUILD_DIR)/kamil -KAMIL_COLLECTOR_OBJS := $(addprefix $(KAMIL_BUILD_DIR)/,$(KAMIL_COLLECTOR_SOURCES:.cpp=.o)) -DEPFILES := $(sort $(PREPROCESSOR_OBJS:.o=.d) $(DATA_PROCESSOR_OBJS:.o=.d) $(KAMIL_COLLECTOR_OBJS:.o=.d)) +RPH_BUILD_DIR := $(BUILD_DIR)/rph +RPH_COLLECTOR_OBJS := $(addprefix $(RPH_BUILD_DIR)/,$(RPH_COLLECTOR_SOURCES:.cpp=.o)) +DEPFILES := $(sort $(PREPROCESSOR_OBJS:.o=.d) $(DATA_PROCESSOR_OBJS:.o=.d) $(RPH_COLLECTOR_OBJS:.o=.d)) NATIVE_TEST_SOURCES := $(COMMON_SOURCES) $(LOCATOR_SOURCES) \ data_acq_and_processing/tests/native_regressions.cpp \ data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp \ $(filter-out %/main.cpp,$(PREPROC_SOURCES)) \ - $(KAMIL_COLLECTOR_DIR)/src/capture_file_writer.cpp \ - $(KAMIL_COLLECTOR_DIR)/src/tty_protocol_writer.cpp + $(RPH_COLLECTOR_DIR)/src/tty_protocol_writer.cpp \ + data_acq_and_processing/common_cpp/hardware/src/h7992_minimal_driver.cpp NATIVE_BUILD_DIR := $(BUILD_DIR)/native_tests NATIVE_TEST_OBJS := $(addprefix $(NATIVE_BUILD_DIR)/,$(NATIVE_TEST_SOURCES:.cpp=.o)) NATIVE_CXXFLAGS := $(CXXFLAGS) -O1 -g $(if $(NATIVE_SANITIZERS),-fsanitize=$(NATIVE_SANITIZERS) -fno-omit-frame-pointer) NATIVE_FLAGS_STAMP := $(NATIVE_BUILD_DIR)/compiler.flags $(shell mkdir -p $(NATIVE_BUILD_DIR) && echo '$(NATIVE_CXXFLAGS)' | cmp -s - $(NATIVE_FLAGS_STAMP) || echo '$(NATIVE_CXXFLAGS)' > $(NATIVE_FLAGS_STAMP)) -DEPFILES += $(NATIVE_TEST_OBJS:.o=.d) +DEPFILES += $(NATIVE_TEST_OBJS:.o=.d) $(RPH_BUILD_DIR)/data_acq_and_processing/tests/collector_regressions.d TARGETS := \ $(BIN_DIR)/data_preprocessor \ $(BIN_DIR)/data_processor -.PHONY: all clean kamil_adc_collector check-native check-sar3d +.PHONY: all clean rph_radar_collector check-native check-sar3d all: $(TARGETS) -kamil_adc_collector: $(BIN_DIR)/kamil_adc_collector +rph_radar_collector: $(BIN_DIR)/rph_radar_collector -check-native: $(NATIVE_BUILD_DIR)/native_regressions +check-native: $(NATIVE_BUILD_DIR)/native_regressions $(BIN_DIR)/collector_regressions $(NATIVE_BUILD_DIR)/rph_sdk/libx502api.so + LD_LIBRARY_PATH=$(abspath $(NATIVE_BUILD_DIR)/rph_sdk) $(BIN_DIR)/collector_regressions $(NATIVE_BUILD_DIR)/native_regressions run_config_examples/run_config_simulator.example.json SAR3D_TEST_SOURCES := data_acq_and_processing/tests/sar3d_fixture.cpp \ @@ -146,7 +142,7 @@ $(NATIVE_BUILD_DIR)/native_regressions: $(NATIVE_TEST_OBJS) $(NATIVE_BUILD_DIR)/%.o: %.cpp $(NATIVE_FLAGS_STAMP) @mkdir -p $(dir $@) - $(CXX) $(NATIVE_CXXFLAGS) $(INCLUDES) -I$(KAMIL_COLLECTOR_DIR)/include -c $< -o $@ + $(CXX) $(NATIVE_CXXFLAGS) $(INCLUDES) -Idata_acq_and_processing/common_cpp/hardware/include -I$(RPH_COLLECTOR_DIR)/include -c $< -o $@ $(BIN_DIR)/data_preprocessor: $(PREPROCESSOR_OBJS) @mkdir -p $(BIN_DIR) @@ -161,13 +157,24 @@ $(BUILD_DIR)/data_acq_and_processing/processing/processors/src/sar3d_gpu.o: CXXF $(BUILD_DIR)/data_acq_and_processing/processing/processors/src/sar3d_gpu.o: $(SAR3D_CUDA_STAMP) # Keep source-relative paths so moving a source also replaces its dependencies. -$(KAMIL_BUILD_DIR)/%.o: %.cpp +$(RPH_BUILD_DIR)/%.o: %.cpp @mkdir -p $(dir $@) - $(CXX) $(CXXFLAGS) $(KAMIL_COLLECTOR_INCLUDES) -c $< -o $@ + $(CXX) $(CXXFLAGS) $(RPH_COLLECTOR_INCLUDES) -c $< -o $@ -$(BIN_DIR)/kamil_adc_collector: $(KAMIL_COLLECTOR_OBJS) +$(RPH_BUILD_DIR)/data_acq_and_processing/tests/collector_regressions.o: data_acq_and_processing/tests/collector_regressions.cpp + +$(NATIVE_BUILD_DIR)/rph_sdk/libx502api.so: data_acq_and_processing/tests/rph_sdk_fixture.cpp + @mkdir -p $(@D) + $(CXX) -std=c++20 -shared -fPIC -I$(RPH_COLLECTOR_DIR)/vendor/lcard $< -o $@ + ln -sf libx502api.so $(@D)/libe502api.so + +$(BIN_DIR)/collector_regressions: $(RPH_BUILD_DIR)/data_acq_and_processing/tests/collector_regressions.o $(filter-out %/main.o,$(RPH_COLLECTOR_OBJS)) @mkdir -p $(BIN_DIR) - $(CXX) $(KAMIL_COLLECTOR_OBJS) -o $@ $(KAMIL_COLLECTOR_LDFLAGS) + $(CXX) $^ -o $@ $(RPH_COLLECTOR_LDFLAGS) + +$(BIN_DIR)/rph_radar_collector: $(RPH_COLLECTOR_OBJS) + @mkdir -p $(BIN_DIR) + $(CXX) $(RPH_COLLECTOR_OBJS) -o $@ $(RPH_COLLECTOR_LDFLAGS) $(BUILD_DIR)/%.o: %.cpp @mkdir -p $(dir $@) diff --git a/README.md b/README.md index 951c455..005435e 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ skips overwritten frames and continues from the retained tail: 1. **Acquisition** — Python producers configure the radar, acquire frequency sweeps, and publish raw S21 traces. Both single and multi-device LibreVNA use - `matrix_raw_producer`; Kamil ADC uses `kamil_adc_raw_producer` with its C++ collector. + `matrix_raw_producer`; RadioPhotonic radar (RPH) uses `rph_radar_raw_producer` with its C++ collector. 2. **Preprocessing** — `data_preprocessor` applies through calibration, reference subtraction, optional frequency-domain notch filtering, and an optional object-based MIMO correction of S21. @@ -45,8 +45,11 @@ controls, processing settings and a copy of the live plot through an embedded we - **librevna** — single LibreVNA. - **librevna_multi** — synchronized multi-device LibreVNA. -- **kamil_adc** — [Kamil ADC collector](data_acq_and_processing/kamil_adc_collector/README.md), - with optional laser control and [temperature monitoring](python_app/hardware_full/laser_control/monitoring/README.md). +- **rph_radar** — [RadioPhotonic radar (RPH) collector](data_acq_and_processing/rph_radar_collector/README.md), + with manual laser setpoints or LD1 current variation via **Apply Radar**. + The application owns the collector and exchanges complete sweeps over a PTY. + Switching to a different antenna pair starts a fresh ADC session after settling; + queued samples and an initial partial sweep are discarded before using that pair. - **mock / simulator** — driver-less mode for development without hardware. ## Processing modes @@ -86,7 +89,7 @@ so an older in-flight result cannot overwrite new geometry. | Path | Contents | | --- | --- | -| `data_acq_and_processing/` | C++ pipeline: `preprocessing/`, `processing/` (including `locator/`), `kamil_adc_collector/`, shared `common_cpp/` (config, IPC rings, H7992 driver), and vendored `third_party/` (Eigen, nlohmann/json). | +| `data_acq_and_processing/` | C++ pipeline: `preprocessing/`, `processing/` (including `locator/`), `rph_radar_collector/`, shared `common_cpp/` (config, IPC rings, H7992 driver), and vendored `third_party/` (Eigen, nlohmann/json). | | `python_app/` | Python application: `gui/`, `orchestration/` (process supervisor, shared-memory readers, config), `hardware_full/` (device drivers and services), `webui/`, `storage/`, `models/`, `workflows/`, `scripts/` (producers and tools), and `tests/`. | | `run_config_examples/` | Ready-made configuration profiles, one per hardware setup. | | `deploy/` | systemd daemon installer, and the USB update kit for the Pi and Jetson (`pack-usb.sh`, `update.sh`). | @@ -96,8 +99,7 @@ so an older in-flight result cannot overwrite new geometry. ## Requirements - Linux (POSIX shared memory; GPIO on Raspberry Pi). The deployment target is a - **Raspberry Pi 5**; hardware PWM gating for the generator sweep is Pi 5 - specific (GPIO12/13/18/19, `dtoverlay=pwm-2chan`). + **Raspberry Pi 5**. - A C++20 compiler and `make`; Eigen and nlohmann/json are vendored. - `libusb-1.0` runtime and `libatomic.so.1` (`libatomic1` on Debian/Ubuntu). Python ring readers/writers use lock-free atomic loads, stores and cursor CAS @@ -121,7 +123,7 @@ python3 -m venv .venv .venv/bin/python -m pip install --upgrade pip .venv/bin/python -m pip install -r requirements.txt make # data_preprocessor and data_processor in build/bin/ -make kamil_adc_collector # also required for Kamil ADC acquisition +make rph_radar_collector # also required for RadioPhotonic radar (RPH) acquisition ``` `make` builds the sar3d GPU code when it finds CUDA (see Requirements) and @@ -163,7 +165,7 @@ not need to be replaced. The default `run_config.json` already sets `radar.driver_mode: "mock"`, so the producer generates synthetic S-parameters and no radar is needed. Mock acquisition -supports the LibreVNA models; Kamil ADC needs native acquisition. +supports the LibreVNA models; RadioPhotonic radar (RPH) needs native acquisition. Both LibreVNA modes share one simulator: a point reflector moves back and forth along the antenna array and between depths 3.25–4.25 m over an eight-second cycle. Each S21 uses the actual transmitter–target–receiver distance from the configured @@ -250,9 +252,9 @@ are never overwritten; missing raw/preprocessed counterparts produce empty array and stages pair only by the exact acquisition key. Its `vna_config_profile.json` companion leaves the NumPy dataset's `config_profile.json` intact. A streaming write failure keeps all collections already published; only the incomplete collection is discarded. -The offline snapshot converter uses this same exporter. Legacy snapshots without -capture windows retain repeated IDs through one-to-one time/index matching; -processing completion timestamps are not compared with acquisition timestamps. +Legacy snapshots without capture windows retain repeated IDs through one-to-one +time/index matching; processing completion timestamps are not compared with +acquisition timestamps. ### Calibration and reference sets @@ -342,8 +344,38 @@ back to the root profile. **Load Config** and **Save Config** manage desktop pro Hardware examples are in `run_config_examples/`. The root `run_config.json` is intentionally mock; select a hardware profile explicitly on a real device. A -change of model, serials or sweep parameters changes the `radar_key` used to find -calibration/reference sets; select or capture matching sets before **Start**. +change of model, serials, sweep parameters or multi-device switch positions changes +the `radar_key` used to find calibration/reference sets; select or capture matching +sets before **Start**. Changing the multi-device reference-clock source also resets +display history. For RadioPhotonic radar (RPH), identity follows the phase anchors, output +band and acquisition/laser settings; executable/TTY paths and LibreVNA-only sweep +fields do not affect it. RPH processing frequency controls use its output band. +Phase, band and timeout errors are rejected before a running pipeline is stopped. + +Profiles with model `kamil_adc` are rejected with an explicit error: use +`rph_radar` and the `radar.rph_radar` section. An inactive `radar.kamil_adc` +section left in older saved profiles is ignored; there are no aliases or automatic +data migrations. Old RPH calibration/reference sets use a different +measurement key and are not selected by the renamed mode. + +### RPH phase calibration + +Use `run_config_examples/run_config_rph_radar.example.json` for **RadioPhotonic radar (RPH)**. +Apply the desired laser settings with **Apply Radar**, then stop acquisition before +running the calibration tool: + +```bash +.venv/bin/python -m python_app.scripts.rph_radar_calibrate --config run_config.json --sweeps 200 +.venv/bin/python -m python_app.scripts.rph_radar_calibrate --config run_config.json --sweeps 200 --apply +``` + +Pass the path to your active RPH profile. The first command reports measured phase +anchors; `--apply` captures again and writes only `phase0_rad` and `phase1_rad` in +`radar.rph_radar.phase_calibration`. Frequency anchors must already describe the +known sweep endpoints. The tool uses current acquisition settings, sends no laser +commands, preserves every other JSON byte, and refuses to overwrite a profile +edited during capture. Band and point count stay unchanged. `--warmup` controls +how many initial sweeps to discard (default 10). ## Deployment @@ -394,7 +426,7 @@ ASAN_OPTIONS=detect_leaks=0 make check-native NATIVE_SANITIZERS=address,undefine The tests cover failed writes and settings commits, recordings during manual Save, repeated Start/Stop/Close, web-action cancellation, malformed IPC frames, hardware resource cleanup with mock failures, and an isolated deployment update. Native tests -also exercise mutated frames, ring concurrency, locator input and ADC spool recovery. +also exercise mutated frames, ring concurrency, locator input and ADC whole-sweep PTY overflow recovery. Firmware host tests check cart timer rollover and SBUS packing; they do not validate physical timing. Real devices, GPIO and the CUDA backend need their own hardware run. diff --git a/data_acq_and_processing/kamil_adc_collector/README.md b/data_acq_and_processing/kamil_adc_collector/README.md deleted file mode 100644 index 6528a58..0000000 --- a/data_acq_and_processing/kamil_adc_collector/README.md +++ /dev/null @@ -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:` from `radar.kamil_adc.tty_path`. - -When both RF switches use `driver_mode: "native"`, the producer also passes -`config:` 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:` and emits no combo tags. Calibration captures also -use this Python-controlled path. diff --git a/data_acq_and_processing/kamil_adc_collector/include/capture_file_writer.h b/data_acq_and_processing/kamil_adc_collector/include/capture_file_writer.h deleted file mode 100644 index 8d24ae9..0000000 --- a/data_acq_and_processing/kamil_adc_collector/include/capture_file_writer.h +++ /dev/null @@ -1,53 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -struct CapturePacket { - std::size_t packet_index = 0; - std::size_t channel_count = 2; - bool has_di1_trace = false; - std::vector ch1; - std::vector ch2; - std::vector 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& 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& 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_; -}; diff --git a/data_acq_and_processing/kamil_adc_collector/include/switch_sequencer.h b/data_acq_and_processing/kamil_adc_collector/include/switch_sequencer.h deleted file mode 100644 index e0df25b..0000000 --- a/data_acq_and_processing/kamil_adc_collector/include/switch_sequencer.h +++ /dev/null @@ -1,146 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#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 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 input_switch, - std::unique_ptr 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 input_switch_; - std::unique_ptr output_switch_; - std::size_t index_ = 0; - radar::ipc::ComboKey applied_{}; - bool upcoming_dirty_ = false; -}; - -} // namespace radar::kamil diff --git a/data_acq_and_processing/kamil_adc_collector/src/capture_file_writer.cpp b/data_acq_and_processing/kamil_adc_collector/src/capture_file_writer.cpp deleted file mode 100644 index 8ab2491..0000000 --- a/data_acq_and_processing/kamil_adc_collector/src/capture_file_writer.cpp +++ /dev/null @@ -1,1047 +0,0 @@ -#include "capture_file_writer.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace { - -constexpr std::size_t kLivePlotMaxColumns = 800; -constexpr uint32_t kCsvSpoolMagic = 0x4C564353U; // "SVCL" -constexpr uint32_t kCsvSpoolVersion = 2U; - -struct CsvSpoolFileHeader { - uint32_t magic = kCsvSpoolMagic; - uint32_t version = kCsvSpoolVersion; - uint32_t channel_count = 0; - uint32_t has_di1_trace = 0; -}; - -struct CsvSpoolPacketHeader { - uint64_t packet_index = 0; - uint64_t channel_count = 0; - uint64_t frame_count = 0; - uint64_t has_di1_trace = 0; -}; - -struct PlotPoint { - std::size_t sample_index = 0; - double value = 0.0; -}; - -std::size_t packet_channel_count(const CapturePacket& packet) { - return (packet.channel_count <= 1U) ? 1U : 2U; -} - -bool packet_has_ch2(const CapturePacket& packet) { - return packet_channel_count(packet) >= 2U; -} - -bool packet_has_di1_trace(const CapturePacket& packet) { - return packet.has_di1_trace && !packet.di1.empty(); -} - -std::size_t packet_frame_count(const CapturePacket& packet) { - std::size_t frames = packet_has_ch2(packet) ? std::min(packet.ch1.size(), packet.ch2.size()) : packet.ch1.size(); - if (packet_has_di1_trace(packet)) { - frames = std::min(frames, packet.di1.size()); - } - return frames; -} - -std::vector build_min_max_trace(const std::vector& data, std::size_t max_columns) { - std::vector result; - if (data.empty()) { - return result; - } - - const std::size_t bucket_size = std::max(1, (data.size() + max_columns - 1) / max_columns); - result.reserve(max_columns * 2); - - for (std::size_t begin = 0; begin < data.size(); begin += bucket_size) { - const std::size_t end = std::min(begin + bucket_size, data.size()); - std::size_t min_index = begin; - std::size_t max_index = begin; - for (std::size_t i = begin + 1; i < end; ++i) { - if (data[i] < data[min_index]) { - min_index = i; - } - if (data[i] > data[max_index]) { - max_index = i; - } - } - - if (min_index <= max_index) { - result.push_back({min_index, data[min_index]}); - if (max_index != min_index) { - result.push_back({max_index, data[max_index]}); - } - } else { - result.push_back({max_index, data[max_index]}); - result.push_back({min_index, data[min_index]}); - } - } - - if (result.back().sample_index != (data.size() - 1)) { - result.push_back({data.size() - 1, data.back()}); - } - - return result; -} - -std::vector build_digital_step_trace(const std::vector& data) { - std::vector result; - if (data.empty()) { - return result; - } - - result.reserve(std::min(data.size() * 2U, 16384U)); - uint8_t previous = data.front(); - result.push_back({0U, static_cast(previous)}); - - for (std::size_t i = 1; i < data.size(); ++i) { - const uint8_t current = data[i]; - if (current != previous) { - result.push_back({i - 1U, static_cast(previous)}); - result.push_back({i, static_cast(current)}); - previous = current; - } - } - - if (result.back().sample_index != (data.size() - 1U)) { - result.push_back({data.size() - 1U, static_cast(data.back())}); - } - - return result; -} - -std::string polyline_points(const std::vector& trace, - std::size_t packet_start_index, - std::size_t total_frames, - double y_min, - double y_max, - double left, - double top, - double width, - double height) { - std::ostringstream out; - out << std::fixed << std::setprecision(3); - const double y_span = std::max(1e-12, y_max - y_min); - const double x_span = (total_frames > 1U) ? static_cast(total_frames - 1U) : 1.0; - - for (const auto& point : trace) { - const double global_index = static_cast(packet_start_index + point.sample_index); - const double x = left + (global_index / x_span) * width; - const double y = top + height - ((point.value - y_min) / y_span) * height; - out << x << "," << y << " "; - } - - return out.str(); -} - -std::string digital_polyline_points(const std::vector& trace, - std::size_t packet_start_index, - std::size_t total_frames, - double left, - double top, - double width, - double height) { - std::ostringstream out; - out << std::fixed << std::setprecision(3); - const double x_span = (total_frames > 1U) ? static_cast(total_frames - 1U) : 1.0; - - for (const auto& point : trace) { - const double global_index = static_cast(packet_start_index + point.sample_index); - const double x = left + (global_index / x_span) * width; - const double y = top + height - ((0.15 + 0.7 * point.value) * height); - out << x << "," << y << " "; - } - - return out.str(); -} - -std::string json_points(const std::vector& trace, double frame_freq_hz) { - std::ostringstream out; - out << std::fixed << std::setprecision(9); - out << "["; - bool first = true; - for (const auto& point : trace) { - if (!first) { - out << ","; - } - first = false; - out << "[" << (static_cast(point.sample_index) / frame_freq_hz) << "," << point.value << "]"; - } - out << "]"; - return out.str(); -} - -[[noreturn]] void fail_write(const std::string& message); - -std::string replace_extension(const std::string& path, const std::string& new_extension) { - const std::size_t slash_pos = path.find_last_of("/\\"); - const std::size_t dot_pos = path.find_last_of('.'); - if ((dot_pos == std::string::npos) || ((slash_pos != std::string::npos) && (dot_pos < slash_pos))) { - return path + new_extension; - } - return path.substr(0, dot_pos) + new_extension; -} - -std::string path_to_script_url(std::string path) { - for (char& ch : path) { - if (ch == '\\') { - ch = '/'; - } - } - - std::string encoded; - encoded.reserve(path.size() + 8); - for (char ch : path) { - switch (ch) { - case ' ': - encoded += "%20"; - break; - case '#': - encoded += "%23"; - break; - case '%': - encoded += "%25"; - break; - case '?': - encoded += "%3F"; - break; - default: - encoded.push_back(ch); - break; - } - } - - if ((encoded.size() >= 2U) && std::isalpha(static_cast(encoded[0])) && (encoded[1] == ':')) { - return "file:///" + encoded; - } - return encoded; -} - -std::string csv_spool_path(const std::string& csv_path) { - return replace_extension(csv_path, ".capture_spool.bin"); -} - -void finish_file(std::ofstream& stream, const std::string& path) { - stream.close(); - if (!stream) { - fail_write("Cannot finish writing file: " + path); - } -} - -void write_text_file(const std::string& path, const std::string& text) { - std::ofstream file(path, std::ios::binary); - if (!file) { - fail_write("Cannot open file for writing: " + path); - } - file << text; - finish_file(file, path); -} - -void write_live_data_script(const std::string& path, const std::string& data_json) { - write_text_file(path, "window.e502LiveData = " + data_json + ";\n"); -} - -void write_live_html_document(const std::string& path, const std::string& data_script_url) { - std::ofstream html(path, std::ios::binary); - if (!html) { - fail_write("Cannot open live HTML for writing: " + path); - } - - html << "\n" - << "\n" - << "\n" - << " \n" - << " \n" - << " E-502 Live Plot\n" - << " \n" - << "\n" - << "\n" - << "
\n" - << "
\n" - << "
\n" - << "
\n" - << "
E-502 Live Packet Plot
\n" - << "
\n" - << " Waiting for packets...\n" - << " \n" - << " \n" - << " \n" - << " \n" - << "
\n" - << "
\n" - << "
\n" - << " Auto-refresh every 500 ms\n" - << "
\n" - << "
\n" - << "
Open this page once and leave it open. It refreshes its data script to pick up new packets.
\n" - << "
\n" - << " \n" - << " \n" - << " \n" - << " \n" - << " \n" - << " \n" - << " Wheel: X zoom, Shift+wheel: Y zoom, double-click: reset\n" - << "
\n" - << " \n" - << "
\n" - << " CH1\n" - << " CH2\n" - << " DI1\n" - << "
\n" - << "
\n" - << "
\n" - << " \n" - << "\n" - << "\n"; - finish_file(html, path); -} - -[[noreturn]] void fail_write(const std::string& message) { - throw std::runtime_error(message); -} - -} // namespace - -CaptureFileWriter::CaptureFileWriter(std::string csv_path, - std::string svg_path, - std::string live_html_path, - std::string live_json_path) - : csv_path_(std::move(csv_path)), - svg_path_(std::move(svg_path)), - live_html_path_(std::move(live_html_path)), - live_json_path_(std::move(live_json_path)) {} - -const std::string& CaptureFileWriter::live_html_path() const { - return live_html_path_; -} - -const std::string& CaptureFileWriter::live_json_path() const { - return live_json_path_; -} - -void CaptureFileWriter::write(const std::vector& packets, - double frame_freq_hz, - double nominal_range_v) const { - finalize_csv_from_spool(frame_freq_hz); - write_svg(packets, frame_freq_hz, nominal_range_v); -} - -void CaptureFileWriter::initialize_live_plot() const { - const std::string waiting_json = - "{\n" - " \"status\": \"waiting\",\n" - " \"message\": \"Waiting for first packet...\"\n" - "}\n"; - const std::string live_script_path = replace_extension(live_json_path_, ".js"); - write_text_file(live_json_path_, waiting_json); - write_live_data_script(live_script_path, waiting_json); - write_live_html_document(live_html_path_, path_to_script_url(live_script_path)); -} - -void CaptureFileWriter::initialize_csv(std::size_t channel_count, bool has_di1_trace) const { - if (channel_count != 1U && channel_count != 2U) { - fail_write("Capture channel count must be 1 or 2"); - } - std::ofstream csv_file(csv_path_, std::ios::binary | std::ios::trunc); - if (!csv_file) { - fail_write("Cannot prepare CSV output file: " + csv_path_); - } - finish_file(csv_file, csv_path_); - - const std::string spool_path = csv_spool_path(csv_path_); - std::ofstream spool(spool_path, std::ios::binary | std::ios::trunc); - if (!spool) { - fail_write("Cannot open CSV spool for writing: " + spool_path); - } - const CsvSpoolFileHeader header{ - kCsvSpoolMagic, - kCsvSpoolVersion, - static_cast(channel_count), - static_cast(has_di1_trace ? 1U : 0U) - }; - spool.write(reinterpret_cast(&header), sizeof(header)); - finish_file(spool, spool_path); -} - -void CaptureFileWriter::append_csv_packet(const CapturePacket& packet) const { - if ((packet.channel_count != 1U && packet.channel_count != 2U) - || (packet.channel_count == 2U && packet.ch1.size() != packet.ch2.size()) - || (packet.has_di1_trace && packet.di1.size() != packet.ch1.size())) { - fail_write("Capture packet channel lengths are inconsistent"); - } - - const std::string spool_path = csv_spool_path(csv_path_); - std::ofstream spool(spool_path, std::ios::binary | std::ios::app); - if (!spool) { - fail_write("Cannot open CSV spool for appending: " + spool_path); - } - - const std::size_t frames = packet_frame_count(packet); - const std::size_t channel_count = packet_channel_count(packet); - const CsvSpoolPacketHeader header{ - static_cast(packet.packet_index), - static_cast(channel_count), - static_cast(frames), - static_cast(packet_has_di1_trace(packet) ? 1U : 0U) - }; - spool.write(reinterpret_cast(&header), sizeof(header)); - if (frames != 0U) { - spool.write(reinterpret_cast(packet.ch1.data()), - static_cast(frames * sizeof(double))); - if (channel_count >= 2U) { - spool.write(reinterpret_cast(packet.ch2.data()), - static_cast(frames * sizeof(double))); - } - if (packet_has_di1_trace(packet)) { - std::vector di1_bytes(frames); - for (std::size_t i = 0; i < frames; ++i) { - di1_bytes[i] = packet.di1[i] ? 1U : 0U; - } - spool.write(reinterpret_cast(di1_bytes.data()), - static_cast(frames * sizeof(uint8_t))); - } - } - finish_file(spool, spool_path); -} - -void CaptureFileWriter::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::size_t frames = packet_frame_count(packet); - const double duration_ms = (frames == 0U) ? 0.0 : (1000.0 * static_cast(frames) / frame_freq_hz); - const double zeroed_percent = (stored_samples == 0U) - ? 0.0 - : (100.0 * static_cast(zeroed_samples) / static_cast(stored_samples)); - const auto trace1 = build_min_max_trace(packet.ch1, kLivePlotMaxColumns); - const auto trace2 = build_min_max_trace(packet.ch2, kLivePlotMaxColumns); - const auto di1_trace = build_digital_step_trace(packet.di1); - - std::ostringstream json; - json << std::fixed << std::setprecision(9); - json << "{\n" - << " \"status\": \"packet\",\n" - << " \"packetIndex\": " << packet.packet_index << ",\n" - << " \"channelCount\": " << packet_channel_count(packet) << ",\n" - << " \"packetsSeen\": " << packets_seen << ",\n" - << " \"packetsPerSecond\": " << packets_per_second << ",\n" - << " \"framesPerChannel\": " << frames << ",\n" - << " \"frameFreqHz\": " << frame_freq_hz << ",\n" - << " \"durationMs\": " << duration_ms << ",\n" - << " \"closeReason\": \"" << close_reason << "\",\n" - << " \"zeroedSamples\": " << zeroed_samples << ",\n" - << " \"storedSamples\": " << stored_samples << ",\n" - << " \"zeroedPercent\": " << zeroed_percent << ",\n" - << " \"hasDi1Trace\": " << (packet_has_di1_trace(packet) ? "true" : "false") << ",\n" - << " \"di1Frames\": " << packet.di1.size() << ",\n" - << " \"updatedAt\": \"packet " << packet.packet_index << "\",\n" - << " \"ch1\": " << json_points(trace1, frame_freq_hz) << ",\n" - << " \"ch2\": " << json_points(trace2, frame_freq_hz) << ",\n" - << " \"di1\": " << json_points(di1_trace, frame_freq_hz) << "\n" - << "}\n"; - - const std::string json_text = json.str(); - write_text_file(live_json_path_, json_text); - write_live_data_script(replace_extension(live_json_path_, ".js"), json_text); -} - -void CaptureFileWriter::finalize_csv_from_spool(double frame_freq_hz) const { - const std::string spool_path = csv_spool_path(csv_path_); - std::ifstream spool(spool_path, std::ios::binary); - if (!spool) { - fail_write("Cannot open CSV spool for reading: " + spool_path); - } - - CsvSpoolFileHeader file_header{}; - spool.read(reinterpret_cast(&file_header), sizeof(file_header)); - if (!spool || (file_header.magic != kCsvSpoolMagic) || (file_header.version != kCsvSpoolVersion)) { - fail_write("Invalid CSV spool format: " + spool_path); - } - - std::ofstream csv(csv_path_, std::ios::binary | std::ios::trunc); - if (!csv) { - fail_write("Cannot open CSV for final writing: " + csv_path_); - } - - bool header_written = false; - std::size_t global_frame_index = 0; - - while (true) { - CsvSpoolPacketHeader packet_header{}; - spool.read(reinterpret_cast(&packet_header), sizeof(packet_header)); - if (!spool) { - if (spool.eof() && spool.gcount() == 0) { - break; - } - fail_write("Cannot read packet header from CSV spool: " + spool_path); - } - - const std::size_t packet_index = static_cast(packet_header.packet_index); - const std::size_t channel_count = static_cast(packet_header.channel_count); - const std::size_t frames = static_cast(packet_header.frame_count); - const bool has_di1_trace = packet_header.has_di1_trace != 0U; - if (channel_count != 1U && channel_count != 2U) { - fail_write("Invalid channel count in CSV spool: " + spool_path); - } - const auto data_position = spool.tellg(); - spool.seekg(0, std::ios::end); - const auto remaining = spool.tellg() - data_position; - spool.seekg(data_position); - const auto bytes_per_frame = channel_count * sizeof(double) + (has_di1_trace ? 1U : 0U); - if (!spool || remaining < 0 || packet_header.frame_count > static_cast(remaining) / bytes_per_frame) { - fail_write("Truncated or invalid packet data in CSV spool: " + spool_path); - } - - if (!header_written) { - csv << "packet_index,frame_index,global_frame_index,time_s,packet_time_s,ch1_v"; - if (channel_count >= 2U) { - csv << ",ch2_v"; - } - if (has_di1_trace || (file_header.has_di1_trace != 0U)) { - csv << ",di1"; - } - csv << "\n"; - header_written = true; - } - - std::vector ch1(frames); - std::vector ch2((channel_count >= 2U) ? frames : 0U); - std::vector di1(has_di1_trace ? frames : 0U); - if (frames != 0U) { - spool.read(reinterpret_cast(ch1.data()), static_cast(frames * sizeof(double))); - if (!spool) { - fail_write("Cannot read CH1 data from CSV spool: " + spool_path); - } - if (channel_count >= 2U) { - spool.read(reinterpret_cast(ch2.data()), static_cast(frames * sizeof(double))); - if (!spool) { - fail_write("Cannot read CH2 data from CSV spool: " + spool_path); - } - } - if (has_di1_trace) { - spool.read(reinterpret_cast(di1.data()), static_cast(frames * sizeof(uint8_t))); - if (!spool) { - fail_write("Cannot read DI1 trace from CSV spool: " + spool_path); - } - } - } - - std::ostringstream out; - out << std::fixed << std::setprecision(9); - for (std::size_t i = 0; i < frames; ++i) { - const double time_s = static_cast(global_frame_index) / frame_freq_hz; - const double packet_time_s = static_cast(i) / frame_freq_hz; - out << packet_index << "," << i << "," << global_frame_index << "," - << time_s << "," << packet_time_s << "," << ch1[i]; - if (channel_count >= 2U) { - out << "," << ch2[i]; - } - if (has_di1_trace || (file_header.has_di1_trace != 0U)) { - out << "," << (has_di1_trace ? static_cast(di1[i]) : 0U); - } - out << "\n"; - ++global_frame_index; - } - csv << out.str(); - } - - if (!header_written) { - csv << "packet_index,frame_index,global_frame_index,time_s,packet_time_s,ch1_v"; - if (file_header.channel_count >= 2U) { - csv << ",ch2_v"; - } - if (file_header.has_di1_trace != 0U) { - csv << ",di1"; - } - csv << "\n"; - } - - csv.flush(); - if (!csv) { - fail_write("Cannot flush CSV output; capture spool retained: " + csv_path_); - } - csv.close(); - if (!csv) { - fail_write("Cannot close CSV output; capture spool retained: " + csv_path_); - } - std::remove(spool_path.c_str()); -} - -void CaptureFileWriter::write_svg(const std::vector& packets, - double frame_freq_hz, - double nominal_range_v) const { - std::ofstream file(svg_path_, std::ios::binary); - if (!file) { - fail_write("Cannot open SVG for writing: " + svg_path_); - } - - std::size_t total_frames = 0; - double min_y = std::numeric_limits::infinity(); - double max_y = -std::numeric_limits::infinity(); - const std::size_t channel_count = packets.empty() ? 2U : packet_channel_count(packets.front()); - const bool has_di1_trace = std::any_of(packets.begin(), packets.end(), [](const CapturePacket& packet) { - return packet_has_di1_trace(packet); - }); - for (const auto& packet : packets) { - const std::size_t frames = packet_frame_count(packet); - total_frames += frames; - for (std::size_t i = 0; i < frames; ++i) { - min_y = std::min(min_y, packet.ch1[i]); - max_y = std::max(max_y, packet.ch1[i]); - if (packet_has_ch2(packet)) { - min_y = std::min(min_y, packet.ch2[i]); - max_y = std::max(max_y, packet.ch2[i]); - } - } - } - - if (!std::isfinite(min_y) || !std::isfinite(max_y) || (min_y == max_y)) { - min_y = -nominal_range_v; - max_y = nominal_range_v; - } else { - const double pad = std::max(0.1, (max_y - min_y) * 0.08); - min_y -= pad; - max_y += pad; - } - - const double total_time_s = (total_frames > 1U) ? (static_cast(total_frames - 1U) / frame_freq_hz) : 0.0; - const double width = 1400.0; - const double height = 800.0; - const double left = 90.0; - const double right = 40.0; - const double top = 40.0; - const double bottom = 80.0; - const double plot_w = width - left - right; - const double plot_h = height - top - bottom; - const double zero_y = top + plot_h - ((0.0 - min_y) / std::max(1e-12, max_y - min_y)) * plot_h; - const double di1_band_top = top + 10.0; - const double di1_band_height = 44.0; - - file << "\n"; - file << " \n"; - file << " \n"; - - for (int i = 0; i <= 10; ++i) { - const double x = left + (plot_w * i / 10.0); - const double y = top + (plot_h * i / 10.0); - file << " \n"; - file << " \n"; - } - - if ((0.0 >= min_y) && (0.0 <= max_y)) { - file << " \n"; - } - if (has_di1_trace) { - file << " \n"; - file << " \n"; - file << " DI1\n"; - } - - std::size_t frame_offset = 0; - for (std::size_t packet_idx = 0; packet_idx < packets.size(); ++packet_idx) { - const auto& packet = packets[packet_idx]; - const std::size_t frames = packet_frame_count(packet); - if (frames == 0U) { - continue; - } - - const double x0 = left + ((total_frames > 1U) - ? (static_cast(frame_offset) / static_cast(total_frames - 1U)) * plot_w - : 0.0); - const double x1 = left + ((total_frames > 1U) - ? (static_cast(frame_offset + frames - 1U) / static_cast(total_frames - 1U)) * plot_w - : plot_w); - const char* packet_fill = (packet_idx % 2U == 0U) ? "#f7fafc" : "#fdfefe"; - file << " \n"; - if (packet_idx != 0U) { - file << " \n"; - } - - const auto trace1 = build_min_max_trace(packet.ch1, 1200); - const auto trace2 = build_min_max_trace(packet.ch2, 1200); - const auto trace_di1 = build_digital_step_trace(packet.di1); - file << " \n"; - if (packet_has_ch2(packet)) { - file << " \n"; - } - if (packet_has_di1_trace(packet)) { - file << " \n"; - } - - if (packets.size() <= 24U) { - file << " P" - << packet.packet_index << "\n"; - } - - frame_offset += frames; - } - - file << " E-502 capture: " << packets.size() << " packet(s), " - << ((channel_count >= 2U) ? "CH1 and CH2" : "CH1 only"); - if (has_di1_trace) { - file << ", DI1 trace"; - } - file << "\n"; - file << " time, s\n"; - file << " V\n"; - - file << std::fixed << std::setprecision(6); - for (int i = 0; i <= 10; ++i) { - const double x = left + (plot_w * i / 10.0); - const double t = total_time_s * i / 10.0; - file << " " << t << "\n"; - - const double y = top + plot_h - (plot_h * i / 10.0); - const double v = min_y + (max_y - min_y) * i / 10.0; - file << " " << v << "\n"; - } - - const double legend_y = height - 48.0; - file << " \n"; - file << " CH1\n"; - if (channel_count >= 2U) { - file << " \n"; - file << " CH2\n"; - } - if (has_di1_trace) { - file << " \n"; - file << " DI1\n"; - } - file << "\n"; - finish_file(file, svg_path_); -} diff --git a/data_acq_and_processing/kamil_adc_collector/src/main.cpp b/data_acq_and_processing/kamil_adc_collector/src/main.cpp deleted file mode 100644 index 0424b89..0000000 --- a/data_acq_and_processing/kamil_adc_collector/src/main.cpp +++ /dev/null @@ -1,3370 +0,0 @@ -#ifdef _WIN32 - #ifndef NOMINMAX - #define NOMINMAX - #endif -#endif - -#ifdef _MSC_VER - #pragma warning(push) - #pragma warning(disable: 4995) -#endif -#include "x502api.h" -#include "e502api.h" -#ifdef _MSC_VER - #pragma warning(pop) -#endif - -#include "capture_file_writer.h" -#include "tty_protocol_writer.h" - -#include "switch_sequencer.h" -#include "run_config.hpp" -#include "h7992_minimal_driver.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#ifndef _WIN32 - #include - #include -#endif - -namespace { - -enum class StopMode { - TargetFrames, - DiSyn2Rise, - DiSyn2Fall -}; - -enum class Di1Mode { - ZeroOnChange, - Trace, - Ignore -}; - -enum class CaptureProfile { - Manual, - Phase, - Amplitude -}; - -struct Config { - std::string serial; - std::optional ip_addr; - - CaptureProfile profile = CaptureProfile::Manual; - uint32_t channel_count = 2; - uint32_t mode = X502_LCH_MODE_DIFF; - uint32_t range = X502_ADC_RANGE_02; - uint32_t ch1 = 2; - uint32_t ch2 = 3; - - double sample_clock_hz = 125000.0; - bool sample_clock_specified = false; - bool max_internal_clock = false; - double duration_ms = 100.0; - uint32_t packet_limit = 0; - uint32_t internal_ref_freq = X502_REF_FREQ_2000KHZ; - - uint32_t sync_mode = X502_SYNC_DI_SYN1_RISE; - uint32_t sync_start_mode = X502_SYNC_DI_SYN2_RISE; - StopMode stop_mode = StopMode::DiSyn2Fall; - Di1Mode di1_mode = Di1Mode::ZeroOnChange; - - uint32_t recv_block_words = 32768; - uint32_t recv_timeout_ms = 50; - uint32_t stats_period_ms = 1000; - uint32_t start_wait_ms = 10000; - uint32_t input_buffer_words = 8 * 1024 * 1024; - uint32_t input_step_words = 32768; - uint32_t live_update_period_ms = 1000; - uint32_t svg_history_packets = 50; - - bool recv_block_specified = false; - bool input_buffer_specified = false; - bool input_step_specified = false; - bool live_update_specified = false; - bool mode_specified = false; - bool channel_count_specified = false; - bool ch1_specified = false; - bool ch2_specified = false; - - bool pullup_syn1 = false; - bool pullup_syn2 = false; - bool pulldown_conv_in = false; - bool pulldown_start_in = false; - - std::string csv_path = "capture.csv"; - std::string svg_path = "capture.svg"; - std::string live_html_path = "live_plot.html"; - std::string live_json_path = "live_plot.json"; - std::optional tty_path; - // When set, the collector reads switch and combo configuration from this - // run_config.json and drives the RF switches itself, one combination per - // sweep, tagging each emitted sweep with its combo (see SwitchSequencer). - std::optional run_config_path; - double switch_gap_ms = 10.0; - double switch_settle_ms = 1.0; - bool di1_group_average = false; - bool do1_toggle_per_frame = false; - bool do1_noise_subtract = false; - bool do1_raw_tty_marked = false; - bool do1_pair_subtract_avg = false; - std::optional noise_avg_steps; - bool do8_freq_ref = false; - uint32_t do8_cycle_period_ticks = 10; - std::optional do8_amplitude_threshold; -}; - -[[noreturn]] void fail(const std::string& message) { - throw std::runtime_error(message); -} - -std::string trim_copy(const std::string& text) { - const auto first = text.find_first_not_of(" \t\r\n"); - if (first == std::string::npos) { - return {}; - } - const auto last = text.find_last_not_of(" \t\r\n"); - return text.substr(first, last - first + 1); -} - -bool starts_with(const std::string& value, const std::string& prefix) { - return value.rfind(prefix, 0) == 0; -} - -uint32_t parse_u32(const std::string& text, const std::string& field_name) { - const std::string clean = trim_copy(text); - char* end = nullptr; - const auto value = std::strtoull(clean.c_str(), &end, 0); - if ((!clean.empty() && clean.front() == '-') || (end == clean.c_str()) - || (*end != '\0') || (value > std::numeric_limits::max())) { - fail("Invalid integer for " + field_name + ": " + text); - } - return static_cast(value); -} - -double parse_double(const std::string& text, const std::string& field_name) { - const std::string clean = trim_copy(text); - char* end = nullptr; - const double value = std::strtod(clean.c_str(), &end); - if ((end == clean.c_str()) || (*end != '\0') || !std::isfinite(value)) { - fail("Invalid floating point value for " + field_name + ": " + text); - } - return value; -} - -uint32_t parse_ipv4(const std::string& text) { - std::array parts{}; - std::stringstream ss(text); - std::string token; - for (std::size_t i = 0; i < parts.size(); ++i) { - if (!std::getline(ss, token, '.')) { - fail("Invalid IPv4 address: " + text); - } - parts[i] = parse_u32(token, "ip"); - if (parts[i] > 255) { - fail("IPv4 byte out of range: " + token); - } - } - if (std::getline(ss, token, '.')) { - fail("Invalid IPv4 address: " + text); - } - return (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]; -} - -uint32_t parse_range(const std::string& text) { - const double value = parse_double(text, "range"); - if (std::fabs(value - 10.0) < 1e-9) { - return X502_ADC_RANGE_10; - } - if (std::fabs(value - 5.0) < 1e-9) { - return X502_ADC_RANGE_5; - } - if (std::fabs(value - 2.0) < 1e-9) { - return X502_ADC_RANGE_2; - } - if (std::fabs(value - 1.0) < 1e-9) { - return X502_ADC_RANGE_1; - } - if (std::fabs(value - 0.5) < 1e-9) { - return X502_ADC_RANGE_05; - } - if (std::fabs(value - 0.2) < 1e-9) { - return X502_ADC_RANGE_02; - } - fail("Unsupported E-502 range: " + text); -} - -double range_to_volts(uint32_t range) { - switch (range) { - case X502_ADC_RANGE_10: - return 10.0; - case X502_ADC_RANGE_5: - return 5.0; - case X502_ADC_RANGE_2: - return 2.0; - case X502_ADC_RANGE_1: - return 1.0; - case X502_ADC_RANGE_05: - return 0.5; - case X502_ADC_RANGE_02: - return 0.2; - default: - fail("Unknown ADC range enum"); - } -} - -uint32_t parse_mode(const std::string& text) { - const std::string value = trim_copy(text); - if ((value == "comm") || (value == "gnd") || (value == "single_ended")) { - return X502_LCH_MODE_COMM; - } - if ((value == "diff") || (value == "differential")) { - return X502_LCH_MODE_DIFF; - } - fail("Unsupported input mode: " + text); -} - -CaptureProfile parse_profile(const std::string& text) { - const std::string value = trim_copy(text); - if (value == "phase") { - return CaptureProfile::Phase; - } - if (value == "amplitude") { - return CaptureProfile::Amplitude; - } - fail("Unsupported profile: " + text + ". Use phase or amplitude"); -} - -std::string profile_to_string(CaptureProfile profile) { - switch (profile) { - case CaptureProfile::Manual: - return "manual"; - case CaptureProfile::Phase: - return "phase"; - case CaptureProfile::Amplitude: - return "amplitude"; - default: - return "unknown"; - } -} - -struct ProfileSettings { - uint32_t channel_count = 2; - uint32_t mode = X502_LCH_MODE_DIFF; - uint32_t ch1 = 2; - uint32_t ch2 = 3; -}; - -ProfileSettings profile_settings(CaptureProfile profile) { - switch (profile) { - case CaptureProfile::Phase: - return {2U, X502_LCH_MODE_DIFF, 2U, 3U}; - case CaptureProfile::Amplitude: - return {1U, X502_LCH_MODE_COMM, 0U, 0U}; - case CaptureProfile::Manual: - default: - fail("Internal error: no settings for manual profile"); - } -} - -void apply_profile(Config& cfg) { - if (cfg.profile == CaptureProfile::Manual) { - return; - } - - const ProfileSettings settings = profile_settings(cfg.profile); - const std::string profile_name = profile_to_string(cfg.profile); - const std::string profile_arg = "profile:" + profile_name; - - if (cfg.mode_specified && (cfg.mode != settings.mode)) { - fail(profile_arg + " conflicts with explicit mode"); - } - if (cfg.channel_count_specified && (cfg.channel_count != settings.channel_count)) { - fail(profile_arg + " conflicts with explicit channels"); - } - if (cfg.ch1_specified && (cfg.ch1 != settings.ch1)) { - fail(profile_arg + " conflicts with explicit ch1"); - } - if (settings.channel_count <= 1U) { - if (cfg.ch2_specified) { - fail(profile_arg + " does not allow ch2"); - } - } else if (cfg.ch2_specified && (cfg.ch2 != settings.ch2)) { - fail(profile_arg + " conflicts with explicit ch2"); - } - - cfg.mode = settings.mode; - cfg.channel_count = settings.channel_count; - cfg.ch1 = settings.ch1; - cfg.ch2 = settings.ch2; -} - -uint32_t parse_internal_ref_freq(const std::string& text) { - const std::string value = trim_copy(text); - if ((value == "2000000") || (value == "2000khz") || (value == "2mhz")) { - return X502_REF_FREQ_2000KHZ; - } - if ((value == "1500000") || (value == "1500khz") || (value == "1.5mhz")) { - return X502_REF_FREQ_1500KHZ; - } - fail("Unsupported internal_ref_hz value: " + text + ". Use 1500000 or 2000000"); -} - -uint32_t parse_channel_count(const std::string& text) { - const uint32_t value = parse_u32(text, "channels"); - if ((value == 1U) || (value == 2U)) { - return value; - } - fail("channels must be 1 or 2"); -} - -std::string ref_freq_to_string(uint32_t freq) { - switch (freq) { - case X502_REF_FREQ_2000KHZ: - return "2000000"; - case X502_REF_FREQ_1500KHZ: - return "1500000"; - default: - return "unknown"; - } -} - -bool use_internal_max_clock(const Config& cfg) { - return (cfg.sync_mode == X502_SYNC_INTERNAL) && (cfg.max_internal_clock || !cfg.sample_clock_specified); -} - -uint32_t parse_sync_mode(const std::string& text) { - const std::string value = trim_copy(text); - if ((value == "conv_in") || (value == "start_in") || (value == "external_master")) { - return X502_SYNC_EXTERNAL_MASTER; - } - if (value == "di_syn1_rise") { - return X502_SYNC_DI_SYN1_RISE; - } - if (value == "di_syn1_fall") { - return X502_SYNC_DI_SYN1_FALL; - } - if (value == "di_syn2_rise") { - return X502_SYNC_DI_SYN2_RISE; - } - if (value == "di_syn2_fall") { - return X502_SYNC_DI_SYN2_FALL; - } - if ((value == "internal") || (value == "immediate")) { - return X502_SYNC_INTERNAL; - } - fail("Unsupported sync mode: " + text); -} - -std::string sync_mode_to_string(uint32_t mode, bool for_start) { - switch (mode) { - case X502_SYNC_INTERNAL: - return for_start ? "immediate" : "internal"; - case X502_SYNC_EXTERNAL_MASTER: - return for_start ? "start_in" : "conv_in"; - case X502_SYNC_DI_SYN1_RISE: - return "di_syn1_rise"; - case X502_SYNC_DI_SYN1_FALL: - return "di_syn1_fall"; - case X502_SYNC_DI_SYN2_RISE: - return "di_syn2_rise"; - case X502_SYNC_DI_SYN2_FALL: - return "di_syn2_fall"; - default: - return "unknown"; - } -} - -StopMode parse_stop_mode(const std::string& text) { - const std::string value = trim_copy(text); - if ((value == "frames") || (value == "duration") || (value == "none")) { - return StopMode::TargetFrames; - } - if (value == "di_syn2_rise") { - return StopMode::DiSyn2Rise; - } - if (value == "di_syn2_fall") { - return StopMode::DiSyn2Fall; - } - fail("Unsupported stop mode: " + text); -} - -std::string stop_mode_to_string(StopMode mode) { - switch (mode) { - case StopMode::TargetFrames: - return "target_frames"; - case StopMode::DiSyn2Rise: - return "di_syn2_rise"; - case StopMode::DiSyn2Fall: - return "di_syn2_fall"; - default: - return "unknown"; - } -} - -Di1Mode parse_di1_mode(const std::string& text) { - const std::string value = trim_copy(text); - if ((value == "zero") || (value == "zero_on_change") || (value == "mark")) { - return Di1Mode::ZeroOnChange; - } - if ((value == "trace") || (value == "plot") || (value == "stream")) { - return Di1Mode::Trace; - } - if ((value == "ignore") || (value == "off") || (value == "none")) { - return Di1Mode::Ignore; - } - fail("Unsupported di1 mode: " + text); -} - -std::string di1_mode_to_string(Di1Mode mode) { - switch (mode) { - case Di1Mode::ZeroOnChange: - return "zero"; - case Di1Mode::Trace: - return "trace"; - case Di1Mode::Ignore: - return "ignore"; - default: - return "unknown"; - } -} - -bool sync_uses_di_syn1(uint32_t mode) { - return (mode == X502_SYNC_DI_SYN1_RISE) || (mode == X502_SYNC_DI_SYN1_FALL); -} - -bool sync_uses_di_syn2(uint32_t mode) { - return (mode == X502_SYNC_DI_SYN2_RISE) || (mode == X502_SYNC_DI_SYN2_FALL); -} - -std::string phy_channel_name(uint32_t mode, uint32_t phy_ch) { - if (mode == X502_LCH_MODE_DIFF) { - return "X" + std::to_string(phy_ch + 1) + "-Y" + std::to_string(phy_ch + 1); - } - if (phy_ch < 16) { - return "X" + std::to_string(phy_ch + 1); - } - if (phy_ch < 32) { - return "Y" + std::to_string((phy_ch - 16) + 1); - } - return "CH" + std::to_string(phy_ch); -} - -void print_help(const char* exe_name) { - std::cout - << "Usage:\n" - << " " << exe_name << " [serial:SN] [ip:192.168.0.10] [profile:phase|amplitude]\n" - << " [channels:2] [ch1:2] [ch2:3]\n" - << " [mode:diff|comm] [range:0.2] [clock:di_syn1_rise]\n" - << " [start:di_syn2_rise] [stop:di_syn2_fall] [sample_clock_hz:125000|max]\n" - << " [internal_ref_hz:2000000]\n" - << " [di1:zero|trace|ignore]\n" - << " [duration_ms:100] [packet_limit:0] [csv:capture.csv] [svg:capture.svg]\n" - << " [live_html:live_plot.html] [live_json:live_plot.json] [tty:/tmp/ttyADC_data] [di1_group_avg]\n" - << " [config:run_config.json] [switch_gap_ms:10] [switch_settle_ms:1]\n" - << " [do1_toggle_per_frame] [do1_noise_subtract] [do1_raw_tty_marked] [do1_pair_subtract_avg] [noise_avg_steps:N]\n" - << " [do8_freq_ref] [do8_cycle_period:10] [do8_threshold:X]\n" - << " [recv_block:32768] [stats_period_ms:1000] [live_update_period_ms:1000] [svg_history_packets:50] [start_wait_ms:10000]\n" - << " [buffer_words:8388608] [step_words:32768]\n" - << " [pullup_syn1] [pullup_syn2] [pulldown_conv_in] [pulldown_start_in]\n" - << "\n" - << "Defaults for E-502:\n" - << " channels:2 -> capture CH1 and CH2\n" - << " ch1:2, ch2:3 -> X3-Y3 and X4-Y4\n" - << " mode:diff -> differential measurement\n" - << " range:0.2 -> +/-0.2 V range\n" - << " supported ranges -> 10, 5, 2, 1, 0.5, 0.2 V\n" - << " channels:1 -> capture only CH1, CH2 is ignored\n" - << " clock:di_syn1_rise-> external sample clock on DI_SYN1 rising edge\n" - << " clock:internal -> module generates its own clock\n" - << " start:di_syn2_rise-> packet starts on DI_SYN2 rising edge\n" - << " stop:di_syn2_fall -> packet stops on DI_SYN2 falling edge\n" - << " sample_clock_hz:125000 -> requested ADC sample rate; for external clock it is the expected rate\n" - << " sample_clock_hz:max -> with clock:internal, use the maximum ADC speed\n" - << " internal_ref_hz:2000000 -> internal base clock for clock:internal (1500000 or 2000000)\n" - << " di1:zero -> write ADC sample as 0 on each DI1 level change\n" - << " di1:trace -> keep ADC unchanged and store DI1 as a separate synchronous trace\n" - << " di1:ignore -> ignore DI1 for both zeroing and plotting\n" - << " stats_period_ms:1000 -> print online transfer/capture statistics every 1000 ms (0 disables)\n" - << " recv_block:32768 -> request up to 32768 raw 32-bit words per X502_Recv() call\n" - << " live_update_period_ms:1000 -> refresh live HTML/JSON no more than once per second" - << " (ignored in tty fast stream-only modes)\n" - << " svg_history_packets:50 -> keep last 50 packets in RAM for final SVG (0 keeps all, risky)" - << " (ignored in tty fast stream-only modes)\n" - << " duration_ms:100 -> max packet length if stop edge does not arrive\n" - << " packet_limit:0 -> 0 means continuous until Ctrl+C, N means stop after N packets\n" - << " buffer_words:8388608 -> input stream buffer size in 32-bit words\n" - << " step_words:32768 -> input stream transfer step in 32-bit words\n" - << " live_html/live_json -> live graph files updated as packets arrive outside tty fast stream-only modes\n" - << " tty:/tmp/ttyADC_data -> write a continuous legacy 4-word CH1/CH2 stream; with channels:1, CH2 is 0\n" - << " config:run_config.json -> switch-aware mode: read the input/output switch and combo\n" - << " configuration from this run_config.json and drive the RF switches\n" - << " in lock-step with sweeps; each sweep is tagged with its combo via a\n" - << " 0x00C0 frame (input_pos, output_pos, dirty). Omit for standalone mode.\n" - << " switch_gap_ms:10 -> guaranteed minimum inter-sweep idle window; lower bound used to decide\n" - << " whether an in-gap switch lands before the next sweep starts\n" - << " switch_settle_ms:1 -> RF settle time required after a switch position change\n" - << " di1_group_avg -> with tty + di1:trace, emit one averaged 4-word step per constant DI1 run\n" - << " do1_toggle_per_frame -> hardware cyclic DO1 pattern in module memory:\n" - << " DO1 outputs 00110011... continuously (toggle every 2 ADC ticks)\n" - << " without per-tick DOUT updates from PC\n" - << " do1_noise_subtract -> with tty + do1_toggle_per_frame, use DI1 state from synchronous DIN\n" - << " (expected DO1->DI1 loopback) to take previous DI1=HIGH step as baseline and subtract it\n" - << " from useful steps before tty output\n" - << " (only corrected DO1=LOW steps are sent to tty)\n" - << " do1_raw_tty_marked -> with tty + do1_toggle_per_frame + phase profile, emit raw per-step averages\n" - << " from both phase channels, tagged by DI1 level from synchronous DIN:\n" - << " word0=0x00A3 for DO1/DI1 LOW, word0=0x00A4 for DO1/DI1 HIGH\n" - << " no baseline subtraction is applied in this mode\n" - << " do1_pair_subtract_avg -> with tty + do1_toggle_per_frame + phase profile, pair adjacent CH1/CH2 ticks,\n" - << " drop boundary pairs with mismatched DI1/DI2, compute low-high by DI2 per pair,\n" - << " average within each constant DI1 run, and emit legacy 4-word tty frames (word0=0x000A)\n" - << " noise_avg_steps:N -> kept for compatibility in do1_noise_subtract mode (current baseline is previous HIGH step)\n" - << " (still required when do1_noise_subtract is enabled)\n" - << " do8_freq_ref -> enable DO8/DI8 frequency reference overlay (DO8 and DI8 must be physically looped back);\n" - << " DO8 toggles HIGH for 2 ticks within each do8_cycle_period cycle;\n" - << " DI8=HIGH samples are accumulated separately and emitted as 0x00A8 frames\n" - << " do8_cycle_period:10 -> DO8 cycle period in ADC ticks (default 10, minimum 4)\n" - << " do8_threshold:X -> only emit DI8 reference frame when channel amplitude exceeds X (raw ADC code units)\n" - << " tty fast stream-only modes -> di1_group_avg, do1_noise_subtract, do1_raw_tty_marked, do1_pair_subtract_avg;\n" - << " skip CSV/SVG/live outputs\n" - << " If sample_clock_hz is omitted together with clock:internal, the maximum ADC speed is used\n" - << "\n" - << "Profiles:\n" - << " profile:phase -> mode:diff channels:2 ch1:2 ch2:3 (X3-Y3 and X4-Y4)\n" - << " profile:amplitude -> mode:comm channels:1 ch1:0 (X1, raw AD8317 voltage)\n" - << " profile:* is strict -> conflicting mode/channels/ch1/ch2 arguments are rejected\n" - << "\n" - << "Differential physical channel mapping:\n" - << " 0..15 -> X1-Y1 .. X16-Y16\n" - << "\n" - << "Common-ground physical channel mapping:\n" - << " 0..15 -> X1..X16\n" - << " 16..31 -> Y1..Y16\n" - << "\n" - << "Useful sync lines on E-502:\n" - << " clock: conv_in | di_syn1_rise | di_syn1_fall | di_syn2_rise | di_syn2_fall\n" - << " start: immediate | start_in | di_syn1_rise | di_syn1_fall | di_syn2_rise | di_syn2_fall\n" - << " stop: frames | di_syn2_rise | di_syn2_fall\n" - << "\n" - << "This build enables synchronous DIN together with ADC. DI_SYN2 stop edges are detected\n" - << "inside the same input stream, packets are split continuously by DI_SYN2 edges, and DI1\n" - << "can either zero ADC samples on change, be exported as a separate synchronous trace, or be ignored.\n" - << "Outside tty fast stream-only modes, open live_plot.html in a browser to see the live graph update over time.\n" - << "The live HTML supports X/Y zoom buttons, mouse-wheel zoom, and reset.\n" - << "For tty output, use di1_group_avg together with di1:trace to emit one averaged 4-word step\n" - << "per constant DI1 run while keeping the current ring-buffered binary frame format.\n" - << "For DO1 subtraction with loopback, use do1_toggle_per_frame + do1_noise_subtract + noise_avg_steps:N;\n" - << "DI1 from synchronous DIN is used as the actual DO1 state marker, and each LOW step subtracts previous HIGH step.\n" - << "For raw loopback diagnostics, use do1_toggle_per_frame + do1_raw_tty_marked in phase profile;\n" - << "steps are tagged in word0 as 0x00A3 (LOW) / 0x00A4 (HIGH) with raw per-step channel averages.\n" - << "For DI1-stable low/high DI2 subtraction with legacy tty output, use do1_toggle_per_frame + do1_pair_subtract_avg\n" - << "in phase profile; boundary CH1/CH2 pairs with mismatched DI1/DI2 are dropped before averaging.\n" - << "Amplitude mode keeps the raw AD8317 voltage as captured on X1; no inversion is applied.\n" - << "\n" - << "Phase profile example:\n" - << " " << exe_name - << " profile:phase clock:di_syn1_rise start:di_syn2_rise stop:di_syn2_fall sample_clock_hz:125000" - << " duration_ms:100 packet_limit:10 csv:chirp.csv svg:chirp.svg\n" - << "\n" - << "Amplitude profile example:\n" - << " " << exe_name - << " profile:amplitude clock:internal internal_ref_hz:2000000 start:di_syn2_rise stop:di_syn2_fall" - << " sample_clock_hz:max duration_ms:100 packet_limit:0 csv:amplitude.csv svg:amplitude.svg\n" - << "\n" - << "Amplitude TTY DI1-group average example:\n" - << " " << exe_name - << " profile:amplitude clock:internal internal_ref_hz:2000000 start:di_syn2_rise stop:di_syn2_fall" - << " sample_clock_hz:max range:0.2 di1:trace di1_group_avg duration_ms:100 packet_limit:0" - << " tty:/tmp/ttyADC_data\n" - << "\n" - << "Amplitude TTY DO1 noise subtract example:\n" - << " " << exe_name - << " profile:amplitude clock:internal internal_ref_hz:2000000 start:di_syn2_rise stop:di_syn2_fall" - << " sample_clock_hz:max range:0.2 do1_toggle_per_frame do1_noise_subtract noise_avg_steps:16" - << " duration_ms:100 packet_limit:0 tty:/tmp/ttyADC_data\n" - << "\n" - << "Phase TTY raw DO1-level tagged example:\n" - << " " << exe_name - << " profile:phase clock:internal internal_ref_hz:2000000 start:di_syn2_rise stop:di_syn2_fall" - << " sample_clock_hz:max do1_toggle_per_frame do1_raw_tty_marked" - << " duration_ms:100 packet_limit:0 tty:/tmp/ttyADC_data\n"; -} - -Config parse_args(int argc, char** argv) { - Config cfg; - - for (int i = 1; i < argc; ++i) { - const std::string arg = argv[i]; - if ((arg == "help") || (arg == "--help") || (arg == "-h")) { - print_help(argv[0]); - std::exit(0); - } - if (arg == "pullup_syn1") { - cfg.pullup_syn1 = true; - continue; - } - if (arg == "pullup_syn2") { - cfg.pullup_syn2 = true; - continue; - } - if (arg == "pulldown_conv_in") { - cfg.pulldown_conv_in = true; - continue; - } - if (arg == "pulldown_start_in") { - cfg.pulldown_start_in = true; - continue; - } - if (starts_with(arg, "serial:")) { - cfg.serial = arg.substr(7); - continue; - } - if (starts_with(arg, "ip:")) { - cfg.ip_addr = parse_ipv4(arg.substr(3)); - continue; - } - if (starts_with(arg, "profile:")) { - cfg.profile = parse_profile(arg.substr(8)); - continue; - } - if (starts_with(arg, "mode:")) { - cfg.mode = parse_mode(arg.substr(5)); - cfg.mode_specified = true; - continue; - } - if (starts_with(arg, "channels:")) { - cfg.channel_count = parse_channel_count(arg.substr(9)); - cfg.channel_count_specified = true; - continue; - } - if (starts_with(arg, "range:")) { - cfg.range = parse_range(arg.substr(6)); - continue; - } - if (starts_with(arg, "ch1:")) { - cfg.ch1 = parse_u32(arg.substr(4), "ch1"); - cfg.ch1_specified = true; - continue; - } - if (starts_with(arg, "ch2:")) { - cfg.ch2 = parse_u32(arg.substr(4), "ch2"); - cfg.ch2_specified = true; - continue; - } - if (starts_with(arg, "clock:")) { - cfg.sync_mode = parse_sync_mode(arg.substr(6)); - continue; - } - if (starts_with(arg, "start:")) { - cfg.sync_start_mode = parse_sync_mode(arg.substr(6)); - continue; - } - if (starts_with(arg, "stop:")) { - cfg.stop_mode = parse_stop_mode(arg.substr(5)); - continue; - } - if (starts_with(arg, "di1:")) { - cfg.di1_mode = parse_di1_mode(arg.substr(4)); - continue; - } - if ((arg == "di1_group_avg") || (arg == "di1_avg")) { - cfg.di1_group_average = true; - continue; - } - if (arg == "do1_noise_subtract") { - cfg.do1_noise_subtract = true; - continue; - } - if (arg == "do1_raw_tty_marked") { - cfg.do1_raw_tty_marked = true; - continue; - } - if (arg == "do1_pair_subtract_avg") { - cfg.do1_pair_subtract_avg = true; - continue; - } - if (arg == "do1_toggle_per_frame") { - cfg.do1_toggle_per_frame = true; - continue; - } - if (starts_with(arg, "noise_avg_steps:")) { - cfg.noise_avg_steps = parse_u32(arg.substr(16), "noise_avg_steps"); - continue; - } - if (arg == "do8_freq_ref") { - cfg.do8_freq_ref = true; - continue; - } - if (starts_with(arg, "do8_cycle_period:")) { - cfg.do8_cycle_period_ticks = parse_u32(arg.substr(17), "do8_cycle_period"); - continue; - } - if (starts_with(arg, "do8_threshold:")) { - cfg.do8_amplitude_threshold = parse_double(arg.substr(14), "do8_threshold"); - continue; - } - if (starts_with(arg, "sample_clock_hz:")) { - const std::string value = trim_copy(arg.substr(16)); - cfg.sample_clock_specified = true; - if ((value == "max") || (value == "maximum")) { - cfg.max_internal_clock = true; - } else { - cfg.sample_clock_hz = parse_double(value, "sample_clock_hz"); - cfg.max_internal_clock = false; - } - continue; - } - if (starts_with(arg, "internal_ref_hz:")) { - cfg.internal_ref_freq = parse_internal_ref_freq(arg.substr(16)); - continue; - } - if (starts_with(arg, "duration_ms:")) { - cfg.duration_ms = parse_double(arg.substr(12), "duration_ms"); - continue; - } - if (starts_with(arg, "packet_limit:")) { - cfg.packet_limit = parse_u32(arg.substr(13), "packet_limit"); - continue; - } - if (starts_with(arg, "recv_block:")) { - cfg.recv_block_words = parse_u32(arg.substr(11), "recv_block"); - cfg.recv_block_specified = true; - continue; - } - if (starts_with(arg, "recv_timeout_ms:")) { - cfg.recv_timeout_ms = parse_u32(arg.substr(16), "recv_timeout_ms"); - continue; - } - if (starts_with(arg, "stats_period_ms:")) { - cfg.stats_period_ms = parse_u32(arg.substr(16), "stats_period_ms"); - continue; - } - if (starts_with(arg, "live_update_period_ms:")) { - cfg.live_update_period_ms = parse_u32(arg.substr(22), "live_update_period_ms"); - cfg.live_update_specified = true; - continue; - } - if (starts_with(arg, "svg_history_packets:")) { - cfg.svg_history_packets = parse_u32(arg.substr(20), "svg_history_packets"); - continue; - } - if (starts_with(arg, "start_wait_ms:")) { - cfg.start_wait_ms = parse_u32(arg.substr(14), "start_wait_ms"); - continue; - } - if (starts_with(arg, "buffer_words:")) { - cfg.input_buffer_words = parse_u32(arg.substr(13), "buffer_words"); - cfg.input_buffer_specified = true; - continue; - } - if (starts_with(arg, "step_words:")) { - cfg.input_step_words = parse_u32(arg.substr(11), "step_words"); - cfg.input_step_specified = true; - continue; - } - if (starts_with(arg, "csv:")) { - cfg.csv_path = arg.substr(4); - continue; - } - if (starts_with(arg, "svg:")) { - cfg.svg_path = arg.substr(4); - continue; - } - if (starts_with(arg, "live_html:")) { - cfg.live_html_path = arg.substr(10); - continue; - } - if (starts_with(arg, "live_json:")) { - cfg.live_json_path = arg.substr(10); - continue; - } - if (starts_with(arg, "tty:")) { - cfg.tty_path = arg.substr(4); - continue; - } - if (starts_with(arg, "config:")) { - cfg.run_config_path = arg.substr(7); - continue; - } - if (starts_with(arg, "switch_gap_ms:")) { - cfg.switch_gap_ms = parse_double(arg.substr(14), "switch_gap_ms"); - continue; - } - if (starts_with(arg, "switch_settle_ms:")) { - cfg.switch_settle_ms = parse_double(arg.substr(17), "switch_settle_ms"); - continue; - } - fail("Unknown argument: " + arg); - } - - apply_profile(cfg); - - if (cfg.duration_ms <= 0.0) { - fail("duration_ms must be > 0"); - } - if (!cfg.max_internal_clock && (cfg.sample_clock_hz <= 0.0)) { - fail("sample_clock_hz must be > 0"); - } - if (cfg.max_internal_clock && (cfg.sync_mode != X502_SYNC_INTERNAL)) { - fail("sample_clock_hz:max is only valid together with clock:internal"); - } - const bool high_rate_capture = - use_internal_max_clock(cfg) || - ((cfg.sample_clock_hz >= 1000000.0) && - ((cfg.sync_mode == X502_SYNC_INTERNAL) || cfg.sample_clock_specified)); - if (high_rate_capture) { - if (!cfg.recv_block_specified) { - cfg.recv_block_words = std::max(cfg.recv_block_words, 32768U); - } - if (!cfg.input_step_specified) { - cfg.input_step_words = std::max(cfg.input_step_words, 32768U); - } - if (!cfg.input_buffer_specified) { - cfg.input_buffer_words = std::max(cfg.input_buffer_words, 8U * 1024U * 1024U); - } - if (!cfg.live_update_specified) { - cfg.live_update_period_ms = std::max(cfg.live_update_period_ms, 1000U); - } - } - if (cfg.tty_path && (cfg.di1_group_average || cfg.do1_noise_subtract || cfg.do1_raw_tty_marked || cfg.do1_pair_subtract_avg)) { - if (!cfg.recv_block_specified) { - cfg.recv_block_words = std::max(cfg.recv_block_words, 65536U); - } - if (!cfg.input_step_specified) { - cfg.input_step_words = std::max(cfg.input_step_words, 65536U); - } - if (!cfg.input_buffer_specified) { - cfg.input_buffer_words = std::max(cfg.input_buffer_words, 16U * 1024U * 1024U); - } - } - if (cfg.recv_block_words == 0) { - fail("recv_block must be > 0"); - } - if (cfg.input_step_words == 0) { - cfg.input_step_words = cfg.recv_block_words; - } - if (cfg.input_buffer_words < cfg.recv_block_words) { - cfg.input_buffer_words = cfg.recv_block_words; - } - if (cfg.di1_group_average && (cfg.di1_mode != Di1Mode::Trace)) { - fail("di1_group_avg requires di1:trace"); - } - if (cfg.di1_group_average && cfg.do1_raw_tty_marked) { - fail("do1_raw_tty_marked is incompatible with di1_group_avg"); - } - if (cfg.di1_group_average && cfg.do1_pair_subtract_avg) { - fail("do1_pair_subtract_avg is incompatible with di1_group_avg"); - } - if (cfg.do1_noise_subtract) { - if (!cfg.tty_path.has_value()) { - fail("do1_noise_subtract requires tty:"); - } - if (!cfg.do1_toggle_per_frame) { - fail("do1_noise_subtract requires do1_toggle_per_frame"); - } - if (cfg.di1_group_average) { - fail("do1_noise_subtract is incompatible with di1_group_avg"); - } - if (cfg.do1_raw_tty_marked) { - fail("do1_noise_subtract is incompatible with do1_raw_tty_marked"); - } - if (cfg.do1_pair_subtract_avg) { - fail("do1_noise_subtract is incompatible with do1_pair_subtract_avg"); - } - if (!cfg.noise_avg_steps.has_value()) { - fail("do1_noise_subtract requires noise_avg_steps:N"); - } - if (*cfg.noise_avg_steps == 0U) { - fail("noise_avg_steps must be > 0"); - } - } else if (cfg.noise_avg_steps.has_value()) { - fail("noise_avg_steps:N can only be used together with do1_noise_subtract"); - } - if (cfg.do1_raw_tty_marked) { - if (!cfg.tty_path.has_value()) { - fail("do1_raw_tty_marked requires tty:"); - } - if (!cfg.do1_toggle_per_frame) { - fail("do1_raw_tty_marked requires do1_toggle_per_frame"); - } - if (cfg.do1_pair_subtract_avg) { - fail("do1_raw_tty_marked is incompatible with do1_pair_subtract_avg"); - } - if (cfg.mode != X502_LCH_MODE_DIFF) { - fail("do1_raw_tty_marked requires phase configuration: mode:diff channels:2 ch1:2 ch2:3"); - } - if ((cfg.channel_count != 2U) || (cfg.ch1 != 2U) || (cfg.ch2 != 3U)) { - fail("do1_raw_tty_marked requires phase configuration: mode:diff channels:2 ch1:2 ch2:3"); - } - } - if (cfg.do1_pair_subtract_avg) { - if (!cfg.tty_path.has_value()) { - fail("do1_pair_subtract_avg requires tty:"); - } - if (!cfg.do1_toggle_per_frame) { - fail("do1_pair_subtract_avg requires do1_toggle_per_frame"); - } - if (cfg.do1_noise_subtract) { - fail("do1_pair_subtract_avg is incompatible with do1_noise_subtract"); - } - if (cfg.do1_raw_tty_marked) { - fail("do1_pair_subtract_avg is incompatible with do1_raw_tty_marked"); - } - if (cfg.profile != CaptureProfile::Phase) { - fail("do1_pair_subtract_avg requires profile:phase"); - } - if ((cfg.mode != X502_LCH_MODE_DIFF) || (cfg.channel_count != 2U) || (cfg.ch1 != 2U) || (cfg.ch2 != 3U)) { - fail("do1_pair_subtract_avg requires phase configuration: mode:diff channels:2 ch1:2 ch2:3"); - } - } - if (cfg.do8_freq_ref) { - if (!cfg.tty_path.has_value()) { - fail("do8_freq_ref requires tty:"); - } - if (!cfg.do1_toggle_per_frame) { - fail("do8_freq_ref requires do1_toggle_per_frame (cyclic output must be active)"); - } - if (cfg.do8_cycle_period_ticks < 4U) { - fail("do8_cycle_period must be >= 4 (at least 2 LOW ticks + 2 HIGH ticks)"); - } - if (cfg.do8_amplitude_threshold.has_value() && *cfg.do8_amplitude_threshold < 0.0) { - fail("do8_threshold must be >= 0"); - } - if (std::gcd(4U, cfg.do8_cycle_period_ticks) > 2U) { - std::cerr << "Warning: gcd(DO1 period=4" - << ", DO8 period=" << cfg.do8_cycle_period_ticks - << ") > 2; DI8=HIGH may systematically align with the same DO1 phase\n"; - } - } - if (sync_uses_di_syn1(cfg.sync_mode) && sync_uses_di_syn1(cfg.sync_start_mode)) { - fail("clock and start cannot both use DI_SYN1; use start_in or immediate start"); - } - if (sync_uses_di_syn2(cfg.sync_mode) && sync_uses_di_syn2(cfg.sync_start_mode)) { - fail("clock and start cannot both use DI_SYN2; use start_in or immediate start"); - } - if ((cfg.stop_mode != StopMode::TargetFrames) && sync_uses_di_syn2(cfg.sync_mode)) { - fail("DI_SYN2 cannot be used simultaneously for clock and stop"); - } - if ((cfg.stop_mode == StopMode::DiSyn2Rise) && (cfg.sync_start_mode == X502_SYNC_DI_SYN2_RISE)) { - fail("start and stop cannot both use the same DI_SYN2 rising edge"); - } - if ((cfg.stop_mode == StopMode::DiSyn2Fall) && (cfg.sync_start_mode == X502_SYNC_DI_SYN2_FALL)) { - fail("start and stop cannot both use the same DI_SYN2 falling edge"); - } - - if (cfg.mode == X502_LCH_MODE_DIFF) { - if (cfg.ch1 >= X502_ADC_DIFF_CH_CNT) { - fail("For differential mode E-502 ch1 must be in range 0..15"); - } - if ((cfg.channel_count >= 2U) && (cfg.ch2 >= X502_ADC_DIFF_CH_CNT)) { - fail("For differential mode E-502 ch2 must be in range 0..15"); - } - } else { - if (cfg.ch1 >= X502_ADC_COMM_CH_CNT) { - fail("For common-ground mode E-502 ch1 must be in range 0..31"); - } - if ((cfg.channel_count >= 2U) && (cfg.ch2 >= X502_ADC_COMM_CH_CNT)) { - fail("For common-ground mode E-502 ch2 must be in range 0..31"); - } - } - - return cfg; -} - -#ifdef _WIN32 -using ModuleHandle = HMODULE; -#else -using ModuleHandle = void*; -#endif - -std::string dynamic_loader_error() { -#ifdef _WIN32 - return "unknown error"; -#else - const char* error = dlerror(); - return ((error != nullptr) && (*error != '\0')) ? std::string(error) : std::string("unknown error"); -#endif -} - -ModuleHandle open_library(const char* path) { -#ifdef _WIN32 - return LoadLibraryA(path); -#else - dlerror(); - return dlopen(path, RTLD_LAZY | RTLD_LOCAL); -#endif -} - -void close_library(ModuleHandle module) { -#ifdef _WIN32 - if (module != nullptr) { - FreeLibrary(module); - } -#else - if (module != nullptr) { - dlclose(module); - } -#endif -} - -ModuleHandle load_library_or_fail(const std::vector& candidates, - const std::string& description) { - std::string last_error = "no candidates provided"; - for (const auto& candidate : candidates) { - ModuleHandle module = open_library(candidate.c_str()); - if (module != nullptr) { - return module; - } - last_error = dynamic_loader_error(); - } - - std::ostringstream out; - out << "Cannot load " << description << ". Tried:"; - for (const auto& candidate : candidates) { - out << " " << candidate; - } - out << ". Last error: " << last_error; - fail(out.str()); -} - -template -Fn load_symbol(ModuleHandle module, const char* name) { -#ifdef _WIN32 - const auto addr = GetProcAddress(module, name); - if (addr == nullptr) { - fail(std::string("GetProcAddress failed for symbol: ") + name); - } - return reinterpret_cast(addr); -#else - dlerror(); - void* addr = dlsym(module, name); - const char* error = dlerror(); - if ((addr == nullptr) || (error != nullptr)) { - std::ostringstream out; - out << "dlsym failed for symbol " << name << ": " - << ((error != nullptr) ? error : "unknown error"); - fail(out.str()); - } - return reinterpret_cast(addr); -#endif -} - -template -Fn try_load_symbol(ModuleHandle module, const char* name) { -#ifdef _WIN32 - const auto addr = GetProcAddress(module, name); - return reinterpret_cast(addr); -#else - dlerror(); - void* addr = dlsym(module, name); - (void) dlerror(); - return reinterpret_cast(addr); -#endif -} - -struct Api { - ModuleHandle x502_module = nullptr; - ModuleHandle e502_module = nullptr; - - decltype(&X502_Create) Create = nullptr; - decltype(&X502_Free) Free = nullptr; - decltype(&X502_Close) Close = nullptr; - decltype(&X502_GetErrorString) GetErrorString = nullptr; - decltype(&X502_GetDevInfo) GetDevInfo = nullptr; - decltype(&X502_GetDevInfo2) GetDevInfo2 = nullptr; - decltype(&X502_SetMode) SetMode = nullptr; - decltype(&X502_StreamsStop) StreamsStop = nullptr; - decltype(&X502_StreamsDisable) StreamsDisable = nullptr; - decltype(&X502_SetSyncMode) SetSyncMode = nullptr; - decltype(&X502_SetSyncStartMode) SetSyncStartMode = nullptr; - decltype(&X502_SetLChannelCount) SetLChannelCount = nullptr; - decltype(&X502_SetLChannel) SetLChannel = nullptr; - decltype(&X502_SetAdcFreqDivider) SetAdcFreqDivider = nullptr; - decltype(&X502_SetAdcInterframeDelay) SetAdcInterframeDelay = nullptr; - decltype(&X502_SetDinFreqDivider) SetDinFreqDivider = nullptr; - decltype(&X502_SetAdcFreq) SetAdcFreq = nullptr; - decltype(&X502_GetAdcFreq) GetAdcFreq = nullptr; - decltype(&X502_SetDinFreq) SetDinFreq = nullptr; - decltype(&X502_SetOutFreq) SetOutFreq = nullptr; - decltype(&X502_AsyncOutDig) AsyncOutDig = nullptr; - decltype(&X502_AsyncInDig) AsyncInDig = nullptr; - decltype(&X502_SetRefFreq) SetRefFreq = nullptr; - decltype(&X502_SetStreamBufSize) SetStreamBufSize = nullptr; - decltype(&X502_SetStreamStep) SetStreamStep = nullptr; - decltype(&X502_SetDigInPullup) SetDigInPullup = nullptr; - decltype(&X502_SetExtRefFreqValue) SetExtRefFreqValue = nullptr; - decltype(&X502_Configure) Configure = nullptr; - decltype(&X502_StreamsEnable) StreamsEnable = nullptr; - decltype(&X502_StreamsStart) StreamsStart = nullptr; - decltype(&X502_GetRecvReadyCount) GetRecvReadyCount = nullptr; - decltype(&X502_Recv) Recv = nullptr; - decltype(&X502_OutCycleLoadStart) OutCycleLoadStart = nullptr; - decltype(&X502_OutCycleSetup) OutCycleSetup = nullptr; - decltype(&X502_OutCycleStop) OutCycleStop = nullptr; - decltype(&X502_Send) Send = nullptr; - decltype(&X502_PrepareData) PrepareData = nullptr; - decltype(&X502_ProcessData) ProcessData = nullptr; - - decltype(&E502_OpenUsb) OpenUsb = nullptr; - decltype(&E502_OpenByIpAddr) OpenByIpAddr = nullptr; - - Api() { - x502_module = load_library_or_fail( -#ifdef _WIN32 - {"x502api.dll"}, -#else - {"libx502api.so", "x502api.so", "./libx502api.so", "./x502api.so"}, -#endif - "x502 API library"); - e502_module = load_library_or_fail( -#ifdef _WIN32 - {"e502api.dll"}, -#else - {"libe502api.so", "e502api.so", "./libe502api.so", "./e502api.so"}, -#endif - "e502 API library"); - - Create = load_symbol(x502_module, "X502_Create"); - Free = load_symbol(x502_module, "X502_Free"); - Close = load_symbol(x502_module, "X502_Close"); - GetErrorString = load_symbol(x502_module, "X502_GetErrorString"); - GetDevInfo = try_load_symbol(x502_module, "X502_GetDevInfo"); - GetDevInfo2 = try_load_symbol(x502_module, "X502_GetDevInfo2"); - if ((GetDevInfo == nullptr) && (GetDevInfo2 == nullptr)) { - fail("Neither X502_GetDevInfo nor X502_GetDevInfo2 is available in x502 API library"); - } - SetMode = load_symbol(x502_module, "X502_SetMode"); - StreamsStop = load_symbol(x502_module, "X502_StreamsStop"); - StreamsDisable = load_symbol(x502_module, "X502_StreamsDisable"); - SetSyncMode = load_symbol(x502_module, "X502_SetSyncMode"); - SetSyncStartMode = load_symbol(x502_module, "X502_SetSyncStartMode"); - SetLChannelCount = load_symbol(x502_module, "X502_SetLChannelCount"); - SetLChannel = load_symbol(x502_module, "X502_SetLChannel"); - SetAdcFreqDivider = load_symbol(x502_module, "X502_SetAdcFreqDivider"); - SetAdcInterframeDelay = load_symbol(x502_module, "X502_SetAdcInterframeDelay"); - SetDinFreqDivider = load_symbol(x502_module, "X502_SetDinFreqDivider"); - SetAdcFreq = load_symbol(x502_module, "X502_SetAdcFreq"); - GetAdcFreq = load_symbol(x502_module, "X502_GetAdcFreq"); - SetDinFreq = load_symbol(x502_module, "X502_SetDinFreq"); - SetOutFreq = load_symbol(x502_module, "X502_SetOutFreq"); - AsyncOutDig = load_symbol(x502_module, "X502_AsyncOutDig"); - AsyncInDig = load_symbol(x502_module, "X502_AsyncInDig"); - SetRefFreq = load_symbol(x502_module, "X502_SetRefFreq"); - SetStreamBufSize = load_symbol(x502_module, "X502_SetStreamBufSize"); - SetStreamStep = load_symbol(x502_module, "X502_SetStreamStep"); - SetDigInPullup = load_symbol(x502_module, "X502_SetDigInPullup"); - SetExtRefFreqValue = load_symbol(x502_module, "X502_SetExtRefFreqValue"); - Configure = load_symbol(x502_module, "X502_Configure"); - StreamsEnable = load_symbol(x502_module, "X502_StreamsEnable"); - StreamsStart = load_symbol(x502_module, "X502_StreamsStart"); - GetRecvReadyCount = load_symbol(x502_module, "X502_GetRecvReadyCount"); - Recv = load_symbol(x502_module, "X502_Recv"); - OutCycleLoadStart = try_load_symbol(x502_module, "X502_OutCycleLoadStart"); - OutCycleSetup = try_load_symbol(x502_module, "X502_OutCycleSetup"); - OutCycleStop = try_load_symbol(x502_module, "X502_OutCycleStop"); - Send = load_symbol(x502_module, "X502_Send"); - PrepareData = load_symbol(x502_module, "X502_PrepareData"); - ProcessData = load_symbol(x502_module, "X502_ProcessData"); - - OpenUsb = load_symbol(e502_module, "E502_OpenUsb"); - OpenByIpAddr = load_symbol(e502_module, "E502_OpenByIpAddr"); - } - - ~Api() { - close_library(e502_module); - close_library(x502_module); - } -}; - -std::string x502_error(const Api& api, int32_t err) { - const char* text = api.GetErrorString ? api.GetErrorString(err) : nullptr; - std::ostringstream out; - out << "err=" << err; - if ((text != nullptr) && (*text != '\0')) { - out << " (" << text << ")"; - } - return out.str(); -} - -void expect_ok(const Api& api, int32_t err, const std::string& what) { - if (err != X502_ERR_OK) { - fail(what + ": " + x502_error(api, err)); - } -} - -constexpr uint32_t kE502DiSyn2Mask = - (static_cast(1U) << 13U) | (static_cast(1U) << 17U); -constexpr uint32_t kE502Digital1Mask = (static_cast(1U) << 0U); -constexpr uint32_t kE502Digital2Mask = (static_cast(1U) << 1U); -constexpr uint32_t kE502Do1Mask = (static_cast(1U) << 0U); -constexpr uint32_t kE502Do2Mask = (static_cast(1U) << 1U); -constexpr uint32_t kE502Digital8Mask = (static_cast(1U) << 6U); // DI8 reads as bit 6 (hardware offset confirmed by loopback test) -constexpr uint32_t kE502Do8Mask = (static_cast(1U) << 7U); -constexpr uint32_t kDo1TogglePeriodTicks = 2U; -constexpr uint32_t kDo1CyclePatternWords = kDo1TogglePeriodTicks * 2U; -constexpr uint32_t kDo8HighTicks = 2U; -constexpr uint16_t kTtyMarkerDi8High = 0x00A8U; -// Combo tag frame: [0x00C0, input_pos, output_pos, dirty]. Emitted right after a -// sweep-boundary frame to tell the consumer which switch combination the upcoming -// sweep belongs to, and whether it straddled a switch transition (dirty != 0). -constexpr uint16_t kTtyMarkerComboTag = 0x00C0U; -constexpr uint32_t kStreamInputAdcFlag = 0x80000000U; -constexpr uint32_t kStreamInputCalibratedAdcFlag = 0x40000000U; - -using TickMs = uint64_t; - -TickMs tick_count_ms() { -#ifdef _WIN32 - return static_cast(GetTickCount64()); -#else - using namespace std::chrono; - return static_cast( - duration_cast(steady_clock::now().time_since_epoch()).count()); -#endif -} - -#ifdef _WIN32 -volatile LONG g_console_stop_requested = 0; - -BOOL WINAPI console_ctrl_handler(DWORD ctrl_type) { - if ((ctrl_type == CTRL_C_EVENT) || (ctrl_type == CTRL_BREAK_EVENT) || (ctrl_type == CTRL_CLOSE_EVENT)) { - InterlockedExchange(&g_console_stop_requested, 1); - return TRUE; - } - return FALSE; -} - -bool console_stop_requested() { - return InterlockedCompareExchange(&g_console_stop_requested, 0, 0) != 0; -} -#else -volatile std::sig_atomic_t g_console_stop_requested = 0; - -void console_ctrl_handler(int sig) { - if (g_console_stop_requested != 0) { - // Already asked to stop and signalled again (e.g. a second Ctrl+C, or the - // parent escalating): the graceful device teardown can block, so exit - // immediately rather than ever hang. The kernel releases the E-502 on exit. - std::_Exit(128 + sig); - } - g_console_stop_requested = 1; -} - -bool console_stop_requested() { - return g_console_stop_requested != 0; -} -#endif - -enum class PacketCloseReason { - ExternalStopEdge, - DurationLimit, - UserStop -}; - -const char* packet_close_reason_to_string(PacketCloseReason reason) { - switch (reason) { - case PacketCloseReason::ExternalStopEdge: - return "DI_SYN2 edge"; - case PacketCloseReason::DurationLimit: - return "duration limit"; - case PacketCloseReason::UserStop: - return "user stop"; - default: - return "unknown"; - } -} - -bool matches_sync_edge(uint32_t mode, bool prev_level, bool current_level) { - if (mode == X502_SYNC_DI_SYN2_RISE) { - return !prev_level && current_level; - } - if (mode == X502_SYNC_DI_SYN2_FALL) { - return prev_level && !current_level; - } - return false; -} - -bool matches_stop_edge(StopMode mode, bool prev_level, bool current_level) { - if (mode == StopMode::DiSyn2Rise) { - return !prev_level && current_level; - } - if (mode == StopMode::DiSyn2Fall) { - return prev_level && !current_level; - } - return false; -} - -struct PacketAccumulator { - std::array, 2> channels; - std::vector di1; - std::size_t zeroed_samples = 0; - std::size_t stored_samples = 0; - bool pending_frame_di1_valid = false; - uint8_t pending_frame_di1 = 0; - - void reset(std::size_t reserve_frames, std::size_t channel_count) { - for (std::size_t i = 0; i < channels.size(); ++i) { - auto& channel = channels[i]; - channel.clear(); - if (i < channel_count) { - channel.reserve(reserve_frames); - } - } - di1.clear(); - di1.reserve(reserve_frames); - zeroed_samples = 0; - stored_samples = 0; - pending_frame_di1_valid = false; - pending_frame_di1 = 0; - } - - std::size_t frame_count(std::size_t channel_count) const { - std::size_t frames = (channel_count <= 1U) ? channels[0].size() : std::min(channels[0].size(), channels[1].size()); - if (!di1.empty()) { - frames = std::min(frames, di1.size()); - } - return frames; - } -}; - -struct TtyContinuousState { - bool pending_ch1_valid = false; - int16_t pending_ch1 = 0; - uint16_t next_index = 1; -}; - -struct TtyGroupAverageState { - double sum_ch1 = 0.0; - double sum_ch2 = 0.0; - uint32_t count_ch1 = 0; - uint32_t count_ch2 = 0; - uint32_t next_index = 1; - - void clear_step() { - sum_ch1 = 0.0; - sum_ch2 = 0.0; - count_ch1 = 0; - count_ch2 = 0; - } - - void reset_packet() { - clear_step(); - next_index = 1; - } - - void add_sample(uint32_t lch, double raw_code) { - if (lch == 0U) { - sum_ch1 += raw_code; - ++count_ch1; - } else if (lch == 1U) { - sum_ch2 += raw_code; - ++count_ch2; - } - } - - bool has_complete_step(uint32_t channel_count) const { - return (count_ch1 != 0U) && ((channel_count <= 1U) || (count_ch2 != 0U)); - } -}; - -struct Do1RawTaggedState { - std::array step_sum {}; - std::array step_count {}; - bool step_level_initialized = false; - bool step_di1_high = false; - uint16_t next_index = 1; - - void clear_step() { - step_sum = {}; - step_count = {}; - } - - void reset_packet() { - clear_step(); - step_level_initialized = false; - step_di1_high = false; - next_index = 1; - } - - void start_new_step(bool di1_high) { - clear_step(); - step_di1_high = di1_high; - step_level_initialized = true; - } - - void add_sample(uint32_t lch, double raw_code) { - if (lch >= step_sum.size()) { - return; - } - step_sum[lch] += raw_code; - ++step_count[lch]; - } - - bool has_complete_step(uint32_t channel_count) const { - return (step_count[0] != 0U) && ((channel_count <= 1U) || (step_count[1] != 0U)); - } - - double step_average(uint32_t lch) const { - if ((lch >= step_sum.size()) || (step_count[lch] == 0U)) { - return 0.0; - } - return step_sum[lch] / static_cast(step_count[lch]); - } -}; - -struct Do1NoiseSubtractState { - std::array step_sum {}; - std::array step_count {}; - std::array last_high_avg {}; - std::array last_high_valid {}; - bool high_baseline_pending = false; - bool step_level_initialized = false; - bool step_di1_high = false; - uint16_t next_index = 1; - - void configure(uint32_t history_steps) { - (void) history_steps; - clear_baseline(); - reset_packet(); - } - - void clear_step() { - step_sum = {}; - step_count = {}; - } - - void clear_baseline() { - last_high_avg = {}; - last_high_valid = {}; - high_baseline_pending = false; - } - - void reset_packet() { - clear_step(); - step_level_initialized = false; - step_di1_high = false; - next_index = 1; - } - - void start_new_step(bool di1_high) { - clear_step(); - step_di1_high = di1_high; - step_level_initialized = true; - } - - void add_sample(uint32_t lch, double raw_code) { - if (lch >= step_sum.size()) { - return; - } - step_sum[lch] += raw_code; - ++step_count[lch]; - } - - bool has_complete_step(uint32_t channel_count) const { - return (step_count[0] != 0U) && ((channel_count <= 1U) || (step_count[1] != 0U)); - } - - double step_average(uint32_t lch) const { - if ((lch >= step_sum.size()) || (step_count[lch] == 0U)) { - return 0.0; - } - return step_sum[lch] / static_cast(step_count[lch]); - } - - void update_last_high(uint32_t lch, double avg_value) { - if (lch >= last_high_avg.size()) { - return; - } - last_high_avg[lch] = avg_value; - last_high_valid[lch] = true; - } - - bool has_pending_high(uint32_t channel_count) const { - if (!high_baseline_pending) { - return false; - } - if (!last_high_valid[0]) { - return false; - } - if ((channel_count > 1U) && !last_high_valid[1]) { - return false; - } - return true; - } - - void mark_high_pending() { high_baseline_pending = true; } - - void consume_pending_high() { high_baseline_pending = false; } - - double last_high(uint32_t lch) const { - if (lch >= last_high_avg.size()) { - return 0.0; - } - return last_high_avg[lch]; - } - - void finish_step() { clear_step(); } -}; - -struct Do1PairSubtractAverageState { - struct PairSample { - double ch1 = 0.0; - double ch2 = 0.0; - }; - - std::deque low_pairs; - std::deque 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(diff_count); - } - - double avg_diff_ch2() const { - if (diff_count == 0U) { - return 0.0; - } - return diff_sum_ch2 / static_cast(diff_count); - } -}; - -struct Di8FreqRefState { - std::array high_sum {}; - std::array high_count {}; - - static constexpr uint32_t kGuardSamples = 2U; - bool di8_prev_level = false; - bool di8_initialized = false; - uint32_t samples_since_transition = 0; - bool in_guard = false; - - uint16_t last_standard_index = 0; - bool last_standard_valid = false; - - void clear_step() { - high_sum = {}; - high_count = {}; - } - - void reset_packet() { - clear_step(); - di8_prev_level = false; - di8_initialized = false; - samples_since_transition = 0; - in_guard = false; - last_standard_index = 0; - last_standard_valid = false; - } - - // Returns true if the sample is in the settled DI8=HIGH region. - // Returns false for guard samples and DI8=LOW. - // Sets in_guard for samples within the guard window after any transition. - bool update_di8(bool di8_level) { - if (!di8_initialized) { - di8_prev_level = di8_level; - di8_initialized = true; - in_guard = true; - samples_since_transition = 0; - return false; - } - - if (di8_level != di8_prev_level) { - di8_prev_level = di8_level; - samples_since_transition = 0; - in_guard = true; - return false; - } - - if (in_guard) { - ++samples_since_transition; - if (samples_since_transition >= kGuardSamples) { - in_guard = false; - } else { - return false; - } - } - - return di8_level; - } - - void add_sample(uint32_t lch, double raw_code) { - if (lch >= high_sum.size()) { - return; - } - high_sum[lch] += raw_code; - ++high_count[lch]; - } - - bool has_complete_step(uint32_t channel_count) const { - return (high_count[0] != 0U) && ((channel_count <= 1U) || (high_count[1] != 0U)); - } - - double step_average(uint32_t lch) const { - if ((lch >= high_sum.size()) || (high_count[lch] == 0U)) { - return 0.0; - } - return high_sum[lch] / static_cast(high_count[lch]); - } -}; - -int16_t pack_raw_code_to_int16(double avg_raw_code) { - const double scaled = - avg_raw_code * 32767.0 / static_cast(X502_ADC_SCALE_CODE_MAX); - const long long rounded = std::llround(scaled); - const long long clamped = std::clamp(rounded, -32768LL, 32767LL); - return static_cast(clamped); -} - -bool try_extract_raw_adc_code(uint32_t word, double& raw_code) { - if ((word & kStreamInputAdcFlag) == 0U) { - return false; - } - - int32_t value = 0; - if ((word & kStreamInputCalibratedAdcFlag) != 0U) { - const uint32_t payload = word & 0x00FFFFFFU; - value = static_cast(payload); - if ((payload & 0x00800000U) != 0U) { - value |= static_cast(0xFF000000U); - } - } else { - const uint32_t payload = word & 0x0000FFFFU; - value = static_cast(payload); - if ((payload & 0x00008000U) != 0U) { - value |= static_cast(0xFFFF0000U); - } - } - - raw_code = static_cast(value); - return true; -} - -struct ConsoleCtrlGuard { - bool installed = false; - -#ifndef _WIN32 - bool sigint_installed = false; - bool sigterm_installed = false; - bool sigabrt_installed = false; - struct sigaction old_sigint {}; - struct sigaction old_sigterm {}; - struct sigaction old_sigabrt {}; -#endif - - ConsoleCtrlGuard() { -#ifdef _WIN32 - InterlockedExchange(&g_console_stop_requested, 0); - installed = SetConsoleCtrlHandler(console_ctrl_handler, TRUE) != 0; -#else - g_console_stop_requested = 0; - - struct sigaction action {}; - action.sa_handler = console_ctrl_handler; - sigemptyset(&action.sa_mask); - action.sa_flags = 0; - - sigint_installed = sigaction(SIGINT, &action, &old_sigint) == 0; - sigterm_installed = sigaction(SIGTERM, &action, &old_sigterm) == 0; - sigabrt_installed = sigaction(SIGABRT, &action, &old_sigabrt) == 0; - installed = sigint_installed && sigterm_installed && sigabrt_installed; -#endif - } - - ~ConsoleCtrlGuard() { -#ifdef _WIN32 - if (installed) { - SetConsoleCtrlHandler(console_ctrl_handler, FALSE); - } - InterlockedExchange(&g_console_stop_requested, 0); -#else - if (sigint_installed) { - sigaction(SIGINT, &old_sigint, nullptr); - } - if (sigterm_installed) { - sigaction(SIGTERM, &old_sigterm, nullptr); - } - if (sigabrt_installed) { - sigaction(SIGABRT, &old_sigabrt, nullptr); - } - g_console_stop_requested = 0; -#endif - } -}; - -struct DeviceHandle { - const Api& api; - t_x502_hnd hnd = nullptr; - bool opened = false; - bool streams_started = false; - - explicit DeviceHandle(const Api& api_ref) : api(api_ref), hnd(api.Create()) { - if (hnd == nullptr) { - fail("X502_Create failed"); - } - } - - ~DeviceHandle() { - if (hnd != nullptr) { - if (streams_started) { - api.StreamsStop(hnd); - } - if (opened) { - api.Close(hnd); - } - api.Free(hnd); - } - } -}; - -void print_device_info(const t_x502_info& info) { - std::cout << "Device: " << info.name << "\n" - << "Serial: " << info.serial << "\n" - << "FPGA version: " << static_cast(info.fpga_ver >> 8) << "." - << static_cast(info.fpga_ver & 0xFF) << "\n" - << "PLDA version: " << static_cast(info.plda_ver) << "\n" - << "Board revision: " << static_cast(info.board_rev) << "\n" - << "MCU firmware: " << info.mcu_firmware_ver << "\n"; -} - -// Build one switch driver (native GPIO or mock) from a parsed switch config, -// using the shared H7992 GPIO implementation. -std::unique_ptr make_switch_driver(const radar::config::SwitchConfig& sc) { - return std::make_unique( - radar::drivers::H7992MinimalDriverSettings{ - .name = sc.name, - .mode = sc.driver_mode, - .positions = sc.positions, - .default_position = sc.default_position, - .gpio_chip = sc.gpio_chip, - .pin_a = sc.pin_a, - .pin_b = sc.pin_b, - }); -} - -// Construct the switch sequencer from the run_config given via `config:`. -// Returns nullptr (switching disabled, single-channel passthrough) when no config -// was supplied or it declares no combinations. -std::unique_ptr make_switch_sequencer(const Config& cfg) { - if (!cfg.run_config_path.has_value()) { - return nullptr; - } - const radar::config::RunConfig rc = radar::config::load_run_config(*cfg.run_config_path); - if (rc.run_combos.empty()) { - return nullptr; - } - radar::kamil::SwitchSequencer::Settings settings; - settings.combos = rc.run_combos; - settings.gap_ms = cfg.switch_gap_ms; - settings.settle_ms = cfg.switch_settle_ms; - return std::make_unique( - std::move(settings), - make_switch_driver(rc.input_switch), - make_switch_driver(rc.output_switch)); -} - -int run(const Config& cfg) { - Api api; - DeviceHandle device(api); - - int32_t err = X502_ERR_OK; - if (cfg.ip_addr.has_value()) { - err = api.OpenByIpAddr(device.hnd, *cfg.ip_addr, 0, 5000); - } else { - err = api.OpenUsb(device.hnd, cfg.serial.empty() ? nullptr : cfg.serial.c_str()); - } - expect_ok(api, err, "Open device"); - device.opened = true; - - t_x502_info info{}; - if (api.GetDevInfo2 != nullptr) { - err = api.GetDevInfo2(device.hnd, &info, sizeof(info)); - } else { - err = api.GetDevInfo(device.hnd, &info); - } - expect_ok(api, err, "Get device info"); - print_device_info(info); - - expect_ok(api, api.SetMode(device.hnd, X502_MODE_FPGA), "Set FPGA mode"); - api.StreamsStop(device.hnd); - api.StreamsDisable(device.hnd, X502_STREAM_ALL_IN | X502_STREAM_ALL_OUT); - - expect_ok(api, api.SetSyncMode(device.hnd, cfg.sync_mode), "Set sync mode"); - expect_ok(api, api.SetSyncStartMode(device.hnd, cfg.sync_start_mode), "Set sync start mode"); - - if (cfg.sync_mode == X502_SYNC_INTERNAL) { - expect_ok(api, api.SetRefFreq(device.hnd, cfg.internal_ref_freq), "Set internal reference frequency"); - } else { - const int32_t ext_ref_err = api.SetExtRefFreqValue(device.hnd, cfg.sample_clock_hz); - if (ext_ref_err != X502_ERR_OK) { - if (cfg.sample_clock_hz <= 1500000.0) { - expect_ok(api, ext_ref_err, "Set external reference frequency"); - } else { - std::cerr << "Warning: X502_SetExtRefFreqValue(" << cfg.sample_clock_hz - << ") failed, continuing with manual divider configuration: " - << x502_error(api, ext_ref_err) << "\n"; - } - } - } - - expect_ok(api, api.SetLChannelCount(device.hnd, cfg.channel_count), "Set logical channel count"); - expect_ok(api, api.SetLChannel(device.hnd, 0, cfg.ch1, cfg.mode, cfg.range, 1), "Set logical channel 0"); - if (cfg.channel_count >= 2U) { - expect_ok(api, api.SetLChannel(device.hnd, 1, cfg.ch2, cfg.mode, cfg.range, 1), "Set logical channel 1"); - } - - const bool internal_max_clock = use_internal_max_clock(cfg); - double actual_sample_clock_hz = cfg.sample_clock_hz; - double actual_frame_freq_hz = cfg.sample_clock_hz / 2.0; - if (internal_max_clock) { - actual_sample_clock_hz = static_cast(cfg.internal_ref_freq); - actual_frame_freq_hz = 0.0; - expect_ok(api, api.SetAdcFreq(device.hnd, &actual_sample_clock_hz, nullptr), "Set ADC frequency to maximum"); - expect_ok(api, api.GetAdcFreq(device.hnd, &actual_sample_clock_hz, &actual_frame_freq_hz), - "Get effective ADC frequency"); - } else { - expect_ok(api, api.SetAdcFreq(device.hnd, &actual_sample_clock_hz, &actual_frame_freq_hz), "Set ADC frequency"); - } - - double actual_din_freq_hz = actual_sample_clock_hz; - expect_ok(api, api.SetDinFreq(device.hnd, &actual_din_freq_hz), "Set DIN frequency"); - if (std::fabs(actual_din_freq_hz - actual_sample_clock_hz) > std::max(0.5, actual_sample_clock_hz * 1e-6)) { - std::ostringstream message; - message << "ADC and DIN frequencies no longer match closely enough for 1:1 masking logic. " - << "ADC=" << actual_sample_clock_hz << " Hz, DIN=" << actual_din_freq_hz << " Hz."; - fail(message.str()); - } - const double combined_input_rate_hz = actual_sample_clock_hz + actual_din_freq_hz; - if (cfg.ip_addr.has_value() && (combined_input_rate_hz > 2500000.0)) { - std::ostringstream message; - message << "Current Ethernet input load is too high for E-502: ADC " - << actual_sample_clock_hz << " Hz + DIN " << actual_din_freq_hz - << " Hz = " << combined_input_rate_hz - << " words/s, while the documented Ethernet input-only limit is about 2500000 words/s."; - fail(message.str()); - } - double actual_dout_freq_hz = actual_frame_freq_hz; - if (cfg.do1_toggle_per_frame) { - if (actual_sample_clock_hz <= 0.0) { - fail("do1_toggle_per_frame requires a positive ADC sample rate"); - } - actual_dout_freq_hz = actual_sample_clock_hz; - expect_ok(api, api.SetOutFreq(device.hnd, &actual_dout_freq_hz), "Set DOUT frequency"); - } - - expect_ok(api, api.SetStreamBufSize(device.hnd, X502_STREAM_CH_IN, cfg.input_buffer_words), "Set input buffer size"); - expect_ok(api, api.SetStreamStep(device.hnd, X502_STREAM_CH_IN, cfg.input_step_words), "Set input stream step"); - if (cfg.do1_toggle_per_frame) { - expect_ok(api, api.SetStreamBufSize(device.hnd, X502_STREAM_CH_OUT, cfg.input_buffer_words), "Set output buffer size"); - expect_ok(api, api.SetStreamStep(device.hnd, X502_STREAM_CH_OUT, cfg.input_step_words), "Set output stream step"); - } - - uint32_t pullups = 0; - if (cfg.pullup_syn1) { - pullups |= X502_PULLUPS_DI_SYN1; - } - if (cfg.pullup_syn2) { - pullups |= X502_PULLUPS_DI_SYN2; - } - if (cfg.pulldown_conv_in) { - pullups |= X502_PULLDOWN_CONV_IN; - } - if (cfg.pulldown_start_in) { - pullups |= X502_PULLDOWN_START_IN; - } - expect_ok(api, api.SetDigInPullup(device.hnd, pullups), "Set digital input pullups/pulldowns"); - - expect_ok(api, api.Configure(device.hnd, 0), "Configure device"); - expect_ok(api, api.AsyncOutDig(device.hnd, kE502Do2Mask, 0U), "Force DO2 HIGH"); - - // DO8/DI8 loopback diagnostic (before streams start, AsyncOutDig/AsyncInDig work) - if (cfg.do8_freq_ref) { - uint32_t din_val = 0; - // Set DO8 HIGH - expect_ok(api, api.AsyncOutDig(device.hnd, kE502Do2Mask | kE502Do8Mask, 0U), - "DO8 loopback test: set DO8 HIGH"); - usleep(2000); - expect_ok(api, api.AsyncInDig(device.hnd, &din_val), "DO8 loopback test: read DIN"); - const bool di8_high = (din_val & kE502Digital8Mask) != 0U; - std::cerr << "DO8/DI8 loopback: DO8=HIGH -> DI8=" << (di8_high ? "HIGH" : "LOW") - << " (DIN=0x" << std::hex << din_val << std::dec << ")\n"; - // Set DO8 LOW - expect_ok(api, api.AsyncOutDig(device.hnd, kE502Do2Mask, 0U), - "DO8 loopback test: set DO8 LOW"); - usleep(2000); - expect_ok(api, api.AsyncInDig(device.hnd, &din_val), "DO8 loopback test: read DIN"); - const bool di8_low = (din_val & kE502Digital8Mask) != 0U; - std::cerr << "DO8/DI8 loopback: DO8=LOW -> DI8=" << (di8_low ? "HIGH" : "LOW") - << " (DIN=0x" << std::hex << din_val << std::dec << ")\n"; - if (!di8_high || di8_low) { - std::cerr << "WARNING: DO8/DI8 loopback FAILED. Check physical connection.\n"; - } else { - std::cerr << "DO8/DI8 loopback PASSED.\n"; - } - // Restore - expect_ok(api, api.AsyncOutDig(device.hnd, kE502Do2Mask, 0U), "Restore DO2 HIGH only"); - } - uint32_t enabled_streams = X502_STREAM_ADC | X502_STREAM_DIN; - if (cfg.do1_toggle_per_frame) { - enabled_streams |= X502_STREAM_DOUT; - } - expect_ok(api, api.StreamsEnable(device.hnd, enabled_streams), "Enable data streams"); - - const double target_frame_count = (cfg.duration_ms / 1000.0) * actual_frame_freq_hz; - if (!std::isfinite(target_frame_count) || target_frame_count < 0.0 - || target_frame_count >= static_cast(std::numeric_limits::max()) - || target_frame_count > static_cast(std::numeric_limits::max())) { - fail("Capture duration and frame clock produce an unsupported frame count"); - } - const std::size_t target_frames = std::max( - 1, static_cast(std::llround(target_frame_count))); - - std::cout << "Capture settings:\n" - << " clock source: " << sync_mode_to_string(cfg.sync_mode, false) << "\n" - << " start source: " << sync_mode_to_string(cfg.sync_start_mode, true) << "\n" - << " stop source: " << stop_mode_to_string(cfg.stop_mode) << "\n" - << " sample clock: " << actual_sample_clock_hz << " Hz"; - if (cfg.sync_mode == X502_SYNC_INTERNAL) { - std::cout << " (internal ref " << ref_freq_to_string(cfg.internal_ref_freq) << " Hz)"; - if (internal_max_clock) { - std::cout << ", maximum ADC speed"; - } - } - std::cout << "\n" - << " DIN clock: " << actual_din_freq_hz << " Hz\n" - << " ADC+DIN total input rate: " << combined_input_rate_hz << " words/s\n" - << " ADC logical channels: " << cfg.channel_count << "\n" - << " per-channel frame rate: " << actual_frame_freq_hz << " Hz\n" - << " duration: " << cfg.duration_ms << " ms\n" - << " packet limit: " - << ((cfg.packet_limit == 0U) ? std::string("continuous until Ctrl+C") - : std::to_string(cfg.packet_limit) + " packet(s)") << "\n" - << " live update period: " - << ((cfg.live_update_period_ms == 0U) ? std::string("every packet") - : std::to_string(cfg.live_update_period_ms) + " ms") << "\n" - << " channel 1: " << phy_channel_name(cfg.mode, cfg.ch1) << "\n" - << " channel 2: " - << ((cfg.channel_count >= 2U) ? phy_channel_name(cfg.mode, cfg.ch2) : std::string("disabled")) << "\n" - << " DI1 handling: " << di1_mode_to_string(cfg.di1_mode) << "\n" - << " DO1 toggle per frame: " - << (cfg.do1_toggle_per_frame ? std::string("enabled (hardware cyclic 00110011..., toggle every 2 ADC ticks)") - : std::string("disabled")) << "\n" - << " DO2 state: forced HIGH\n" - << " DOUT rate: " - << (cfg.do1_toggle_per_frame ? std::to_string(actual_dout_freq_hz) + " Hz" : std::string("disabled")) << "\n" - << " DO8 freq ref: " - << (cfg.do8_freq_ref - ? ("enabled (period=" + std::to_string(cfg.do8_cycle_period_ticks) - + " ticks, high=" + std::to_string(kDo8HighTicks) + " ticks" - + (cfg.do8_amplitude_threshold.has_value() - ? ", threshold=" + std::to_string(*cfg.do8_amplitude_threshold) : "") - + ")") - : std::string("disabled")) - << "\n" - << " ADC range: +/-" << range_to_volts(cfg.range) << " V\n" - << " max frames per packet per channel: " << target_frames << "\n"; - - const uint32_t read_capacity_words = std::max(cfg.recv_block_words, cfg.input_step_words); - std::size_t tty_ring_capacity_bytes = 0; - std::unique_ptr tty_writer; - const bool tty_di1_group_average = cfg.tty_path.has_value() && cfg.di1_group_average; - const bool tty_do1_noise_subtract = cfg.tty_path.has_value() && cfg.do1_noise_subtract; - const bool tty_do1_raw_tty_marked = cfg.tty_path.has_value() && cfg.do1_raw_tty_marked; - const bool tty_do1_pair_subtract_avg = cfg.tty_path.has_value() && cfg.do1_pair_subtract_avg; - const bool fast_tty_avg_stream_mode = - tty_di1_group_average || tty_do1_noise_subtract || tty_do1_raw_tty_marked || tty_do1_pair_subtract_avg; - const uint16_t tty_packet_start_marker = - (cfg.profile == CaptureProfile::Amplitude) ? 0x001AU : 0x000AU; - const std::string fast_tty_mode_name = tty_do1_noise_subtract - ? std::string("do1_noise_subtract") - : (tty_do1_raw_tty_marked ? std::string("do1_raw_tty_marked") - : (tty_do1_pair_subtract_avg ? std::string("do1_pair_subtract_avg") - : (tty_di1_group_average ? std::string("di1_group_avg") - : std::string("none")))); - if (cfg.tty_path) { - const uint64_t typical_tty_batch_frames = (static_cast(read_capacity_words) + 1U) / 2U; - const uint64_t typical_tty_batch_bytes = typical_tty_batch_frames * sizeof(uint16_t) * 4U; - const uint64_t required_tty_ring_bytes = typical_tty_batch_bytes * 16U; - const uint64_t tty_ring_bytes_u64 = std::max(1024U * 1024U, required_tty_ring_bytes); - if (tty_ring_bytes_u64 > static_cast(std::numeric_limits::max())) { - fail("TTY ring buffer size overflowed size_t"); - } - tty_ring_capacity_bytes = static_cast(tty_ring_bytes_u64); - tty_writer = std::make_unique(*cfg.tty_path, tty_ring_capacity_bytes); - if (!tty_di1_group_average && !tty_do1_noise_subtract && !tty_do1_raw_tty_marked && - !tty_do1_pair_subtract_avg) { - tty_writer->emit_packet_start(tty_packet_start_marker); - } - } - // Switch sequencer (optional): drives the RF switches in lock-step with sweep - // boundaries and tags each sweep with its combination. Disabled (nullptr) when - // no run_config was passed, so the standalone/calibration use stays unchanged. - std::unique_ptr sequencer = make_switch_sequencer(cfg); - if (sequencer && sequencer->enabled()) { - sequencer->open(); - std::cout << "Switch sequencer enabled: cycling switch combinations per sweep " - << "(gap_ms=" << cfg.switch_gap_ms << ", settle_ms=" << cfg.switch_settle_ms << ")\n"; - } else { - sequencer.reset(); // normalize to nullptr so call sites can test the pointer - } - // Estimated readout backlog (ms) at the current loop iteration; how far behind - // real time we are, used to decide whether an in-gap switch lands in time. - double current_backlog_ms = 0.0; - - std::unique_ptr writer; - if (!fast_tty_avg_stream_mode) { - writer = std::make_unique(cfg.csv_path, cfg.svg_path, cfg.live_html_path, cfg.live_json_path); - writer->initialize_live_plot(); - writer->initialize_csv(cfg.channel_count, cfg.di1_mode == Di1Mode::Trace); - } - if (fast_tty_avg_stream_mode) { - std::cout << " tty fast stream-only mode: enabled (" << fast_tty_mode_name << ")\n" - << " tty stream output: " << *cfg.tty_path << "\n" - << " tty ring buffer bytes: " << tty_ring_capacity_bytes << "\n" - << " recv block words: " << cfg.recv_block_words << "\n" - << " input step words: " << cfg.input_step_words << "\n" - << " input buffer words: " << cfg.input_buffer_words << "\n" - << " tty di1_group_avg: " << (tty_di1_group_average ? "enabled" : "disabled") << "\n" - << " tty do1_noise_subtract: " << (tty_do1_noise_subtract ? "enabled" : "disabled") << "\n" - << " tty do1_raw_tty_marked: " << (tty_do1_raw_tty_marked ? "enabled" : "disabled") << "\n" - << " tty do1_pair_subtract_avg: " << (tty_do1_pair_subtract_avg ? "enabled" : "disabled") << "\n" - << " do1_noise_subtract marker: " - << (tty_do1_noise_subtract ? "DI1 from DIN (DO1->DI1 loopback expected)" : "n/a") << "\n" - << " do1_raw_tty_marked markers: " - << (tty_do1_raw_tty_marked ? "0x00A3=LOW, 0x00A4=HIGH (DI1 from DIN)" : "n/a") << "\n" - << " do1_pair_subtract_avg marker: " - << (tty_do1_pair_subtract_avg ? "word0=0x000A, low-high by DI2, averaged per DI1 run" : "n/a") << "\n" - << " noise_avg_steps: " - << (tty_do1_noise_subtract ? std::to_string(*cfg.noise_avg_steps) : std::string("n/a")) << "\n" - << " stream-only note: ignoring csv/svg/live_html/live_json/live_update_period_ms/svg_history_packets\n"; - } else { - std::cout << " live plot html: " << writer->live_html_path() << "\n" - << " live plot data: " << writer->live_json_path() << "\n" - << " tty stream output: " << (cfg.tty_path ? *cfg.tty_path : std::string("disabled")) << "\n" - << " tty ring buffer bytes: " - << (cfg.tty_path ? std::to_string(tty_ring_capacity_bytes) : std::string("disabled")) << "\n" - << " recv block words: " << cfg.recv_block_words << "\n" - << " input step words: " << cfg.input_step_words << "\n" - << " input buffer words: " << cfg.input_buffer_words << "\n" - << " tty di1_group_avg: " - << (tty_di1_group_average ? std::string("enabled") - : (cfg.di1_group_average ? std::string("requested, tty disabled") - : std::string("disabled"))) - << "\n" - << " tty do1_noise_subtract: " - << (tty_do1_noise_subtract ? std::string("enabled") - : (cfg.do1_noise_subtract ? std::string("requested, tty disabled") - : std::string("disabled"))) - << "\n" - << " tty do1_raw_tty_marked: " - << (tty_do1_raw_tty_marked ? std::string("enabled") - : (cfg.do1_raw_tty_marked ? std::string("requested, tty disabled") - : std::string("disabled"))) - << "\n" - << " tty do1_pair_subtract_avg: " - << (tty_do1_pair_subtract_avg ? std::string("enabled") - : (cfg.do1_pair_subtract_avg ? std::string("requested, tty disabled") - : std::string("disabled"))) - << "\n" - << " SVG history packets kept in RAM: " - << ((cfg.svg_history_packets == 0U) ? std::string("all (unbounded)") : std::to_string(cfg.svg_history_packets)) - << "\n"; - } - - ConsoleCtrlGuard console_guard; - if (!console_guard.installed) { - std::cerr << "Warning: Ctrl+C handler could not be installed; continuous mode may stop abruptly.\n"; - } - - const uint32_t cycle_pattern_len = cfg.do8_freq_ref - ? static_cast(std::lcm(static_cast(kDo1CyclePatternWords), - cfg.do8_cycle_period_ticks)) - : kDo1CyclePatternWords; - std::vector do1_cycle_pattern(cycle_pattern_len, 0U); - std::vector do1_cycle_encoded(cycle_pattern_len, 0U); - if (cfg.do1_toggle_per_frame) { - if ((api.OutCycleLoadStart == nullptr) || (api.OutCycleSetup == nullptr) || (api.OutCycleStop == nullptr)) { - fail("do1_toggle_per_frame requires X502_OutCycle* API support (cyclic output mode)"); - } - - for (std::size_t i = 0; i < do1_cycle_pattern.size(); ++i) { - uint32_t word = kE502Do2Mask; - - const uint32_t do1_phase = static_cast(i) % kDo1CyclePatternWords; - if (do1_phase >= kDo1TogglePeriodTicks) { - word |= kE502Do1Mask; - } - - if (cfg.do8_freq_ref) { - const uint32_t do8_phase = static_cast(i) % cfg.do8_cycle_period_ticks; - if (do8_phase >= (cfg.do8_cycle_period_ticks - kDo8HighTicks)) { - word |= kE502Do8Mask; - } - } - - do1_cycle_pattern[i] = word; - } - - // Debug: print cyclic DOUT pattern - { - std::cerr << "Cyclic DOUT pattern (" << do1_cycle_pattern.size() << " words):"; - for (std::size_t i = 0; i < do1_cycle_pattern.size(); ++i) { - const uint32_t w = do1_cycle_pattern[i]; - const char do1_ch = (w & kE502Do1Mask) ? '1' : '0'; - const char do2_ch = (w & kE502Do2Mask) ? '1' : '0'; - const char do8_ch = (w & kE502Do8Mask) ? '1' : '0'; - std::cerr << "\n [" << i << "] 0x" << std::hex << w << std::dec - << " DO1=" << do1_ch << " DO2=" << do2_ch << " DO8=" << do8_ch; - } - std::cerr << "\n"; - } - - if (cycle_pattern_len > 262144U) { - fail("Combined DO1+DO8 cyclic pattern too large: " + std::to_string(cycle_pattern_len)); - } - - expect_ok(api, - api.OutCycleLoadStart(device.hnd, static_cast(do1_cycle_pattern.size())), - "Allocate cyclic DOUT buffer"); - expect_ok(api, - api.PrepareData(device.hnd, - nullptr, - nullptr, - do1_cycle_pattern.data(), - static_cast(do1_cycle_pattern.size()), - 0, - do1_cycle_encoded.data()), - "Prepare cyclic DOUT pattern"); - - std::size_t sent_cycle_words = 0; - while (sent_cycle_words < do1_cycle_encoded.size()) { - const int32_t sent = api.Send(device.hnd, - do1_cycle_encoded.data() + sent_cycle_words, - static_cast(do1_cycle_encoded.size() - sent_cycle_words), - cfg.recv_timeout_ms); - if (sent < 0) { - fail("Send cyclic DOUT pattern: " + x502_error(api, sent)); - } - if (sent == 0) { - fail("Send cyclic DOUT pattern timed out before all words were queued"); - } - sent_cycle_words += static_cast(sent); - } - - // Debug: print encoded pattern - { - std::cerr << "Encoded DOUT pattern (" << do1_cycle_encoded.size() << " words):"; - for (std::size_t i = 0; i < do1_cycle_encoded.size(); ++i) { - std::cerr << " 0x" << std::hex << do1_cycle_encoded[i] << std::dec; - } - std::cerr << "\nSent " << sent_cycle_words << " encoded words to device.\n"; - } - - expect_ok(api, api.OutCycleSetup(device.hnd, X502_OUT_CYCLE_FLAGS_WAIT_DONE), "Activate cyclic DOUT pattern"); - } - - expect_ok(api, api.StreamsStart(device.hnd), "Start streams"); - device.streams_started = true; - - std::vector raw(read_capacity_words); - std::vector adc_buffer(fast_tty_avg_stream_mode ? 0U : read_capacity_words); - std::vector adc_raw_buffer(read_capacity_words); - std::vector din_buffer(read_capacity_words); - std::deque pending_adc; - std::deque pending_adc_raw; - std::deque pending_din; - std::deque packets; - PacketAccumulator current_packet; - TtyContinuousState tty_state; - TtyGroupAverageState tty_group_state; - Do1RawTaggedState tty_do1_raw_state; - Do1NoiseSubtractState tty_do1_noise_state; - Do1PairSubtractAverageState tty_do1_pair_state; - Di8FreqRefState tty_di8_state; - std::vector tty_frame_words; - tty_frame_words.reserve(static_cast(read_capacity_words) * 2U + 16U); - if (tty_do1_noise_subtract) { - tty_do1_noise_state.configure(*cfg.noise_avg_steps); - } - if (!fast_tty_avg_stream_mode) { - current_packet.reset(target_frames, cfg.channel_count); - } - std::size_t packet_avg_steps = 0; - std::size_t fast_packet_frames = 0; - uint64_t packet_clock_count = 0; - uint64_t packet_di2_high_clocks = 0; - uint64_t packet_di2_low_clocks = 0; - uint64_t packet_di8_high_clocks = 0; - uint64_t packet_di8_low_clocks = 0; - uint64_t packet_di8_samples_accumulated = 0; - uint64_t packet_di8_ref_frames_emitted = 0; - uint64_t packet_di8_no_complete = 0; - uint64_t packet_di8_no_standard = 0; - uint64_t packet_di8_both_high = 0; - uint64_t packet_di8_both_normal = 0; - uint64_t packet_di8_mixed = 0; - - bool capture_started = false; - bool stop_loop_requested = false; - bool packet_active = false; - bool trigger_level_initialized = false; - bool trigger_prev_level = false; - std::size_t total_zeroed_samples = 0; - uint32_t next_lch = 0; - bool di1_initialized = false; - bool di1_prev_level = false; - const TickMs start_wait_deadline = tick_count_ms() + cfg.start_wait_ms; - const TickMs capture_loop_start = tick_count_ms(); - TickMs stats_window_start = capture_loop_start; - TickMs last_stats_print = capture_loop_start; - TickMs last_live_update = 0; - - uint64_t total_raw_words = 0; - uint64_t total_adc_samples = 0; - uint64_t total_din_samples = 0; - uint64_t total_stored_adc_samples = 0; - uint64_t total_completed_frames = 0; - uint64_t total_completed_packets = 0; - - uint64_t stats_raw_words = 0; - uint64_t stats_adc_samples = 0; - uint64_t stats_din_samples = 0; - uint64_t stats_stored_adc_samples = 0; - uint64_t stats_zeroed_samples = 0; - uint64_t stats_completed_frames = 0; - uint64_t stats_completed_packets = 0; - TtyProtocolWriter::StatsSnapshot tty_stats_window_start {}; - bool tty_overflow_warning_printed = false; - - auto print_stats = [&](bool final_report) { - const TickMs now = tick_count_ms(); - const double elapsed_s = std::max(1e-9, static_cast(now - stats_window_start) / 1000.0); - const double mb_per_s = (static_cast(stats_raw_words) * sizeof(uint32_t)) / elapsed_s / 1000.0 / 1000.0; - const double adc_samples_per_s = static_cast(stats_adc_samples) / elapsed_s; - const double din_samples_per_s = static_cast(stats_din_samples) / elapsed_s; - const double frames_per_ch_per_s = static_cast(stats_completed_frames) / elapsed_s; - const double packets_per_s = static_cast(stats_completed_packets) / elapsed_s; - const double zeroed_fraction = (stats_stored_adc_samples == 0U) - ? 0.0 - : (100.0 * static_cast(stats_zeroed_samples) / static_cast(stats_stored_adc_samples)); - const TtyProtocolWriter::StatsSnapshot tty_stats_now = - tty_writer ? tty_writer->stats() : TtyProtocolWriter::StatsSnapshot {}; - const uint64_t tty_frames_written = tty_stats_now.frames_written - tty_stats_window_start.frames_written; - const uint64_t tty_frames_dropped = tty_stats_now.frames_dropped - tty_stats_window_start.frames_dropped; - const uint64_t tty_ring_overflows = tty_stats_now.ring_overflows - tty_stats_window_start.ring_overflows; - - std::cout << std::fixed << std::setprecision(3) - << (final_report ? "Final stats: " : "Online stats: ") - << "MB/s=" << mb_per_s - << ", ADC samples/s=" << adc_samples_per_s - << ", DIN samples/s=" << din_samples_per_s - << ", frames/s per channel=" << frames_per_ch_per_s - << ", packets/s=" << packets_per_s; - if (!fast_tty_avg_stream_mode) { - if (cfg.di1_mode == Di1Mode::ZeroOnChange) { - std::cout << ", zeroed on DI1 change=" << zeroed_fraction << "% (" - << stats_zeroed_samples << "/" << stats_stored_adc_samples << ")"; - } else if (cfg.di1_mode == Di1Mode::Trace) { - std::cout << ", DI1 trace=enabled"; - } - } - if (tty_writer) { - std::cout << ", tty_frames_written=" << tty_frames_written - << ", tty_frames_dropped=" << tty_frames_dropped - << ", tty_ring_overflows=" << tty_ring_overflows; - } - std::cout << "\n"; - - if (!final_report) { - stats_window_start = now; - last_stats_print = now; - stats_raw_words = 0; - stats_adc_samples = 0; - stats_din_samples = 0; - stats_stored_adc_samples = 0; - stats_zeroed_samples = 0; - stats_completed_frames = 0; - stats_completed_packets = 0; - tty_stats_window_start = tty_stats_now; - } - }; - - auto append_tty_frame = [&](uint16_t word0, uint16_t word1, uint16_t word2, uint16_t word3) { - tty_frame_words.push_back(word0); - tty_frame_words.push_back(word1); - tty_frame_words.push_back(word2); - tty_frame_words.push_back(word3); - }; - - auto append_tty_packet_start = [&]() { - append_tty_frame(tty_packet_start_marker, 0xFFFF, 0xFFFF, 0xFFFF); - // Right after the boundary, tag the upcoming sweep with its switch combo so - // the consumer can group sweeps and drop the dirty ones. - if (sequencer) { - const radar::ipc::ComboKey combo = sequencer->current_combo(); - append_tty_frame(kTtyMarkerComboTag, - static_cast(combo.input_pos), - static_cast(combo.output_pos), - static_cast(sequencer->current_dirty() ? 1U : 0U)); - } - }; - - auto append_tty_group_step = [&]() { - if (!tty_di1_group_average || !tty_group_state.has_complete_step(cfg.channel_count)) { - return; - } - if (tty_group_state.next_index >= 0xFFFFU) { - fail("TTY protocol step index overflow within packet"); - } - - const double ch1_avg = tty_group_state.sum_ch1 / static_cast(tty_group_state.count_ch1); - const double ch2_avg = - (cfg.channel_count <= 1U) - ? 0.0 - : (tty_group_state.sum_ch2 / static_cast(tty_group_state.count_ch2)); - const uint16_t step_idx = static_cast(tty_group_state.next_index); - append_tty_frame( (cfg.profile == CaptureProfile::Amplitude) ? 0x001AU : 0x000AU, - step_idx, - static_cast(pack_raw_code_to_int16(ch1_avg)), - static_cast(pack_raw_code_to_int16(ch2_avg))); - ++tty_group_state.next_index; - ++packet_avg_steps; - if (cfg.do8_freq_ref) { - tty_di8_state.last_standard_index = step_idx; - tty_di8_state.last_standard_valid = true; - } - }; - - auto append_tty_do1_raw_marked_step = [&]() { - if (!tty_do1_raw_tty_marked) { - return; - } - if (!tty_do1_raw_state.step_level_initialized) { - return; - } - if (!tty_do1_raw_state.has_complete_step(cfg.channel_count)) { - tty_do1_raw_state.clear_step(); - return; - } - if (tty_do1_raw_state.next_index >= 0xFFFFU) { - fail("TTY protocol step index overflow within packet"); - } - - const double ch1_avg = tty_do1_raw_state.step_average(0U); - const double ch2_avg = (cfg.channel_count <= 1U) ? 0.0 : tty_do1_raw_state.step_average(1U); - const uint16_t marker = tty_do1_raw_state.step_di1_high ? 0x00A4U : 0x00A3U; - const uint16_t step_idx = tty_do1_raw_state.next_index; - append_tty_frame(marker, - step_idx, - static_cast(pack_raw_code_to_int16(ch1_avg)), - static_cast(pack_raw_code_to_int16(ch2_avg))); - ++tty_do1_raw_state.next_index; - ++packet_avg_steps; - tty_do1_raw_state.clear_step(); - if (cfg.do8_freq_ref) { - tty_di8_state.last_standard_index = step_idx; - tty_di8_state.last_standard_valid = true; - } - }; - - auto append_tty_do1_subtracted_step = [&]() { - if (!tty_do1_noise_subtract) { - return; - } - - if (!tty_do1_noise_state.step_level_initialized) { - return; - } - - if (!tty_do1_noise_state.has_complete_step(cfg.channel_count)) { - if (!tty_do1_noise_state.step_di1_high && - tty_do1_noise_state.has_pending_high(cfg.channel_count)) { - // Invalidate the HIGH->LOW pair if LOW step is incomplete. - tty_do1_noise_state.consume_pending_high(); - } - tty_do1_noise_state.finish_step(); - return; - } - - const double ch1_avg = tty_do1_noise_state.step_average(0U); - const double ch2_avg = (cfg.channel_count <= 1U) ? 0.0 : tty_do1_noise_state.step_average(1U); - - if (tty_do1_noise_state.step_di1_high) { - tty_do1_noise_state.update_last_high(0U, ch1_avg); - if (cfg.channel_count > 1U) { - tty_do1_noise_state.update_last_high(1U, ch2_avg); - } - tty_do1_noise_state.mark_high_pending(); - tty_do1_noise_state.finish_step(); - return; - } - - if (!tty_do1_noise_state.has_pending_high(cfg.channel_count)) { - // Skip LOW output unless a preceding HIGH step baseline is pending. - tty_do1_noise_state.finish_step(); - return; - } - - if (tty_do1_noise_state.next_index >= 0xFFFFU) { - fail("TTY protocol step index overflow within packet"); - } - - const double ch1_corrected = ch1_avg - tty_do1_noise_state.last_high(0U); - const double ch2_corrected = - (cfg.channel_count <= 1U) ? 0.0 : (ch2_avg - tty_do1_noise_state.last_high(1U)); - - const uint16_t step_idx = tty_do1_noise_state.next_index; - append_tty_frame((cfg.profile == CaptureProfile::Amplitude) ? 0x001AU : 0x000AU, - step_idx, - static_cast(pack_raw_code_to_int16(ch1_corrected)), - static_cast(pack_raw_code_to_int16(ch2_corrected))); - ++tty_do1_noise_state.next_index; - ++packet_avg_steps; - tty_do1_noise_state.consume_pending_high(); - tty_do1_noise_state.finish_step(); - if (cfg.do8_freq_ref) { - tty_di8_state.last_standard_index = step_idx; - tty_di8_state.last_standard_valid = true; - } - }; - - auto append_tty_do1_pair_subtracted_avg_run = [&]() { - if (!tty_do1_pair_subtract_avg) { - return; - } - if (!tty_do1_pair_state.run_initialized) { - return; - } - - if (!tty_do1_pair_state.has_output()) { - tty_do1_pair_state.clear_run(); - return; - } - - if (tty_do1_pair_state.next_index >= 0xFFFFU) { - fail("TTY protocol step index overflow within packet"); - } - - const uint16_t step_idx = tty_do1_pair_state.next_index; - append_tty_frame(0x000AU, - step_idx, - static_cast(pack_raw_code_to_int16(tty_do1_pair_state.avg_diff_ch1())), - static_cast(pack_raw_code_to_int16(tty_do1_pair_state.avg_diff_ch2()))); - ++tty_do1_pair_state.next_index; - ++packet_avg_steps; - tty_do1_pair_state.clear_run(); - if (cfg.do8_freq_ref) { - tty_di8_state.last_standard_index = step_idx; - tty_di8_state.last_standard_valid = true; - } - }; - - auto append_tty_di8_ref_step = [&]() { - if (!cfg.do8_freq_ref) { - return; - } - if (!tty_di8_state.has_complete_step(cfg.channel_count)) { - ++packet_di8_no_complete; - tty_di8_state.clear_step(); - return; - } - - const double ch1_avg = tty_di8_state.step_average(0U); - const double ch2_avg = (cfg.channel_count <= 1U) ? 0.0 : tty_di8_state.step_average(1U); - - bool exceeds = true; - if (cfg.do8_amplitude_threshold.has_value()) { - const double t = *cfg.do8_amplitude_threshold; - exceeds = (std::abs(ch1_avg) > t) || - ((cfg.channel_count > 1U) && (std::abs(ch2_avg) > t)); - } - - if (!exceeds) { - tty_di8_state.clear_step(); - return; - } - - if (!tty_di8_state.last_standard_valid) { - ++packet_di8_no_standard; - tty_di8_state.clear_step(); - return; - } - - append_tty_frame(kTtyMarkerDi8High, - tty_di8_state.last_standard_index, - static_cast(pack_raw_code_to_int16(ch1_avg)), - static_cast(pack_raw_code_to_int16(ch2_avg))); - ++packet_di8_ref_frames_emitted; - tty_di8_state.clear_step(); - }; - - auto flush_tty_frames = [&]() { - if (!tty_writer || tty_frame_words.empty()) { - return; - } - - tty_writer->enqueue_encoded_frames(tty_frame_words.data(), tty_frame_words.size() / 4U); - tty_frame_words.clear(); - - const auto tty_stats = tty_writer->stats(); - if (!tty_overflow_warning_printed && (tty_stats.ring_overflows != 0U)) { - std::cerr << "Warning: TTY ring buffer overflowed; dropping oldest frames to keep the stream continuous\n"; - tty_overflow_warning_printed = true; - } - }; - - auto start_packet = [&]() { - if (packet_active) { - return; - } - packet_avg_steps = 0; - fast_packet_frames = 0; - packet_clock_count = 0; - packet_di2_high_clocks = 0; - packet_di2_low_clocks = 0; - packet_di8_high_clocks = 0; - packet_di8_low_clocks = 0; - packet_di8_samples_accumulated = 0; - packet_di8_ref_frames_emitted = 0; - packet_di8_no_complete = 0; - packet_di8_no_standard = 0; - packet_di8_both_high = 0; - packet_di8_both_normal = 0; - packet_di8_mixed = 0; - if (!fast_tty_avg_stream_mode) { - current_packet.reset(target_frames, cfg.channel_count); - } - if (tty_di1_group_average) { - tty_group_state.reset_packet(); - append_tty_packet_start(); - } else if (tty_do1_raw_tty_marked) { - tty_do1_raw_state.reset_packet(); - append_tty_packet_start(); - } else if (tty_do1_noise_subtract) { - tty_do1_noise_state.reset_packet(); - append_tty_packet_start(); - } else if (tty_do1_pair_subtract_avg) { - tty_do1_pair_state.reset_packet(); - append_tty_packet_start(); - } - if (cfg.do8_freq_ref) { - tty_di8_state.reset_packet(); - } - packet_active = true; - }; - - auto finish_packet = [&](PacketCloseReason reason) { - if (packet_clock_count != (packet_di2_high_clocks + packet_di2_low_clocks)) { - std::ostringstream message; - message << "DI2 clock split invariant failed: clocks=" << packet_clock_count - << ", high=" << packet_di2_high_clocks - << ", low=" << packet_di2_low_clocks; - fail(message.str()); - } - - if (tty_di1_group_average) { - append_tty_group_step(); - if (cfg.do8_freq_ref) { append_tty_di8_ref_step(); } - tty_group_state.clear_step(); - } else if (tty_do1_raw_tty_marked) { - append_tty_do1_raw_marked_step(); - if (cfg.do8_freq_ref) { append_tty_di8_ref_step(); } - tty_do1_raw_state.clear_step(); - } else if (tty_do1_noise_subtract) { - // Finalize the last DI1 run in packet, drop only incomplete channel tuples. - append_tty_do1_subtracted_step(); - if (cfg.do8_freq_ref) { append_tty_di8_ref_step(); } - tty_do1_noise_state.clear_step(); - } else if (tty_do1_pair_subtract_avg) { - append_tty_do1_pair_subtracted_avg_run(); - if (cfg.do8_freq_ref) { append_tty_di8_ref_step(); } - tty_do1_pair_state.reset_packet(); - } - if (cfg.do8_freq_ref) { - tty_di8_state.clear_step(); - } - - const std::size_t frames = fast_tty_avg_stream_mode - ? fast_packet_frames - : current_packet.frame_count(cfg.channel_count); - if (frames != 0U) { - const double packet_duration_ms = (1000.0 * static_cast(frames)) / actual_frame_freq_hz; - if (fast_tty_avg_stream_mode) { - const std::size_t packet_index = static_cast(total_completed_packets + 1U); - std::cout << std::fixed << std::setprecision(3) - << "Packet " << packet_index - << ": frames/ch=" << frames - << ", duration_ms=" << packet_duration_ms - << ", close_reason=" << packet_close_reason_to_string(reason) - << ", clocks=" << packet_clock_count - << ", di2_high_clocks=" << packet_di2_high_clocks - << ", di2_low_clocks=" << packet_di2_low_clocks; - if (cfg.do8_freq_ref) { - std::cout << ", di8_high_clocks=" << packet_di8_high_clocks - << ", di8_low_clocks=" << packet_di8_low_clocks - << ", di8_pairs=" << packet_di8_samples_accumulated - << ", di8_ref_frames=" << packet_di8_ref_frames_emitted - << ", di8_no_complete=" << packet_di8_no_complete - << ", di8_no_std=" << packet_di8_no_standard - << ", di8_hi=" << packet_di8_both_high - << ", di8_lo=" << packet_di8_both_normal - << ", di8_mix=" << packet_di8_mixed; - } - std::cout << ", avg_steps=" << packet_avg_steps - << "\n"; - } else { - current_packet.channels[0].resize(frames); - if (cfg.channel_count >= 2U) { - current_packet.channels[1].resize(frames); - } else { - current_packet.channels[1].clear(); - } - if (cfg.di1_mode == Di1Mode::Trace) { - current_packet.di1.resize(frames); - } else { - current_packet.di1.clear(); - } - - CapturePacket packet; - packet.packet_index = static_cast(total_completed_packets + 1U); - packet.channel_count = cfg.channel_count; - packet.has_di1_trace = (cfg.di1_mode == Di1Mode::Trace); - packet.ch1 = std::move(current_packet.channels[0]); - packet.ch2 = std::move(current_packet.channels[1]); - packet.di1 = std::move(current_packet.di1); - - const double zeroed_fraction = (current_packet.stored_samples == 0U) - ? 0.0 - : (100.0 * static_cast(current_packet.zeroed_samples) / - static_cast(current_packet.stored_samples)); - - std::cout << std::fixed << std::setprecision(3) - << "Packet " << packet.packet_index - << ": frames/ch=" << frames - << ", duration_ms=" << packet_duration_ms - << ", close_reason=" << packet_close_reason_to_string(reason) - << ", clocks=" << packet_clock_count - << ", di2_high_clocks=" << packet_di2_high_clocks - << ", di2_low_clocks=" << packet_di2_low_clocks; - if (cfg.do8_freq_ref) { - std::cout << ", di8_high_clocks=" << packet_di8_high_clocks - << ", di8_low_clocks=" << packet_di8_low_clocks; - } - if (cfg.di1_mode == Di1Mode::ZeroOnChange) { - std::cout << ", zeroed_on_DI1_change=" << zeroed_fraction << "% (" - << current_packet.zeroed_samples << "/" << current_packet.stored_samples << ")"; - } else if (cfg.di1_mode == Di1Mode::Trace) { - std::cout << ", di1_trace_frames=" << packet.di1.size(); - } - std::cout << "\n"; - - writer->append_csv_packet(packet); - packets.push_back(std::move(packet)); - if ((cfg.svg_history_packets != 0U) && (packets.size() > cfg.svg_history_packets)) { - packets.pop_front(); - } - - const double elapsed_capture_s = - std::max(1e-9, static_cast(tick_count_ms() - capture_loop_start) / 1000.0); - const double packets_per_second = - (elapsed_capture_s >= 0.1) - ? (static_cast(total_completed_packets + 1U) / elapsed_capture_s) - : 0.0; - const TickMs now = tick_count_ms(); - const bool should_update_live = - (cfg.live_update_period_ms == 0U) || - (last_live_update == 0U) || - ((now - last_live_update) >= cfg.live_update_period_ms); - if (should_update_live) { - writer->update_live_plot(packets.back(), - static_cast(total_completed_packets + 1U), - packets_per_second, - actual_frame_freq_hz, - packet_close_reason_to_string(reason), - current_packet.zeroed_samples, - current_packet.stored_samples); - last_live_update = now; - } - } - - ++total_completed_packets; - ++stats_completed_packets; - - const double elapsed_capture_s = - std::max(1e-9, static_cast(tick_count_ms() - capture_loop_start) / 1000.0); - const double packets_per_second = - (elapsed_capture_s >= 0.1) - ? (static_cast(total_completed_packets) / elapsed_capture_s) - : 0.0; - std::cout << std::fixed << std::setprecision(3) - << " packets/s(avg)=" << packets_per_second; - if (elapsed_capture_s < 0.1) { - std::cout << " (warming up)"; - } - std::cout << "\n"; - } - - // A real sweep just completed: step the switch sequence into the gap. A - // user-stop teardown is not a sweep boundary, so skip it. - if (sequencer && (frames != 0U) && (reason != PacketCloseReason::UserStop)) { - sequencer->on_sweep_end(current_backlog_ms); - } - - packet_active = false; - packet_avg_steps = 0; - fast_packet_frames = 0; - packet_clock_count = 0; - packet_di2_high_clocks = 0; - packet_di2_low_clocks = 0; - packet_di8_high_clocks = 0; - packet_di8_low_clocks = 0; - packet_di8_samples_accumulated = 0; - packet_di8_ref_frames_emitted = 0; - packet_di8_no_complete = 0; - packet_di8_no_standard = 0; - packet_di8_both_high = 0; - packet_di8_both_normal = 0; - packet_di8_mixed = 0; - if (!fast_tty_avg_stream_mode) { - current_packet.reset(target_frames, cfg.channel_count); - } - if (tty_di1_group_average) { - tty_group_state.clear_step(); - } else if (tty_do1_raw_tty_marked) { - tty_do1_raw_state.clear_step(); - } else if (tty_do1_noise_subtract) { - tty_do1_noise_state.clear_step(); - } else if (tty_do1_pair_subtract_avg) { - tty_do1_pair_state.reset_packet(); - } - }; - - while (!stop_loop_requested) { - if (tty_writer) { - tty_writer->throw_if_failed(); - } - if ((cfg.packet_limit != 0U) && (total_completed_packets >= cfg.packet_limit)) { - stop_loop_requested = true; - break; - } - if (console_stop_requested()) { - if (packet_active) { - finish_packet(PacketCloseReason::UserStop); - flush_tty_frames(); - } - stop_loop_requested = true; - break; - } - - uint32_t recv_request_words = cfg.recv_block_words; - uint32_t recv_timeout_ms = cfg.recv_timeout_ms; - uint32_t ready_words = 0; - const int32_t ready_err = api.GetRecvReadyCount(device.hnd, &ready_words); - if ((ready_err == X502_ERR_OK) && (ready_words != 0U)) { - recv_request_words = std::min(ready_words, read_capacity_words); - recv_timeout_ms = 0; - } - // Backlog already queued in the driver = how far behind real time we are. - // Drives the switch sequencer's in-gap clean/dirty decision. - if ((ready_err == X502_ERR_OK) && (combined_input_rate_hz > 0.0)) { - current_backlog_ms = 1000.0 * static_cast(ready_words) / combined_input_rate_hz; - } - - const int32_t recvd = api.Recv(device.hnd, raw.data(), recv_request_words, recv_timeout_ms); - if (recvd < 0) { - fail("X502_Recv failed: " + x502_error(api, recvd)); - } - if (recvd > 0) { - total_raw_words += static_cast(recvd); - stats_raw_words += static_cast(recvd); - } - if (recvd == 0) { - if (!capture_started && (tick_count_ms() >= start_wait_deadline)) { - std::ostringstream message; - message << "Timeout before first ADC data. start=" - << sync_mode_to_string(cfg.sync_start_mode, true) - << ", clock=" << sync_mode_to_string(cfg.sync_mode, false) << ". "; - - if (cfg.sync_start_mode == X502_SYNC_EXTERNAL_MASTER) { - message << "With start:start_in the module waits for START_IN after StreamsStart(); " - "until that condition occurs the external clock is ignored. "; - } else if (cfg.sync_start_mode == X502_SYNC_INTERNAL) { - message << "Start is immediate, so this usually means there is no valid external clock on the selected clock line. "; - } else { - message << "This usually means the selected start condition did not occur after StreamsStart(), " - "or no valid external clock arrived afterwards. "; - } - - message << "For a quick clock-only check, try start:immediate. " - "If you use a separate start pulse, it must arrive after the program starts waiting. " - "You can also increase start_wait_ms."; - fail(message.str()); - } - continue; - } - - uint32_t adc_count = fast_tty_avg_stream_mode ? static_cast(adc_raw_buffer.size()) - : static_cast(adc_buffer.size()); - uint32_t din_count = static_cast(din_buffer.size()); - const uint32_t process_flags = - fast_tty_avg_stream_mode ? 0U : static_cast(X502_PROC_FLAGS_VOLT); - const int32_t process_err = api.ProcessData(device.hnd, - raw.data(), - static_cast(recvd), - process_flags, - fast_tty_avg_stream_mode ? adc_raw_buffer.data() : adc_buffer.data(), - &adc_count, - din_buffer.data(), - &din_count); - if (process_err == X502_ERR_STREAM_OVERFLOW) { - std::ostringstream message; - message << "Process ADC+DIN data: " << x502_error(api, process_err) - << ". Current ADC rate=" << actual_sample_clock_hz - << " Hz, DIN rate=" << actual_din_freq_hz - << " Hz, combined input=" << combined_input_rate_hz - << " words/s. Try larger recv_block/buffer_words/step_words, a longer " - << "live_update_period_ms, or a lower sample_clock_hz / DIN load."; - fail(message.str()); - } - expect_ok(api, process_err, "Process ADC+DIN data"); - - uint32_t raw_adc_count = fast_tty_avg_stream_mode ? adc_count : 0U; - if (tty_writer && !fast_tty_avg_stream_mode) { - for (std::size_t i = 0; i < static_cast(recvd); ++i) { - double raw_code = 0.0; - if (!try_extract_raw_adc_code(raw[i], raw_code)) { - continue; - } - if (raw_adc_count >= adc_raw_buffer.size()) { - fail("Raw ADC parsing overflowed the temporary buffer"); - } - adc_raw_buffer[raw_adc_count++] = raw_code; - } - if (raw_adc_count != adc_count) { - fail("Raw ADC parsing returned a different sample count than voltage processing"); - } - - if (!tty_di1_group_average) { - tty_frame_words.clear(); - const std::size_t tty_frame_count = - (cfg.channel_count <= 1U) - ? static_cast(raw_adc_count) - : ((static_cast(raw_adc_count) + 1U) / 2U); - tty_frame_words.reserve(tty_frame_count * 4U); - for (uint32_t i = 0; i < raw_adc_count; ++i) { - const int16_t sample = pack_raw_code_to_int16(adc_raw_buffer[i]); - if (cfg.channel_count <= 1U) { - append_tty_frame( (cfg.profile == CaptureProfile::Amplitude) ? 0x001AU : 0x000AU, - tty_state.next_index, - static_cast(sample), - 0U); - if (tty_state.next_index >= 0xFFFEU) { - tty_state.next_index = 1U; - } else { - ++tty_state.next_index; - } - continue; - } - if (!tty_state.pending_ch1_valid) { - tty_state.pending_ch1 = sample; - tty_state.pending_ch1_valid = true; - continue; - } - - append_tty_frame( (cfg.profile == CaptureProfile::Amplitude) ? 0x001AU : 0x000AU, - tty_state.next_index, - static_cast(tty_state.pending_ch1), - static_cast(sample)); - tty_state.pending_ch1_valid = false; - - if (tty_state.next_index >= 0xFFFEU) { - tty_state.next_index = 1U; - } else { - ++tty_state.next_index; - } - } - flush_tty_frames(); - } - } - - if ((adc_count == 0U) && (din_count == 0U) && (!tty_writer || (raw_adc_count == 0U))) { - continue; - } - - capture_started = capture_started || (adc_count != 0U); - total_adc_samples += adc_count; - total_din_samples += din_count; - stats_adc_samples += adc_count; - stats_din_samples += din_count; - if (fast_tty_avg_stream_mode) { - for (uint32_t i = 0; i < raw_adc_count; ++i) { - pending_adc_raw.push_back(adc_raw_buffer[i]); - } - } else { - for (uint32_t i = 0; i < adc_count; ++i) { - pending_adc.push_back(adc_buffer[i]); - } - } - for (uint32_t i = 0; i < din_count; ++i) { - pending_din.push_back(din_buffer[i]); - } - - if (((!fast_tty_avg_stream_mode) && (pending_adc.size() > 1000000U)) || - (pending_din.size() > 1000000U) || - (fast_tty_avg_stream_mode && (pending_adc_raw.size() > 1000000U))) { - fail("Internal backlog grew too large while aligning ADC and DIN samples"); - } - - while ((fast_tty_avg_stream_mode ? !pending_adc_raw.empty() : !pending_adc.empty()) && - !pending_din.empty() && - !stop_loop_requested) { - const double adc_value = fast_tty_avg_stream_mode ? 0.0 : pending_adc.front(); - if (!fast_tty_avg_stream_mode) { - pending_adc.pop_front(); - } - const double adc_raw_value = fast_tty_avg_stream_mode ? pending_adc_raw.front() : 0.0; - if (fast_tty_avg_stream_mode) { - pending_adc_raw.pop_front(); - } - - const uint32_t din_value = pending_din.front(); - pending_din.pop_front(); - // Keep ADC logical-channel phase aligned even when samples are skipped outside packet windows. - const uint32_t lch = next_lch; - next_lch = (next_lch + 1U) % cfg.channel_count; - - const bool di1_level = (din_value & kE502Digital1Mask) != 0U; - bool di1_changed = false; - if (!di1_initialized) { - di1_prev_level = di1_level; - di1_initialized = true; - } else if (di1_level != di1_prev_level) { - di1_changed = true; - di1_prev_level = di1_level; - } - - const bool di_syn2_level = (din_value & kE502DiSyn2Mask) != 0U; - bool start_edge = false; - bool stop_edge = false; - if (!trigger_level_initialized) { - trigger_prev_level = di_syn2_level; - trigger_level_initialized = true; - if (!packet_active) { - if (cfg.sync_start_mode == X502_SYNC_INTERNAL) { - start_packet(); - } else if (sync_uses_di_syn2(cfg.sync_start_mode) && di_syn2_level) { - start_packet(); - } else if (!sync_uses_di_syn2(cfg.sync_start_mode)) { - start_packet(); - } - } - } else { - start_edge = matches_sync_edge(cfg.sync_start_mode, trigger_prev_level, di_syn2_level); - if (cfg.stop_mode != StopMode::TargetFrames) { - stop_edge = matches_stop_edge(cfg.stop_mode, trigger_prev_level, di_syn2_level); - } - trigger_prev_level = di_syn2_level; - } - if (!packet_active && (cfg.sync_start_mode == X502_SYNC_INTERNAL)) { - start_packet(); - } - - if (packet_active && start_edge) { - finish_packet(PacketCloseReason::ExternalStopEdge); - if ((cfg.packet_limit != 0U) && (total_completed_packets >= cfg.packet_limit)) { - stop_loop_requested = true; - continue; - } - start_packet(); - } else if (!packet_active && start_edge) { - start_packet(); - } - - if (packet_active && stop_edge) { - finish_packet(PacketCloseReason::ExternalStopEdge); - if ((cfg.packet_limit != 0U) && (total_completed_packets >= cfg.packet_limit)) { - stop_loop_requested = true; - } - continue; - } - - if (!packet_active) { - continue; - } - - const bool di2_level = (din_value & kE502Digital2Mask) != 0U; - if (di2_level) { - ++packet_di2_high_clocks; - } else { - ++packet_di2_low_clocks; - } - ++packet_clock_count; - - if (cfg.do8_freq_ref) { - const bool di8_raw = (din_value & kE502Digital8Mask) != 0U; - if (di8_raw) { - ++packet_di8_high_clocks; - } else { - ++packet_di8_low_clocks; - } - } - - const bool di8_level = cfg.do8_freq_ref - ? ((din_value & kE502Digital8Mask) != 0U) - : false; - const bool di8_settled_high = cfg.do8_freq_ref - ? tty_di8_state.update_di8(di8_level) - : false; - const bool di8_in_guard = cfg.do8_freq_ref && tty_di8_state.in_guard; - - if (tty_di1_group_average && di1_changed) { - append_tty_group_step(); - if (cfg.do8_freq_ref) { append_tty_di8_ref_step(); tty_di8_state.clear_step(); } - tty_group_state.clear_step(); - } else if (tty_do1_raw_tty_marked) { - if (!tty_do1_raw_state.step_level_initialized) { - tty_do1_raw_state.start_new_step(di1_level); - } else if (di1_changed) { - append_tty_do1_raw_marked_step(); - if (cfg.do8_freq_ref) { append_tty_di8_ref_step(); tty_di8_state.clear_step(); } - tty_do1_raw_state.start_new_step(di1_level); - } - } else if (tty_do1_noise_subtract) { - if (!tty_do1_noise_state.step_level_initialized) { - tty_do1_noise_state.start_new_step(di1_level); - } else if (di1_changed) { - append_tty_do1_subtracted_step(); - if (cfg.do8_freq_ref) { append_tty_di8_ref_step(); tty_di8_state.clear_step(); } - tty_do1_noise_state.start_new_step(di1_level); - } - } - - if (!fast_tty_avg_stream_mode && - (cfg.di1_mode == Di1Mode::Trace) && - ((cfg.channel_count <= 1U) || (lch == 0U))) { - current_packet.pending_frame_di1 = static_cast(di1_level ? 1U : 0U); - current_packet.pending_frame_di1_valid = true; - } - - if (fast_tty_avg_stream_mode) { - if (fast_packet_frames < target_frames) { - if (tty_do1_pair_subtract_avg) { - if (lch == 0U) { - tty_do1_pair_state.add_pending_ch1(adc_raw_value, di1_level, di2_level, - di8_level); - } else if (lch == 1U) { - if (tty_do1_pair_state.pending_ch1_valid) { - const bool pair_di1_matches = - (tty_do1_pair_state.pending_ch1_di1_high == di1_level); - const bool pair_di2_matches = - (tty_do1_pair_state.pending_ch1_di2_high == di2_level); - // Use raw DI8 level for pair matching (not guard-filtered). - // Guard-based filtering consumed ALL settled HIGH samples - // due to per-DIN-sample guard counting vs per-DOUT-tick transitions. - // Mixed-level pairs (transition boundaries) are still discarded. - const bool both_di8_high = - tty_do1_pair_state.pending_ch1_di8_high && di8_level; - const bool both_di8_normal = - !tty_do1_pair_state.pending_ch1_di8_high && !di8_level; - - if (pair_di1_matches && pair_di2_matches) { - if (both_di8_high) { - tty_di8_state.add_sample(0U, tty_do1_pair_state.pending_ch1_raw); - tty_di8_state.add_sample(1U, adc_raw_value); - ++packet_di8_samples_accumulated; - ++packet_di8_both_high; - } else if (both_di8_normal) { - ++packet_di8_both_normal; - if (!tty_do1_pair_state.run_initialized) { - tty_do1_pair_state.start_run(di1_level); - } else if (tty_do1_pair_state.run_di1_high != di1_level) { - append_tty_do1_pair_subtracted_avg_run(); - if (cfg.do8_freq_ref) { append_tty_di8_ref_step(); tty_di8_state.clear_step(); } - tty_do1_pair_state.start_run(di1_level); - } - - tty_do1_pair_state.add_pair_sample(di2_level, - tty_do1_pair_state.pending_ch1_raw, - adc_raw_value); - } else { - ++packet_di8_mixed; // mixed DI8 pair -- discard - } - } - } - tty_do1_pair_state.clear_pending_ch1(); - } else { - tty_do1_pair_state.clear_pending_ch1(); - } - } else if (di8_settled_high) { - tty_di8_state.add_sample(lch, adc_raw_value); - } else if (!di8_in_guard) { - if (tty_do1_noise_subtract) { - tty_do1_noise_state.add_sample(lch, adc_raw_value); - } else if (tty_do1_raw_tty_marked) { - tty_do1_raw_state.add_sample(lch, adc_raw_value); - } else { - tty_group_state.add_sample(lch, adc_raw_value); - } - } - if (lch == (cfg.channel_count - 1U)) { - ++fast_packet_frames; - ++total_completed_frames; - ++stats_completed_frames; - } - } - } else { - double stored_value = adc_value; - if ((cfg.di1_mode == Di1Mode::ZeroOnChange) && di1_changed) { - stored_value = 0.0; - ++total_zeroed_samples; - ++stats_zeroed_samples; - ++current_packet.zeroed_samples; - } - - if (current_packet.channels[lch].size() < target_frames) { - current_packet.channels[lch].push_back(stored_value); - ++current_packet.stored_samples; - ++total_stored_adc_samples; - ++stats_stored_adc_samples; - if (lch == (cfg.channel_count - 1U)) { - if ((cfg.di1_mode == Di1Mode::Trace) && - current_packet.pending_frame_di1_valid && - (current_packet.di1.size() < target_frames)) { - current_packet.di1.push_back(current_packet.pending_frame_di1); - current_packet.pending_frame_di1_valid = false; - } - ++total_completed_frames; - ++stats_completed_frames; - } - } - } - - const std::size_t completed_frames = fast_tty_avg_stream_mode - ? fast_packet_frames - : current_packet.frame_count(cfg.channel_count); - if (completed_frames >= target_frames) { - finish_packet(PacketCloseReason::DurationLimit); - if ((cfg.packet_limit != 0U) && (total_completed_packets >= cfg.packet_limit)) { - stop_loop_requested = true; - } - } - } - - flush_tty_frames(); - - if (console_stop_requested()) { - if (packet_active) { - finish_packet(PacketCloseReason::UserStop); - } - flush_tty_frames(); - stop_loop_requested = true; - } - - if ((cfg.stats_period_ms != 0U) && ((tick_count_ms() - last_stats_print) >= cfg.stats_period_ms)) { - print_stats(false); - } - } - - if (cfg.do1_toggle_per_frame) { - expect_ok(api, api.OutCycleStop(device.hnd, X502_OUT_CYCLE_FLAGS_WAIT_DONE), "Stop cyclic DOUT pattern"); - } - - expect_ok(api, api.StreamsStop(device.hnd), "Stop streams"); - device.streams_started = false; - - // Park the switches in their safe default and release the GPIO lines. - if (sequencer) { - sequencer->close(); - } - if (cfg.do1_toggle_per_frame) { - const uint32_t clear_mask = kE502Do1Mask | kE502Do2Mask | (cfg.do8_freq_ref ? kE502Do8Mask : 0U); - expect_ok(api, - api.AsyncOutDig(device.hnd, kE502Do2Mask, ~clear_mask), - "Force DO1/DO8 LOW and keep DO2 HIGH after cyclic DOUT stop"); - } else { - expect_ok(api, api.AsyncOutDig(device.hnd, kE502Do2Mask, ~kE502Do2Mask), "Keep DO2 HIGH after streams stop"); - } - - if ((cfg.stats_period_ms != 0U) && ((stats_raw_words != 0U) || (stats_adc_samples != 0U) || - (stats_din_samples != 0U) || (stats_stored_adc_samples != 0U) || (stats_zeroed_samples != 0U) || - (stats_completed_frames != 0U))) { - print_stats(false); - } - - TtyProtocolWriter::StatsSnapshot tty_final_stats {}; - if (tty_writer) { - tty_writer->shutdown(); - tty_writer->throw_if_failed(); - tty_final_stats = tty_writer->stats(); - } - - if (!fast_tty_avg_stream_mode) { - std::cout << "Captured " << total_completed_packets << " packet(s) in capture mode\n"; - if (cfg.di1_mode == Di1Mode::ZeroOnChange) { - std::cout << "ADC samples forced to 0 on DI1 change: " << total_zeroed_samples << "\n"; - } else if (cfg.di1_mode == Di1Mode::Trace) { - std::cout << "DI1 synchronous trace exported to live plot\n"; - } - } else { - std::cout << "Captured " << total_completed_packets - << " packet(s) in tty fast stream-only mode (" << fast_tty_mode_name << ")\n"; - } - std::cout << "Average stats: " - << "MB/s=" << std::fixed << std::setprecision(3) - << ((static_cast(total_raw_words) * sizeof(uint32_t)) / - std::max(1e-9, static_cast(tick_count_ms() - capture_loop_start) / 1000.0) / - 1000.0 / 1000.0) - << ", ADC samples/s=" - << (static_cast(total_adc_samples) / - std::max(1e-9, static_cast(tick_count_ms() - capture_loop_start) / 1000.0)) - << ", DIN samples/s=" - << (static_cast(total_din_samples) / - std::max(1e-9, static_cast(tick_count_ms() - capture_loop_start) / 1000.0)) - << ", frames/s per channel=" - << (static_cast(total_completed_frames) / - std::max(1e-9, static_cast(tick_count_ms() - capture_loop_start) / 1000.0)) - << ", packets/s=" - << (static_cast(total_completed_packets) / - std::max(1e-9, static_cast(tick_count_ms() - capture_loop_start) / 1000.0)) - << ", packets captured=" << total_completed_packets; - if (!fast_tty_avg_stream_mode) { - if (cfg.di1_mode == Di1Mode::ZeroOnChange) { - std::cout << ", zeroed on DI1 change=" - << ((total_stored_adc_samples == 0U) - ? 0.0 - : (100.0 * static_cast(total_zeroed_samples) / static_cast(total_stored_adc_samples))) - << "% (" << total_zeroed_samples << "/" << total_stored_adc_samples << ")"; - } else if (cfg.di1_mode == Di1Mode::Trace) { - std::cout << ", DI1 trace=enabled"; - } - } - if (tty_writer) { - std::cout << ", tty_frames_written=" << tty_final_stats.frames_written - << ", tty_frames_dropped=" << tty_final_stats.frames_dropped - << ", tty_ring_overflows=" << tty_final_stats.ring_overflows; - } - if (fast_tty_avg_stream_mode) { - std::cout << ", tty fast stream-only mode=" << fast_tty_mode_name; - } - std::cout << "\n"; - - return 0; -} - -} // namespace - -int main(int argc, char** argv) { - try { - const Config cfg = parse_args(argc, argv); - return run(cfg); - } catch (const std::bad_alloc&) { - std::cerr << "Error: bad allocation. The process ran out of RAM. " - "Try a smaller duration_ms, fewer logical channels, or a lower svg_history_packets " - "(for example svg_history_packets:10 or svg_history_packets:0 only if you really need all packets in SVG).\n"; - return 1; - } catch (const std::exception& ex) { - std::cerr << "Error: " << ex.what() << "\n"; - return 1; - } -} diff --git a/data_acq_and_processing/kamil_adc_collector/src/tty_protocol_writer.cpp b/data_acq_and_processing/kamil_adc_collector/src/tty_protocol_writer.cpp deleted file mode 100644 index 49ba20b..0000000 --- a/data_acq_and_processing/kamil_adc_collector/src/tty_protocol_writer.cpp +++ /dev/null @@ -1,469 +0,0 @@ -#include "tty_protocol_writer.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#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 -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -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::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 read_link_target(const std::string& path) { - std::array 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(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(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 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(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 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(ch1_avg), - static_cast(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 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(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 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 lock(impl_->mutex); - failure = impl_->failure; - } - - if (failure) { - std::rethrow_exception(failure); - } -} - -void TtyProtocolWriter::shutdown() { - if (!impl_) { - return; - } - - { - std::lock_guard 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 write_batch; - std::size_t batch_count = 0; - { - std::unique_lock 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(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 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 lock(impl_->mutex); - if (impl_->stop_requested) { - return; - } - } - std::this_thread::sleep_for(std::chrono::milliseconds(5)); - continue; - } - - std::lock_guard 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 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(written); - remaining -= static_cast(written); - } - - { - std::lock_guard lock(impl_->mutex); - impl_->stats.frames_written += static_cast(batch_count); - } - } -} - -#endif diff --git a/data_acq_and_processing/processing/processors/src/gpr_legacy_processor.ipp b/data_acq_and_processing/processing/processors/src/gpr_legacy_processor.ipp index 0d398e3..46072f4 100644 --- a/data_acq_and_processing/processing/processors/src/gpr_legacy_processor.ipp +++ b/data_acq_and_processing/processing/processors/src/gpr_legacy_processor.ipp @@ -23,7 +23,7 @@ // * Matrix radars (`librevna_multi`) fire one Tx at a time and // receive on all Rx channels in parallel. A run of 8 traces is just 2 // Tx-events; all (tx_k, *) pairs share one timestamp. -// * Sequential radars (single librevna with switches, kamil_adc) +// * Sequential radars (single librevna with switches, rph_radar) // measure each pair separately. A run of 8 traces is 8 separate sweeps; // every pair has its own timestamp. // Both cases reduce to: there are N events in the frame, each event lasts diff --git a/data_acq_and_processing/rph_radar_collector/README.md b/data_acq_and_processing/rph_radar_collector/README.md new file mode 100644 index 0000000..6b59345 --- /dev/null +++ b/data_acq_and_processing/rph_radar_collector/README.md @@ -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:` from `radar.rph_radar.tty_path`. + +When both RF switches use `driver_mode: "native"`, Python passes +`switching:` 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. diff --git a/data_acq_and_processing/rph_radar_collector/include/switch_sequencer.h b/data_acq_and_processing/rph_radar_collector/include/switch_sequencer.h new file mode 100644 index 0000000..9c462ec --- /dev/null +++ b/data_acq_and_processing/rph_radar_collector/include/switch_sequencer.h @@ -0,0 +1,98 @@ +#pragma once + +#include +#include +#include +#include +#include + +#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 combos{}; + /// RF settling allowance after both GPIO writes complete. + double settle_ms = 1.0; + }; + + SwitchSequencer( + Settings settings, + std::unique_ptr input_switch, + std::unique_ptr 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::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()) + + static_cast(settings_.settle_ms * 1e6); + } + + Settings settings_; + std::unique_ptr input_switch_; + std::unique_ptr 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 diff --git a/data_acq_and_processing/kamil_adc_collector/include/tty_protocol_writer.h b/data_acq_and_processing/rph_radar_collector/include/tty_protocol_writer.h similarity index 74% rename from data_acq_and_processing/kamil_adc_collector/include/tty_protocol_writer.h rename to data_acq_and_processing/rph_radar_collector/include/tty_protocol_writer.h index c97e92b..cc25169 100644 --- a/data_acq_and_processing/kamil_adc_collector/include/tty_protocol_writer.h +++ b/data_acq_and_processing/rph_radar_collector/include/tty_protocol_writer.h @@ -4,6 +4,7 @@ #include #include #include +#include class TtyProtocolWriter { public: @@ -21,9 +22,12 @@ public: TtyProtocolWriter(TtyProtocolWriter&& other) noexcept = delete; TtyProtocolWriter& operator=(TtyProtocolWriter&& other) noexcept = delete; - void emit_packet_start(uint16_t marker = 0x000A); - void emit_step(uint16_t index, int16_t ch1_avg, int16_t ch2_avg); - void enqueue_encoded_frames(const uint16_t* words, std::size_t frame_count); + struct SweepMetadata { + std::uint64_t source_id, cycle_id, capture_start_ns, capture_end_ns; + std::uint16_t input = 0xFFFF, output = 0xFFFF; + bool dirty = false; + }; + void enqueue_sweep(const std::vector& words, const SweepMetadata& metadata); StatsSnapshot stats() const; const std::string& path() const; @@ -31,7 +35,6 @@ public: void shutdown(); private: - void enqueue_frame(uint16_t word0, uint16_t word1, uint16_t word2, uint16_t word3); void worker_loop(); std::string path_; diff --git a/data_acq_and_processing/rph_radar_collector/src/main.cpp b/data_acq_and_processing/rph_radar_collector/src/main.cpp new file mode 100644 index 0000000..2c170ea --- /dev/null +++ b/data_acq_and_processing/rph_radar_collector/src/main.cpp @@ -0,0 +1,530 @@ +#include "x502api.h" +#include "e502api.h" +#include "tty_protocol_writer.h" +#include "switch_sequencer.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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::chrono::duration_cast( + 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(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 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(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:"); } + 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 +Fn symbol(void* library, const char* name) { + auto fn = reinterpret_cast(::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(x502, "X502_Create"); + decltype(&X502_Free) Free = symbol(x502, "X502_Free"); + decltype(&X502_Close) Close = symbol(x502, "X502_Close"); + decltype(&X502_GetErrorString) GetErrorString = symbol(x502, "X502_GetErrorString"); + decltype(&X502_SetMode) SetMode = symbol(x502, "X502_SetMode"); + decltype(&X502_StreamsStop) StreamsStop = symbol(x502, "X502_StreamsStop"); + decltype(&X502_StreamsDisable) StreamsDisable = symbol(x502, "X502_StreamsDisable"); + decltype(&X502_SetSyncMode) SetSyncMode = symbol(x502, "X502_SetSyncMode"); + decltype(&X502_SetSyncStartMode) SetSyncStartMode = symbol(x502, "X502_SetSyncStartMode"); + decltype(&X502_SetLChannelCount) SetLChannelCount = symbol(x502, "X502_SetLChannelCount"); + decltype(&X502_SetLChannel) SetLChannel = symbol(x502, "X502_SetLChannel"); + decltype(&X502_SetAdcFreq) SetAdcFreq = symbol(x502, "X502_SetAdcFreq"); + decltype(&X502_GetAdcFreq) GetAdcFreq = symbol(x502, "X502_GetAdcFreq"); + decltype(&X502_SetDinFreq) SetDinFreq = symbol(x502, "X502_SetDinFreq"); + decltype(&X502_SetOutFreq) SetOutFreq = symbol(x502, "X502_SetOutFreq"); + decltype(&X502_AsyncOutDig) AsyncOutDig = symbol(x502, "X502_AsyncOutDig"); + decltype(&X502_SetRefFreq) SetRefFreq = symbol(x502, "X502_SetRefFreq"); + decltype(&X502_SetStreamBufSize) SetStreamBufSize = symbol(x502, "X502_SetStreamBufSize"); + decltype(&X502_SetStreamStep) SetStreamStep = symbol(x502, "X502_SetStreamStep"); + decltype(&X502_SetDigInPullup) SetDigInPullup = symbol(x502, "X502_SetDigInPullup"); + decltype(&X502_Configure) Configure = symbol(x502, "X502_Configure"); + decltype(&X502_StreamsEnable) StreamsEnable = symbol(x502, "X502_StreamsEnable"); + decltype(&X502_StreamsStart) StreamsStart = symbol(x502, "X502_StreamsStart"); + decltype(&X502_GetRecvReadyCount) GetRecvReadyCount = symbol(x502, "X502_GetRecvReadyCount"); + decltype(&X502_Recv) Recv = symbol(x502, "X502_Recv"); + decltype(&X502_OutCycleLoadStart) OutCycleLoadStart = symbol(x502, "X502_OutCycleLoadStart"); + decltype(&X502_OutCycleSetup) OutCycleSetup = symbol(x502, "X502_OutCycleSetup"); + decltype(&X502_OutCycleStop) OutCycleStop = symbol(x502, "X502_OutCycleStop"); + decltype(&X502_Send) Send = symbol(x502, "X502_Send"); + decltype(&X502_PrepareData) PrepareData = symbol(x502, "X502_PrepareData"); + decltype(&X502_ProcessData) ProcessData = symbol(x502, "X502_ProcessData"); + decltype(&E502_OpenUsb) OpenUsb = symbol(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 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(); + if (mode != "native" && mode != "mock") { fail("Invalid switch driver_mode"); } + return std::make_unique(radar::drivers::H7992MinimalDriverSettings{ + .name = item.at("name").get(), + .mode = mode == "native" ? radar::config::DriverMode::Native : radar::config::DriverMode::Mock, + .positions = item.at("positions").get(), + .default_position = item.at("default_position").get(), + .gpio_chip = item.at("gpio_chip").get(), + .pin_a = item.at("pin_a").get(), .pin_b = item.at("pin_b").get()}); + }; + radar::rph::SwitchSequencer::Settings settings; + settings.settle_ms = cfg.switching.at("settling_ms").get(); + 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() >= input.at("positions").get() || + pair[1].get() >= output.at("positions").get()) { + fail("Switch combination out of range"); + } + settings.combos.push_back({pair[0].get(), pair[1].get()}); + } + if (settings.combos.empty()) { fail("Collector switching requires nonempty combos"); } + return std::make_unique(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(X502_ADC_SCALE_CODE_MAX)); + return static_cast(std::clamp(rounded, -32768, 32767)); +} +struct Do1PairSubtractAverageState { + struct PairSample { + double ch1 = 0.0; + double ch2 = 0.0; + }; + + std::deque low_pairs; + std::deque 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(diff_count); + } + + double avg_diff_ch2() const { + if (diff_count == 0U) { + return 0.0; + } + return diff_sum_ch2 / static_cast(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 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 raw(kBlockWords), din(kBlockWords); + std::vector adc(kBlockWords); + std::deque pending_adc; + std::deque pending_din; + std::vector words; + Do1PairSubtractAverageState pairs; + std::array 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(pack_raw_code_to_int16(i)), + static_cast(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(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 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(encoded.size() - sent), 50); + if (n <= 0) { fail("Send DOUT cycle failed or timed out: " + std::to_string(n)); } + sent += static_cast(n); + } + expect_ok(api, api.OutCycleSetup(device->hnd, X502_OUT_CYCLE_FLAGS_WAIT_DONE), "Activate DOUT cycle"); + max_frames = std::max(1, static_cast(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(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(static_cast(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(combo.input_pos); + metadata.output = static_cast(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; + } +} diff --git a/data_acq_and_processing/rph_radar_collector/src/tty_protocol_writer.cpp b/data_acq_and_processing/rph_radar_collector/src/tty_protocol_writer.cpp new file mode 100644 index 0000000..3dc3464 --- /dev/null +++ b/data_acq_and_processing/rph_radar_collector/src/tty_protocol_writer.cpp @@ -0,0 +1,317 @@ +#include "tty_protocol_writer.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 read_link_target(const std::string& path) { + std::array 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(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> 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(ring_capacity_bytes)) { + std::array 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& 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 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(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 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 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 lock(impl_->mutex); + failure = impl_->failure; + } + + if (failure) { + std::rethrow_exception(failure); + } +} + +void TtyProtocolWriter::shutdown() { + if (!impl_) { + return; + } + + { + std::lock_guard 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 packet; + { + std::unique_lock 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 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 lock(impl_->mutex); + if (impl_->stop_requested) { + return; + } + } + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + continue; + } + + std::lock_guard 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 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(written); + remaining -= static_cast(written); + } + + { + std::lock_guard lock(impl_->mutex); + impl_->stats.frames_written += (packet.size() - 48U) / 8U; + } + } +} diff --git a/data_acq_and_processing/kamil_adc_collector/vendor/lcard/e502api.h b/data_acq_and_processing/rph_radar_collector/vendor/lcard/e502api.h similarity index 100% rename from data_acq_and_processing/kamil_adc_collector/vendor/lcard/e502api.h rename to data_acq_and_processing/rph_radar_collector/vendor/lcard/e502api.h diff --git a/data_acq_and_processing/kamil_adc_collector/vendor/lcard/lcard_pstdint.h b/data_acq_and_processing/rph_radar_collector/vendor/lcard/lcard_pstdint.h similarity index 100% rename from data_acq_and_processing/kamil_adc_collector/vendor/lcard/lcard_pstdint.h rename to data_acq_and_processing/rph_radar_collector/vendor/lcard/lcard_pstdint.h diff --git a/data_acq_and_processing/kamil_adc_collector/vendor/lcard/x502api.h b/data_acq_and_processing/rph_radar_collector/vendor/lcard/x502api.h similarity index 100% rename from data_acq_and_processing/kamil_adc_collector/vendor/lcard/x502api.h rename to data_acq_and_processing/rph_radar_collector/vendor/lcard/x502api.h diff --git a/data_acq_and_processing/tests/collector_regressions.cpp b/data_acq_and_processing/tests/collector_regressions.cpp new file mode 100644 index 0000000..65a7ab6 --- /dev/null +++ b/data_acq_and_processing/tests/collector_regressions.cpp @@ -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 +#include +#include +#include +#include + + +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 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(file), std::istreambuf_iterator()}; + 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(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 args{"collector", "profile:phase", "clock:internal", "tty:/tmp/unused", + "do1_toggle_per_frame", "do1_pair_subtract_avg", "do8_freq_ref", period}; + std::vector argv; + for (auto& arg : args) { argv.push_back(arg.data()); } + bool rejected = false; + try { (void)parse_args(static_cast(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; + } +} diff --git a/data_acq_and_processing/tests/native_regressions.cpp b/data_acq_and_processing/tests/native_regressions.cpp index 924c2a8..09ce4e0 100644 --- a/data_acq_and_processing/tests/native_regressions.cpp +++ b/data_acq_and_processing/tests/native_regressions.cpp @@ -1,4 +1,3 @@ -#include "capture_file_writer.h" #include "channel_bundle_support.hpp" #include "data_preprocessor.hpp" #include "locator/tcp_server.hpp" @@ -7,6 +6,8 @@ #include "runtime_wait.hpp" #include "shm_ring.hpp" #include "tty_protocol_writer.h" +#include "switch_sequencer.h" +#include #include #include @@ -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(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 corrupt_packet{0, 2, 0xffffffffffffffffULL, 0}; - std::ofstream(spool, std::ios::binary | std::ios::app).write( - reinterpret_cast(corrupt_packet.data()), sizeof(corrupt_packet)); - expect_error([&] { writer.write({}, 1000, 10); }); - require(std::filesystem::exists(spool), "oversized packet count deleted the spool"); -} - void test_asset_reload_preserves_data(const Workspace& workspace) { const auto save = [&](const char* name, float sample) { return save_bundle(workspace, name, {.traces = { @@ -497,10 +464,77 @@ void test_locator_packet_errors() { for (unsigned iteration = 0; iteration < 30; ++iteration) { server.start(); server.stop(); } } +void test_tty_sweep_overflow(const Workspace& workspace) { + TtyProtocolWriter writer((workspace.path / "overflow_tty").string(), 32'096); + const int fd = ::open(writer.path().c_str(), O_RDONLY | O_NONBLOCK); + require(fd >= 0, "Cannot open test PTY"); + std::vector 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 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 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(settings), + std::make_unique(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(settings), + std::make_unique(settings)); + single.open(); single.session_started(); + require(!single.on_sweep_end() && single.cycle_id() == 2 && !single.current_dirty(), + "unchanged combination restarted its source or stopped advancing cycles"); +} + void test_unread_tty_shutdown(const Workspace& workspace) { - TtyProtocolWriter writer((workspace.path / "tty").string(), 160'000); + TtyProtocolWriter writer((workspace.path / "tty").string(), 160'048); const std::vector words(80'000, 42); - writer.enqueue_encoded_frames(words.data(), words.size() / 4U); + writer.enqueue_sweep(words, {1, 1, 100, 200}); std::this_thread::sleep_for(std::chrono::milliseconds(50)); const auto start = std::chrono::steady_clock::now(); writer.shutdown(); @@ -524,9 +558,10 @@ int main(int argc, char** argv) { test_acquisition_identity(); test_interruptible_wait(); test_cosine_notch_taper(); - test_csv_failures(workspace); - test_locator_packet_errors(); + test_tty_sweep_overflow(workspace); + test_switch_backlog(); test_unread_tty_shutdown(workspace); + test_locator_packet_errors(); std::cout << "native regressions passed (10,000 decoder mutations; 20,000 concurrent ring frames)\n"; } catch (const std::exception& error) { std::cerr << "native regression failed: " << error.what() << '\n'; diff --git a/data_acq_and_processing/tests/rph_sdk_fixture.cpp b/data_acq_and_processing/tests/rph_sdk_fixture.cpp new file mode 100644 index 0000000..739a01b --- /dev/null +++ b/data_acq_and_processing/tests/rph_sdk_fixture.cpp @@ -0,0 +1,70 @@ +// SDK contract fixture: Stop retains queued input; only a new descriptor is fresh. +#include "x502api.h" +#include "e502api.h" +#include +#include +#include +#include +#include +#include +#include + +namespace { +struct Session { unsigned id; bool consumed = false; std::vector 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(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(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(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(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; } diff --git a/deploy/update.sh b/deploy/update.sh index d177550..8eeba4a 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -181,7 +181,7 @@ else "${venv}/bin/python" -m pip install -r "${target}/requirements.txt" fi say "building the C++ part" -make -C "${target}" -j"$(nproc)" all kamil_adc_collector +make -C "${target}" -j"$(nproc)" all rph_radar_collector echo "${archive##*/}" > "${target}/RELEASE" trap - ERR diff --git a/device_firmware/librevna_protocol.md b/device_firmware/librevna_protocol.md index 212f150..78019b8 100644 --- a/device_firmware/librevna_protocol.md +++ b/device_firmware/librevna_protocol.md @@ -90,8 +90,9 @@ multi-device: `python_app/hardware_full/librevna_driver/protocol/frame.py`, Структура — `Protocol.hpp:186`: `uint32 ExtRefOuputFreq` плюс битовое поле `AutomaticSwitch` и `UseExternalRef`. -Шлём `output_frequency_hz = 0` и флаг внешней опоры: выход `Ref Out` не -включаем, а устройству принудительно указываем использовать внешние 10 МГц. +Шлём `output_frequency_hz = 0` (выход `Ref Out` не включаем) и `UseExternalRef` из +`multi_device.force_external_reference` профиля; `AutomaticSwitch` всегда 0. Во всех +профилях сейчас `false`, то есть приборы работают от внутренней опоры. Строит `build_reference_settings_payload` (`protocol.py:48`), отправляет `controller.py`. diff --git a/python_app/gui/controllers/config_controller.py b/python_app/gui/controllers/config_controller.py index daa25eb..59f300c 100644 --- a/python_app/gui/controllers/config_controller.py +++ b/python_app/gui/controllers/config_controller.py @@ -31,14 +31,14 @@ _SWEEP_FIELDS = ( ("radar.sweep.power_dbm", "_power_input"), ) _ADC_FIELDS = ( - ("radar.kamil_adc.project_dir", "_adc_project_dir_input"), - ("radar.kamil_adc.executable_path", "_adc_executable_path_input"), - ("radar.kamil_adc.tty_path", "_adc_tty_path_input"), - ("radar.kamil_adc.args", "_adc_args_input"), - ("radar.kamil_adc.env", "_adc_env_input"), - ("radar.kamil_adc.startup_timeout_s", "_adc_startup_timeout_s_input"), - ("radar.kamil_adc.sweep_timeout_s", "_adc_sweep_timeout_s_input"), - ("radar.kamil_adc.stop_timeout_s", "_adc_stop_timeout_s_input"), + ("radar.rph_radar.project_dir", "_adc_project_dir_input"), + ("radar.rph_radar.executable_path", "_adc_executable_path_input"), + ("radar.rph_radar.tty_path", "_adc_tty_path_input"), + ("radar.rph_radar.args", "_adc_args_input"), + ("radar.rph_radar.env", "_adc_env_input"), + ("radar.rph_radar.startup_timeout_s", "_adc_startup_timeout_s_input"), + ("radar.rph_radar.sweep_timeout_s", "_adc_sweep_timeout_s_input"), + ("radar.rph_radar.stop_timeout_s", "_adc_stop_timeout_s_input"), ("radar.laser_control.enabled", "_optical_enabled_checkbox"), ("radar.laser_control.port", "_optical_port_input"), ("radar.laser_control.mode", "_optical_mode_combo"), @@ -50,7 +50,6 @@ _ADC_FIELDS = ( ("radar.laser_control.manual.temp2", "_optical_manual_temp2_input"), ("radar.laser_control.manual.current1", "_optical_manual_current1_input"), ("radar.laser_control.manual.current2", "_optical_manual_current2_input"), - ("radar.laser_control.variation.variation_type", "_optical_variation_type_combo"), ("radar.laser_control.variation.static_temp1", "_optical_static_temp1_input"), ("radar.laser_control.variation.static_temp2", "_optical_static_temp2_input"), ("radar.laser_control.variation.static_current1", "_optical_static_current1_input"), @@ -137,7 +136,7 @@ class ConfigController: return Path(str(self._scalar("_save_path_input"))).expanduser(), str(self._scalar("_save_name_input")), prefix def _read_radar(self, config: RunConfigModel) -> None: - for path, widget_id in (*_SWEEP_FIELDS, *(_ADC_FIELDS if config.is_kamil_adc else ())): + for path, widget_id in (*_SWEEP_FIELDS, *(_ADC_FIELDS if config.is_rph_radar else ())): owner, name = _model_field(config, path) original = getattr(owner, name) value = self._scalar(widget_id) diff --git a/python_app/gui/controllers/pipeline_controller.py b/python_app/gui/controllers/pipeline_controller.py index 9ff7f72..e2567cf 100644 --- a/python_app/gui/controllers/pipeline_controller.py +++ b/python_app/gui/controllers/pipeline_controller.py @@ -21,7 +21,7 @@ from python_app.gui.runtime.constraints import validate_processing_mode_constrai from python_app.gui.runtime.history import build_processor_run_signature, build_run_history_signature from python_app.gui.runtime.pipeline_pause import PipelinePause from python_app.gui.settings_defaults import env_enabled -from python_app.hardware_full.kamil_adc import apply_kamil_adc_laser_control +from python_app.hardware_full.rph_radar.laser import apply_rph_radar_laser_control from python_app.hardware_full.single_radar_service import create_single_radar_service from python_app.models.collection_identity import collection_key, index_collections from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection @@ -331,7 +331,7 @@ class PipelineController: self._prepare_radar(config) logger.info("Radar settings applied: start=%g Hz, stop=%g Hz, points=%s, ifbw=%g Hz, power=%g dBm", config.radar.sweep.start_hz, config.radar.sweep.stop_hz, - "from Kamil ADC stream" if config.is_kamil_adc else config.radar.sweep.points, + "from RadioPhotonic radar (RPH) stream" if config.is_rph_radar else config.radar.sweep.points, config.radar.sweep.if_bandwidth_hz, config.radar.sweep.power_dbm) except InterruptedError: self.operation_cancelled() @@ -344,9 +344,9 @@ class PipelineController: logger.info("Radar pre-configuration skipped (mock mode)") elif config.is_multi_device: logger.info("Multi-device raw producer will configure all LibreVNA devices") - elif config.is_kamil_adc: - applied = apply_kamil_adc_laser_control(config) - logger.info("Kamil ADC laser_control %s", "applied via Apply Radar" if applied else "disabled") + elif config.is_rph_radar: + applied = apply_rph_radar_laser_control(config) + logger.info("RadioPhotonic radar (RPH) laser_control %s", "applied via Apply Radar" if applied else "disabled") else: service = create_single_radar_service(config) try: diff --git a/python_app/gui/controllers/profile_controller.py b/python_app/gui/controllers/profile_controller.py index d505bcb..3781bb4 100644 --- a/python_app/gui/controllers/profile_controller.py +++ b/python_app/gui/controllers/profile_controller.py @@ -134,7 +134,7 @@ class ProfileController: self.state.gui = profile.gui or default_gui_state_for_config(profile.run_config, self._project_root) _remember(self.state, path, self._session, self._log) config, sweep = profile.run_config, profile.run_config.radar.sweep - points = "from Kamil ADC stream" if config.is_kamil_adc else str(sweep.points) + points = "from RadioPhotonic radar (RPH) stream" if config.is_rph_radar else str(sweep.points) self._log.info( f"Config profile saved: path={path}, combos={len(config.combos)}, " f"sweep={sweep.start_hz:g}..{sweep.stop_hz:g} Hz, points={points}, " diff --git a/python_app/gui/controllers/radar_settings_controller.py b/python_app/gui/controllers/radar_settings_controller.py index b01acf4..4df892f 100644 --- a/python_app/gui/controllers/radar_settings_controller.py +++ b/python_app/gui/controllers/radar_settings_controller.py @@ -48,7 +48,7 @@ class RadarSettingsController: def sync_profile(self, combo_mode: str) -> None: self.set_combo_mode(combo_mode) - adc = self._defaults().is_kamil_adc + adc = self._defaults().is_rph_radar self._radar.vna_panel.setVisible(not adc) self._radar.adc_panel.setVisible(adc) self.sync_adc_controls() @@ -79,7 +79,7 @@ class RadarSettingsController: def refresh_limits(self) -> bool: config = self._defaults() - if config.is_kamil_adc: + if config.is_rph_radar: return self.apply_limits(None) service = (LibreVnaService(serial=config.radar.serial or None) if config.is_multi_device else create_single_radar_service(config)) @@ -136,7 +136,10 @@ class RadarSettingsController: def sync_frequency_limits(self) -> bool: try: - bounds = sorted(float(widget.text()) / 1e6 for widget in self._radar.sweep_edits[:2]) + config = self._defaults() + band = config.radar.rph_radar.band + bounds = ([band.start_hz / 1e6, band.stop_hz / 1e6] if config.is_rph_radar else + sorted(float(widget.text()) / 1e6 for widget in self._radar.sweep_edits[:2])) except ValueError: return False changed = False diff --git a/python_app/gui/controllers/sections/processing_section.py b/python_app/gui/controllers/sections/processing_section.py index f69f7b8..081c28e 100644 --- a/python_app/gui/controllers/sections/processing_section.py +++ b/python_app/gui/controllers/sections/processing_section.py @@ -348,6 +348,7 @@ def build_processing_group(config: RunConfigModel, gui: GuiStateModel) -> Proces form.addRow(processing_mode_pages) form.addRow(gpr_common_page) + band = config.radar.rph_radar.band if config.is_rph_radar else config.radar.sweep for binding in GUI_FIELDS: if binding.path[0] == "processing": widget = view.fields[binding.spec.widget] @@ -355,7 +356,7 @@ def build_processing_group(config: RunConfigModel, gui: GuiStateModel) -> Proces raise TypeError("Geometry is not a scalar GUI setting") if binding.path[-1] in {"start_freq_mhz", "stop_freq_mhz"}: assert isinstance(widget, QDoubleSpinBox) - widget.setRange(config.radar.sweep.start_hz / 1e6, config.radar.sweep.stop_hz / 1e6) + widget.setRange(band.start_hz / 1e6, band.stop_hz / 1e6) if binding.spec.live in {"gpr_input_positions", "gpr_output_positions"}: assert isinstance(widget, QLineEdit) widget.setValidator(QRegularExpressionValidator(QRegularExpression(POSITION_CSV_PATTERN), widget)) diff --git a/python_app/gui/controllers/sections/radar_section.py b/python_app/gui/controllers/sections/radar_section.py index b98f723..d68b4da 100644 --- a/python_app/gui/controllers/sections/radar_section.py +++ b/python_app/gui/controllers/sections/radar_section.py @@ -62,8 +62,8 @@ def build_radar_group(config: RunConfigModel) -> RadarView: layout.addWidget(vna_radar_settings_panel) layout.addWidget(adc_radar_settings_panel) - vna_radar_settings_panel.setVisible(not config.is_kamil_adc) - adc_radar_settings_panel.setVisible(config.is_kamil_adc) + vna_radar_settings_panel.setVisible(not config.is_rph_radar) + adc_radar_settings_panel.setVisible(config.is_rph_radar) return RadarView(group=group, fields=view.fields, sweep_edits=(start_hz_input, stop_hz_input, points_input, ifbw_input, power_input), sweep_labels=radar_labels, @@ -80,7 +80,7 @@ def _build_adc_settings_panel(view: SectionView, config: RunConfigModel): layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(8) - adc = config.radar.kamil_adc + adc = config.radar.rph_radar optical = config.radar.laser_control manual = optical.manual variation = optical.variation @@ -110,16 +110,6 @@ def _build_adc_settings_panel(view: SectionView, config: RunConfigModel): optical_manual_current1_input = view.add("_optical_manual_current1_input", QLineEdit(f"{manual.current1:g}")) optical_manual_current2_input = view.add("_optical_manual_current2_input", QLineEdit(f"{manual.current2:g}")) - optical_variation_type_combo = view.add("_optical_variation_type_combo", QComboBox()) - optical_variation_type_combo.addItems( - [ - "CHANGE_CURRENT_LD1", - "CHANGE_CURRENT_LD2", - "CHANGE_TEMPERATURE_LD1", - "CHANGE_TEMPERATURE_LD2", - ] - ) - write_setting(optical_variation_type_combo, variation.variation_type) optical_static_temp1_input = view.add("_optical_static_temp1_input", QLineEdit(f"{variation.static_temp1:g}")) optical_static_temp2_input = view.add("_optical_static_temp2_input", QLineEdit(f"{variation.static_temp2:g}")) optical_static_current1_input = view.add("_optical_static_current1_input", QLineEdit(f"{variation.static_current1:g}")) @@ -166,7 +156,6 @@ def _build_adc_settings_panel(view: SectionView, config: RunConfigModel): optical_variation_panel = build_two_column_form_widget( panel, [ - ("Variation type", optical_variation_type_combo), ("Static temp 1", optical_static_temp1_input), ("Static temp 2", optical_static_temp2_input), ("Static current 1", optical_static_current1_input), @@ -186,7 +175,7 @@ def _build_adc_settings_panel(view: SectionView, config: RunConfigModel): (optical_port_input, optical_mode_combo, optical_pi_coeff1_p_input, optical_pi_coeff1_i_input, optical_pi_coeff2_p_input, optical_pi_coeff2_i_input), (optical_manual_temp1_input, optical_manual_temp2_input, optical_manual_current1_input, optical_manual_current2_input), - (optical_variation_type_combo, optical_static_temp1_input, optical_static_temp2_input, + (optical_static_temp1_input, optical_static_temp2_input, optical_static_current1_input, optical_static_current2_input, optical_min_value_input, optical_max_value_input, optical_step_input, optical_time_step_input, optical_delay_time_input), optical_manual_panel, optical_variation_panel) diff --git a/python_app/gui/runtime/history.py b/python_app/gui/runtime/history.py index 01f37b3..81ccd1a 100644 --- a/python_app/gui/runtime/history.py +++ b/python_app/gui/runtime/history.py @@ -60,18 +60,10 @@ def build_processor_run_signature( locator settings, live-file path and polling interval require a restart. """ combos_signature = tuple((int(combo.input), int(combo.output)) for combo in config.combos) - sweep_points_signature: object = "adc" if config.is_kamil_adc else int(config.radar.sweep.points) return ( - str(config.radar.model), + config.radar_key(), + config.is_multi_device and config.radar.multi_device.force_external_reference, str(config.radar.driver_mode), - str(config.radar.serial), - tuple(str(value) for value in config.radar.multi_device.slave_serials), - bool(config.radar.multi_device.force_external_reference), - float(config.radar.sweep.start_hz), - float(config.radar.sweep.stop_hz), - sweep_points_signature, - float(config.radar.sweep.if_bandwidth_hz), - float(config.radar.sweep.power_dbm), str(config.input_switch.driver_mode), int(config.input_switch.positions), str(config.output_switch.driver_mode), diff --git a/python_app/hardware_full/kamil_adc/__init__.py b/python_app/hardware_full/kamil_adc/__init__.py deleted file mode 100644 index 414caaa..0000000 --- a/python_app/hardware_full/kamil_adc/__init__.py +++ /dev/null @@ -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", -] diff --git a/python_app/hardware_full/kamil_adc/laser.py b/python_app/hardware_full/kamil_adc/laser.py deleted file mode 100644 index 4d19d7d..0000000 --- a/python_app/hardware_full/kamil_adc/laser.py +++ /dev/null @@ -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") diff --git a/python_app/hardware_full/kamil_adc/protocol.py b/python_app/hardware_full/kamil_adc/protocol.py deleted file mode 100644 index e1f0dd0..0000000 --- a/python_app/hardware_full/kamil_adc/protocol.py +++ /dev/null @@ -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(" 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, - ) diff --git a/python_app/hardware_full/laser_control/__init__.py b/python_app/hardware_full/laser_control/__init__.py index 597abdd..efb341a 100644 --- a/python_app/hardware_full/laser_control/__init__.py +++ b/python_app/hardware_full/laser_control/__init__.py @@ -1,3 +1 @@ -"""Vendored laser-control package used by the Kamil ADC hardware path.""" - -__version__ = "1.0.0" +"""Vendored laser-control package used by the RadioPhotonic radar (RPH) hardware path.""" diff --git a/python_app/hardware_full/laser_control/constants.py b/python_app/hardware_full/laser_control/constants.py index e07f7a8..53a270c 100644 --- a/python_app/hardware_full/laser_control/constants.py +++ b/python_app/hardware_full/laser_control/constants.py @@ -9,43 +9,8 @@ laser control board. BAUDRATE = 115200 SERIAL_TIMEOUT_SEC = 1.0 -GET_DATA_TOTAL_LENGTH = 30 # bytes in device DATA response -SEND_PARAMS_TOTAL_LENGTH = 30 # bytes in DECODE_ENABLE command -TASK_ENABLE_COMMAND_LENGTH = 32 # bytes in TASK_ENABLE command - WAIT_AFTER_SEND_SEC = 0.15 # delay after sending a command -# ---- Command codes (as sent to device, already flipped to LE) - -CMD_DECODE_ENABLE = 0x1111 # Set control parameters -CMD_DEFAULT_ENABLE = 0x2222 # Reset device -CMD_TRANS_ENABLE = 0x4444 # Request last data -CMD_REMOVE_FILE = 0x5555 # Delete saved data -CMD_STATE = 0x6666 # Request state -CMD_TASK_ENABLE = 0x7777 # Start a task - -# ---- Error codes from device STATE response (after flipfour) - -STATE_OK = '0000' -STATE_SD_ERR = '0001' # SD Card read/write error -STATE_UART_ERR = '0002' # Command (UART) error -STATE_UART_DECODE_ERR = '0004' # Wrong parameter value -STATE_TEC1_ERR = '0008' # Laser 1 TEC driver overheat -STATE_TEC2_ERR = '0010' # Laser 2 TEC driver overheat -STATE_DEFAULT_ERR = '0020' # System reset error -STATE_REMOVE_ERR = '0040' # File deletion error - -STATE_DESCRIPTIONS = { - STATE_OK: "All ok.", - STATE_SD_ERR: "SD Card reading/writing error (SD_ERR).", - STATE_UART_ERR: "Command error (UART_ERR).", - STATE_UART_DECODE_ERR:"Wrong parameter value error (UART_DECODE_ERR).", - STATE_TEC1_ERR: "Laser 1: TEC driver overheat (TEC1_ERR).", - STATE_TEC2_ERR: "Laser 2: TEC driver overheat (TEC2_ERR).", - STATE_DEFAULT_ERR: "Resetting system error (DEFAULT_ERR).", - STATE_REMOVE_ERR: "File deletion error (REMOVE_ERR).", -} - # ---- Physical / hardware constants (from device_conversion.py) VREF = 2.5 # Reference voltage, Volts @@ -60,26 +25,13 @@ R6 = 56000 # Ohm RREF = 10 # Current-setting resistor, Ohm # (@1550 nm – 28.7 Ohm; @840 nm – 10 Ohm) -# External thermistor divider resistors -R7 = 22000 # Ohm -R8 = 22000 # Ohm -R9 = 5100 # Ohm -R10 = 180000 # Ohm - -# Thermistor Steinhart–Hart B-coefficient (internal / external) +# Thermistor Steinhart–Hart B-coefficient BETA_INTERNAL = 3900 # K -BETA_EXTERNAL = 3455 # K T0_K = 298 # Kelvin (25 °C reference) R0 = 10000 # Ohm (thermistor nominal at 25 °C) -# ADC resolution +# DAC resolution ADC_BITS_16 = 65535 # 2^16 - 1 -ADC_BITS_12 = 4095 # 2^12 - 1 - -# Voltage conversion coefficients -U3V3_COEFF = 1.221e-3 # counts → Volts for 3.3V rail -U5V_COEFF = 1.8315e-3 # counts → Volts for 5V rails -U7V_COEFF = 6.72e-3 # counts → Volts for 7V rail # ---- Operational limits (validated in validators.py) @@ -93,9 +45,6 @@ CURRENT_MAX_MA = 60.0 # Maximum allowed laser current, mA CURRENT_STEP_MIN_MA = 0.002 # Minimum current variation step, mA CURRENT_STEP_MAX_MA = 0.5 # Maximum current variation step, mA -TEMP_STEP_MIN_C = 0.05 # Minimum temperature variation step, °C -TEMP_STEP_MAX_C = 1.0 # Maximum temperature variation step, °C - # Time parameter limits TIME_STEP_MIN_US = 20 # Minimum time step, microseconds TIME_STEP_MAX_US = 100 # Maximum time step, microseconds diff --git a/python_app/hardware_full/laser_control/controller.py b/python_app/hardware_full/laser_control/controller.py index c9ded45..883a48b 100644 --- a/python_app/hardware_full/laser_control/controller.py +++ b/python_app/hardware_full/laser_control/controller.py @@ -1,355 +1,60 @@ -""" -Main laser controller for the laser control module. +"""Board commands with mandatory acknowledgements.""" -Provides a high-level API for controlling dual laser systems. -All input parameters are validated before being sent to the device. -Can be embedded in any Python application without GUI dependencies. -""" - -import time import logging -from typing import Optional, Callable +import time -from .protocol import Protocol, TaskType as ProtoTaskType -from .validators import ParameterValidator -from .models import ( - VariationType, - Measurements, -) -from .exceptions import ( - CommunicationError, - DeviceNotRespondingError, -) from .constants import WAIT_AFTER_SEND_SEC +from .exceptions import CommunicationError +from .protocol import Protocol logger = logging.getLogger(__name__) - -# Default PI regulator coefficients (match firmware defaults) -DEFAULT_PI_P = 2560 # 10 * 256 -DEFAULT_PI_I = 128 # 0.5 * 256 +DEFAULT_PI_P = 2560 +DEFAULT_PI_I = 128 DEVICE_MAIN_MESSAGE_ID = 0x00FF class LaserController: - """ - High-level controller for the dual laser board. + """Control constant setpoints or LD1 current variation through one serial port.""" - Usage example:: - - ctrl = LaserController(port='/dev/ttyUSB0') - ctrl.connect() - ctrl.set_manual_mode(temp1=25.0, temp2=30.0, - current1=40.0, current2=35.0) - data = ctrl.get_measurements() - print(data.voltage_3v3) - ctrl.disconnect() - - All public methods raise :class:`ValidationError` for bad parameters - and :class:`CommunicationError` for transport-level problems. - """ - - def __init__( - self, - port: Optional[str] = None, - pi_coeff1_p: int = DEFAULT_PI_P, - pi_coeff1_i: int = DEFAULT_PI_I, - pi_coeff2_p: int = DEFAULT_PI_P, - pi_coeff2_i: int = DEFAULT_PI_I, - on_data: Optional[Callable[[Measurements], None]] = None, - ): - """ - Args: - port: Serial port (e.g. '/dev/ttyUSB0'). None = auto-detect. - pi_coeff1_p: Proportional coefficient for laser 1 PI regulator. - pi_coeff1_i: Integral coefficient for laser 1 PI regulator. - pi_coeff2_p: Proportional coefficient for laser 2 PI regulator. - pi_coeff2_i: Integral coefficient for laser 2 PI regulator. - on_data: Optional callback called whenever new measurements - are received. Signature: ``callback(Measurements)``. - """ + def __init__(self, port: str, + pi_coeff1_p: int = DEFAULT_PI_P, pi_coeff1_i: int = DEFAULT_PI_I, + pi_coeff2_p: int = DEFAULT_PI_P, pi_coeff2_i: int = DEFAULT_PI_I): self._protocol = Protocol(port) - self._pi1_p = pi_coeff1_p - self._pi1_i = pi_coeff1_i - self._pi2_p = pi_coeff2_p - self._pi2_i = pi_coeff2_i - self._on_data = on_data - self._message_id = 0 - self._last_measurements: Optional[Measurements] = None - # Last manual-mode params, used to restore state after stop_task() - self._last_temp1: float = 25.0 - self._last_temp2: float = 25.0 - self._last_current1: float = 30.0 - self._last_current2: float = 30.0 - - # ---- Connection ------------------------------------------------------- + self._pi = dict(pi_coeff1_p=pi_coeff1_p, pi_coeff1_i=pi_coeff1_i, + pi_coeff2_p=pi_coeff2_p, pi_coeff2_i=pi_coeff2_i) def connect(self) -> bool: - """ - Open connection to the device. - - Returns: - True if connection succeeded. - - Raises: - CommunicationError: If the port cannot be opened. - """ self._protocol.connect() - logger.info("Connected to laser controller on port %s", - self._protocol._port_name or "auto") + logger.info("Connected to laser controller on port %s", self._protocol.port) return True def disconnect(self) -> None: - """Close the serial port gracefully.""" self._protocol.disconnect() logger.info("Disconnected from laser controller") - @property - def is_connected(self) -> bool: - """True if the serial port is open.""" - return self._protocol.is_connected + def set_manual_mode(self, temp1: float, temp2: float, current1: float, current2: float, + message_id: int) -> None: + manual = dict(temp1=temp1, temp2=temp2, current1=current1, current2=current2) + self._send_and_read_state(Protocol.encode_decode_enable(**manual, **self._pi, message_id=message_id)) + logger.debug("Manual setpoints: %s", manual) - # ---- Public API ------------------------------------------------------- - - def set_manual_mode( - self, - temp1: float, - temp2: float, - current1: float, - current2: float, - message_id: Optional[int] = None, - ) -> None: - """ - Set manual control parameters for both lasers. - - Args: - temp1: Setpoint temperature for laser 1, °C. - Valid range: [15.0 … 40.0] °C. - temp2: Setpoint temperature for laser 2, °C. - Valid range: [15.0 … 40.0] °C. - current1: Drive current for laser 1, mA. - Valid range: [15.0 … 60.0] mA. - current2: Drive current for laser 2, mA. - Valid range: [15.0 … 60.0] mA. - message_id: Optional fixed DECODE_ENABLE message id. When - omitted, the id is incremented for backward - compatibility with the refactored API. - - Raises: - ValidationError: If any parameter is out of range. - CommunicationError: If the command cannot be sent. - """ - validated = ParameterValidator.validate_manual_mode_params( - temp1, temp2, current1, current2 - ) - if message_id is None: - self._message_id = (self._message_id + 1) & 0xFFFF - else: - self._message_id = int(message_id) & 0xFFFF - - cmd = Protocol.encode_decode_enable( - temp1=validated['temp1'], - temp2=validated['temp2'], - current1=validated['current1'], - current2=validated['current2'], - pi_coeff1_p=self._pi1_p, - pi_coeff1_i=self._pi1_i, - pi_coeff2_p=self._pi2_p, - pi_coeff2_i=self._pi2_i, - message_id=self._message_id, - ) - self._send_and_read_state(cmd) - self._last_temp1 = validated['temp1'] - self._last_temp2 = validated['temp2'] - self._last_current1 = validated['current1'] - self._last_current2 = validated['current2'] - logger.debug("Manual mode set: T1=%.2f T2=%.2f I1=%.2f I2=%.2f", - validated['temp1'], validated['temp2'], - validated['current1'], validated['current2']) - - def start_variation( - self, - variation_type: VariationType, - params: dict, - ) -> None: - """ - Start a parameter variation task. - - Args: - variation_type: Which parameter to vary - (:class:`VariationType.CHANGE_CURRENT_LD1` or - :class:`VariationType.CHANGE_CURRENT_LD2`). - params: Dictionary with the following keys: - - - ``min_value`` – minimum value of the varied parameter. - - ``max_value`` – maximum value of the varied parameter. - - ``step`` – step size. - - ``time_step`` – discretisation time step, µs [20 … 100]. - - ``delay_time``– delay between pulses, ms [3 … 10]. - - ``static_temp1`` – fixed temperature for laser 1, °C. - - ``static_temp2`` – fixed temperature for laser 2, °C. - - ``static_current1`` – fixed current for laser 1, mA. - - ``static_current2`` – fixed current for laser 2, mA. - - Raises: - ValidationError: If any parameter fails validation. - CommunicationError: If the command cannot be sent. - """ - # Validate variation-specific params - validated = ParameterValidator.validate_variation_params( - params, variation_type - ) - - # Validate static parameters - static_temp1 = ParameterValidator.validate_temperature( - params.get('static_temp1', 25.0), 'static_temp1' - ) - static_temp2 = ParameterValidator.validate_temperature( - params.get('static_temp2', 25.0), 'static_temp2' - ) - static_current1 = ParameterValidator.validate_current( - params.get('static_current1', 30.0), 'static_current1' - ) - static_current2 = ParameterValidator.validate_current( - params.get('static_current2', 30.0), 'static_current2' - ) - - # Map VariationType → protocol TaskType - task_type_map = { - VariationType.CHANGE_CURRENT_LD1: ProtoTaskType.CHANGE_CURRENT_LD1, - VariationType.CHANGE_CURRENT_LD2: ProtoTaskType.CHANGE_CURRENT_LD2, - VariationType.CHANGE_TEMPERATURE_LD1: ProtoTaskType.CHANGE_TEMPERATURE_LD1, - VariationType.CHANGE_TEMPERATURE_LD2: ProtoTaskType.CHANGE_TEMPERATURE_LD2, - } - proto_task = task_type_map[validated['variation_type']] - - cmd = Protocol.encode_task_enable( - task_type=proto_task, - static_temp1=static_temp1, - static_temp2=static_temp2, - static_current1=static_current1, - static_current2=static_current2, - min_value=validated['min_value'], - max_value=validated['max_value'], - step=validated['step'], - time_step=validated['time_step'], - delay_time=validated['delay_time'], - message_id=self._message_id, - pi_coeff1_p=self._pi1_p, - pi_coeff1_i=self._pi1_i, - pi_coeff2_p=self._pi2_p, - pi_coeff2_i=self._pi2_i, - ) - self._send_and_read_state(cmd) - logger.info("Variation task started: type=%s min=%.3f max=%.3f step=%.3f", - validated['variation_type'].name, - validated['min_value'], - validated['max_value'], - validated['step']) - - def stop_task(self, restore_message_id: Optional[int] = None) -> None: - """Stop the current task and restore manual mode. - - Sends DEFAULT_ENABLE (reset) followed by DECODE_ENABLE with the last - known manual-mode parameters. This two-step sequence matches the - original firmware protocol: after DEFAULT_ENABLE the board is in a - reset state and must receive DECODE_ENABLE before it can respond to - TRANS_ENABLE data requests again. - """ - cmd_reset = Protocol.encode_default_enable() - self._send_and_read_state(cmd_reset) - logger.info("Task stopped (DEFAULT_ENABLE sent)") - - self.set_manual_mode( - temp1=self._last_temp1, - temp2=self._last_temp2, - current1=self._last_current1, - current2=self._last_current2, - message_id=restore_message_id, - ) - logger.info("Manual mode restored after task stop") - - def get_measurements(self) -> Optional[Measurements]: - """ - Request and return the latest measurements from the device. - - Returns: - :class:`Measurements` dataclass, or None if no data available. - - Raises: - CommunicationError: On transport errors. - """ - cmd = Protocol.encode_trans_enable() - self._send(cmd) - - raw = self._protocol.receive_raw(30) - if not raw or len(raw) != 30: - logger.warning("No data received from device") - return None - - response = Protocol.decode_response(raw) - measurements = response.to_measurements() - self._last_measurements = measurements - - if self._on_data: - self._on_data(measurements) - - return measurements + def start_variation(self, params: dict) -> None: + self._send_and_read_state(Protocol.encode_task_enable( + **params, pi_coeff1_p=self._pi["pi_coeff1_p"], pi_coeff1_i=self._pi["pi_coeff1_i"], + pi_coeff2_p=self._pi["pi_coeff2_p"], pi_coeff2_i=self._pi["pi_coeff2_i"], + )) + logger.info("LD1 current variation started: %s", params) def reset(self) -> None: - """Send a hardware reset command to the device.""" - cmd = Protocol.encode_default_enable() - self._send_and_read_state(cmd) + self._send_and_read_state(b"\x22\x22") logger.info("Device reset command sent") - # ---- Internal helpers ------------------------------------------------- - - def _send(self, cmd: bytes) -> None: - """Send command bytes and wait for the device to process.""" - if not self.is_connected: - raise CommunicationError("Not connected to device. Call connect() first.") - self._protocol.send_raw(cmd) + def _send(self, command: bytes) -> None: + self._protocol.send_raw(command) time.sleep(WAIT_AFTER_SEND_SEC) - def _send_and_read_state(self, cmd: bytes) -> int: - """Send command and read the 2-byte STATE response the device always returns. - - Commands DECODE_ENABLE, TASK_ENABLE and DEFAULT_ENABLE each trigger a - STATE reply from the firmware. If we don't consume those bytes here, - they accumulate in the serial buffer and corrupt the next DATA read. - - Returns the decoded state code (0x0000 = OK). - """ - self._send(cmd) - raw = self._protocol.receive_raw(2) - if raw and len(raw) == 2: - state = Protocol.decode_state(raw) - if state != 0: - # Surface a device-reported non-OK STATE instead of silently - # treating a board-rejected command as success. - logger.warning( - "Device returned non-OK STATE 0x%04x after command: %s", - state, - Protocol.state_to_description(f"{state:04x}"), - ) - else: - logger.debug("STATE response after command: 0x%04x", state) - return state - return 0 - - # ---- Context manager support ----------------------------------------- - - def __enter__(self): - self.connect() - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - # Always try to stop any running task before closing the port. - # If we don't, the board stays in TASK state and ignores all future - # commands until its power is cycled. - if self.is_connected: - try: - self.stop_task() - except Exception: - logger.warning("Failed to stop laser task on exit; closing port anyway", exc_info=True) - self.disconnect() - return False + def _send_and_read_state(self, command: bytes) -> None: + self._send(command) + state = Protocol.decode_state(self._protocol.receive_raw(2)) + if state: + raise CommunicationError(f"Device rejected command: STATE 0x{state:04x}") diff --git a/python_app/hardware_full/laser_control/conversions.py b/python_app/hardware_full/laser_control/conversions.py index ecfb2aa..2486c4d 100644 --- a/python_app/hardware_full/laser_control/conversions.py +++ b/python_app/hardware_full/laser_control/conversions.py @@ -5,14 +5,7 @@ values used by the device firmware, using the hardware's bridge/divider formulas """ import math -from .constants import ( - VREF, R1, R3, R4, R5, R6, - R7, R8, R9, R10, - RREF, - BETA_INTERNAL, BETA_EXTERNAL, T0_K, R0, - ADC_BITS_16, ADC_BITS_12, - U3V3_COEFF, U5V_COEFF, U7V_COEFF, -) +from .constants import ADC_BITS_16, BETA_INTERNAL, R0, R1, R3, R4, R5, R6, RREF, T0_K, VREF def temp_c_to_n(temp_c: float) -> int: @@ -34,38 +27,6 @@ def temp_c_to_n(temp_c: float) -> int: return n -def temp_n_to_c(n: int) -> float: - """ - Convert 16-bit ADC integer to temperature (°C). - - Args: - n: Raw ADC value in [0, 65535]. - - Returns: - Temperature in degrees Celsius. - """ - u = n * VREF / ADC_BITS_16 - rt = R1 * (VREF * R4 * (R5 + R6) - u * R5 * (R3 + R4)) / ( - u * R5 * (R3 + R4) + VREF * R3 * R6 - VREF * R4 * R5 - ) - t = 1 / (1 / T0_K + 1 / BETA_INTERNAL * math.log(rt / R0)) - 273 - return t - - -def temp_ext_n_to_c(n: int) -> float: - """ - Convert 12-bit ADC integer to external thermistor temperature (°C). - - Args: - n: Raw 12-bit ADC value in [0, 4095]. - - Returns: - Temperature in degrees Celsius. - """ - u = n * VREF / ADC_BITS_12 * 1 / (1 + 100000 / R10) + VREF * R9 / (R8 + R9) - rt = R7 * u / (VREF - u) - t = 1 / (1 / T0_K + 1 / BETA_EXTERNAL * math.log(rt / R0)) - 273 - return t def current_ma_to_n(current_ma: float) -> int: @@ -81,31 +42,3 @@ def current_ma_to_n(current_ma: float) -> int: n = int(ADC_BITS_16 / 2000 * RREF * current_ma) n = max(0, min(ADC_BITS_16, n)) return n - - -def current_n_to_ma(n: int) -> float: - """ - Convert raw ADC integer to photodiode current (mA). - - Args: - n: Raw ADC value in [0, 65535]. - - Returns: - Current in milliamps. - """ - return n * 2.5 / (ADC_BITS_16 * 4.4) - 1 / 20.4 - - -def voltage_3v3_n_to_v(n: int) -> float: - """Convert 3.3V rail ADC count to volts.""" - return n * U3V3_COEFF - - -def voltage_5v_n_to_v(n: int) -> float: - """Convert 5V rail ADC count to volts (both 5V1 and 5V2).""" - return n * U5V_COEFF - - -def voltage_7v_n_to_v(n: int) -> float: - """Convert 7V rail ADC count to volts.""" - return n * U7V_COEFF \ No newline at end of file diff --git a/python_app/hardware_full/laser_control/exceptions.py b/python_app/hardware_full/laser_control/exceptions.py index 00489a6..b5cd636 100644 --- a/python_app/hardware_full/laser_control/exceptions.py +++ b/python_app/hardware_full/laser_control/exceptions.py @@ -16,58 +16,11 @@ class ValidationError(LaserControlError): pass -class TemperatureOutOfRangeError(ValidationError): - """Exception raised when temperature is outside valid range.""" - - def __init__(self, param_name: str, value: float, min_val: float, max_val: float): - self.param_name = param_name - self.value = value - self.min_val = min_val - self.max_val = max_val - super().__init__( - f"{param_name}: Temperature {value}°C is out of range " - f"[{min_val}°C - {max_val}°C]" - ) - - -class CurrentOutOfRangeError(ValidationError): - """Exception raised when current is outside valid range.""" - - def __init__(self, param_name: str, value: float, min_val: float, max_val: float): - self.param_name = param_name - self.value = value - self.min_val = min_val - self.max_val = max_val - super().__init__( - f"{param_name}: Current {value}mA is out of range " - f"[{min_val}mA - {max_val}mA]" - ) - - -class InvalidParameterError(ValidationError): - """Exception raised for invalid parameter types or values.""" - - def __init__(self, param_name: str, message: str): - self.param_name = param_name - super().__init__(f"{param_name}: {message}") - - class CommunicationError(LaserControlError): """Base exception for communication errors.""" pass -class PortNotFoundError(CommunicationError): - """Exception raised when serial port cannot be found.""" - - def __init__(self, port: str | None = None): - if port: - message = f"Serial port '{port}' not found" - else: - message = "No suitable serial port found for device connection" - super().__init__(message) - - class PortBusyError(CommunicationError): """Raised when the serial port is already held by another program. @@ -86,28 +39,6 @@ class PortBusyError(CommunicationError): ) -class DeviceNotRespondingError(CommunicationError): - """Exception raised when device doesn't respond to commands.""" - - def __init__(self, timeout: float | None = None): - if timeout: - message = f"Device did not respond within {timeout} seconds" - else: - message = "Device is not responding to commands" - super().__init__(message) - - -class CRCError(CommunicationError): - """Exception raised when CRC check fails.""" - - def __init__(self, expected: int | None = None, received: int | None = None): - if expected is not None and received is not None: - message = f"CRC check failed. Expected: 0x{expected:04X}, Received: 0x{received:04X}" - else: - message = "CRC check failed on received data" - super().__init__(message) - - class ProtocolError(CommunicationError): """Exception raised for protocol-level errors.""" diff --git a/python_app/hardware_full/laser_control/models.py b/python_app/hardware_full/laser_control/models.py deleted file mode 100644 index 030113a..0000000 --- a/python_app/hardware_full/laser_control/models.py +++ /dev/null @@ -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 - } diff --git a/python_app/hardware_full/laser_control/monitoring/README.md b/python_app/hardware_full/laser_control/monitoring/README.md deleted file mode 100644 index 7028e94..0000000 --- a/python_app/hardware_full/laser_control/monitoring/README.md +++ /dev/null @@ -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:` (фикс. период) | -| `--readings` | `/laser_temp_readings.jsonl` | Куда дописывать показания | -| `--session` | `/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` | `/laser_variation_session.json` | Снимок с целями и допуском | -| `--readings` | `/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)), снимок сессии -`/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:`. (Внутренний счётчик `TO6` платы существует, но его -семантика не гарантирована, поэтому для тайминга он не используется.) - -## Тесты - -```bash -.venv/bin/python -m unittest python_app.tests.test_laser_temp_monitoring -``` - -Покрыто: round-trip сессии, tail JSONL (включая усечённую последнюю строку), -расчёт периода свипа, маппинг измерений монитором, и валидация чекера по каждому -лазеру отдельно (порог, граница допуска, повторные предупреждения, восстановление). diff --git a/python_app/hardware_full/laser_control/monitoring/__init__.py b/python_app/hardware_full/laser_control/monitoring/__init__.py deleted file mode 100644 index e241424..0000000 --- a/python_app/hardware_full/laser_control/monitoring/__init__.py +++ /dev/null @@ -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", -] diff --git a/python_app/hardware_full/laser_control/monitoring/checker.py b/python_app/hardware_full/laser_control/monitoring/checker.py deleted file mode 100644 index 103624b..0000000 --- a/python_app/hardware_full/laser_control/monitoring/checker.py +++ /dev/null @@ -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, - ) diff --git a/python_app/hardware_full/laser_control/monitoring/monitor.py b/python_app/hardware_full/laser_control/monitoring/monitor.py deleted file mode 100644 index 1091f5f..0000000 --- a/python_app/hardware_full/laser_control/monitoring/monitor.py +++ /dev/null @@ -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:"`` — a fixed period of ```` 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:'" - ) - - -@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) diff --git a/python_app/hardware_full/laser_control/monitoring/readings_channel.py b/python_app/hardware_full/laser_control/monitoring/readings_channel.py deleted file mode 100644 index 158d07d..0000000 --- a/python_app/hardware_full/laser_control/monitoring/readings_channel.py +++ /dev/null @@ -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 diff --git a/python_app/hardware_full/laser_control/monitoring/session.py b/python_app/hardware_full/laser_control/monitoring/session.py deleted file mode 100644 index fead238..0000000 --- a/python_app/hardware_full/laser_control/monitoring/session.py +++ /dev/null @@ -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 diff --git a/python_app/hardware_full/laser_control/protocol.py b/python_app/hardware_full/laser_control/protocol.py index 1b20031..230588d 100644 --- a/python_app/hardware_full/laser_control/protocol.py +++ b/python_app/hardware_full/laser_control/protocol.py @@ -1,156 +1,39 @@ -"""Communication protocol for the laser control module. - -Encodes commands to wire bytes, decodes device responses, and manages the -serial port connection to the laser control board. -""" +"""Serial transport and little-endian commands for the dual-laser board.""" import errno import logging -from typing import Optional -from enum import IntEnum -from datetime import datetime +import struct +from functools import reduce +from operator import xor import serial -import serial.tools.list_ports -from .constants import ( - BAUDRATE, SERIAL_TIMEOUT_SEC, - GET_DATA_TOTAL_LENGTH, - SEND_PARAMS_TOTAL_LENGTH, - TASK_ENABLE_COMMAND_LENGTH, - CMD_DECODE_ENABLE, CMD_DEFAULT_ENABLE, - CMD_TRANS_ENABLE, CMD_REMOVE_FILE, - CMD_STATE, CMD_TASK_ENABLE, - STATE_DESCRIPTIONS, STATE_OK, -) -from .conversions import ( - temp_c_to_n, temp_n_to_c, - temp_ext_n_to_c, - current_ma_to_n, current_n_to_ma, - voltage_3v3_n_to_v, voltage_5v_n_to_v, voltage_7v_n_to_v, -) -from .models import Measurements -from .exceptions import ( - CommunicationError, - PortNotFoundError, - PortBusyError, - CRCError, - ProtocolError, -) +from .constants import BAUDRATE, SERIAL_TIMEOUT_SEC +from .conversions import current_ma_to_n, temp_c_to_n +from .validators import validate_manual, validate_variation +from .exceptions import CommunicationError, PortBusyError, ProtocolError logger = logging.getLogger(__name__) -class TaskType(IntEnum): - MANUAL = 0x00 - CHANGE_CURRENT_LD1 = 0x01 - CHANGE_CURRENT_LD2 = 0x02 - CHANGE_TEMPERATURE_LD1 = 0x03 - CHANGE_TEMPERATURE_LD2 = 0x04 +def _command(header: int, *words: int) -> bytes: + """Append XOR checksum; header is excluded, every word is uint16 LE.""" + if any(not isinstance(word, int) or not 0 <= word <= 65535 for word in words): + raise ValueError("Laser command words must be uint16 integers") + return struct.pack(f"<{len(words) + 2}H", header, *words, reduce(xor, words, 0)) -# ---- Low-level helpers -------------------------------------------------- - -def _int_to_hex4(value: int) -> str: - """Return 4-character lowercase hex string (0–65535).""" - if value < 0 or value > 65535: - raise ValueError(f"Value {value} out of uint16 range [0, 65535]") - return f"{value:04x}" - - -def _flipfour(hex_word: str) -> str: - """Swap the two byte-pairs of a 4-char hex word: 'aabb' -> 'bbaa' (little-endian).""" - if len(hex_word) != 4: - raise ValueError(f"Expected 4-char hex string, got '{hex_word}'") - return hex_word[2:4] + hex_word[0:2] - - -def _xor_crc(words: list) -> str: - """XOR all 16-bit hex words and return 4-char hex CRC.""" - result = int(words[0], 16) - for w in words[1:]: - result ^= int(w, 16) - return _int_to_hex4(result) - - -def _build_crc(data_hex: str) -> str: - """Calculate XOR CRC over words 1..N of a hex string (skip word 0).""" - words = [data_hex[i:i+4] for i in range(0, len(data_hex), 4)] - return _xor_crc(words[1:]) - - -def _encode_setup() -> str: - """Build the 16-bit setup word (all subsystems enabled, SD save off).""" - bits = ['0'] * 16 - bits[15] = '1' # enable work - bits[14] = '1' # enable 5v1 - bits[13] = '1' # enable 5v2 - bits[12] = '1' # enable LD1 - bits[11] = '1' # enable LD2 - bits[10] = '1' # enable REF1 - bits[9] = '1' # enable REF2 - bits[8] = '1' # enable TEC1 - bits[7] = '1' # enable TEC2 - bits[6] = '1' # enable temp stab 1 - bits[5] = '1' # enable temp stab 2 - bits[4] = '0' # enable sd save (disabled) - bits[3] = '1' # enable PI1 coef read - bits[2] = '1' # enable PI2 coef read - bits[1] = '0' # reserved - bits[0] = '0' # reserved - return f"{int(''.join(bits), 2):04x}" - - -# ---- Response dataclass -------------------------------------------------- - -class Response: - """Decoded device DATA response.""" - __slots__ = [ - 'current1', 'current2', - 'temp1', 'temp2', - 'temp_ext1', 'temp_ext2', - 'voltage_3v3', 'voltage_5v1', 'voltage_5v2', 'voltage_7v0', - 'to6_lsb', 'to6_msb', - 'message_id', - 'header', - ] - - def to_measurements(self) -> Measurements: - return Measurements( - current1=self.current1, - current2=self.current2, - temp1=self.temp1, - temp2=self.temp2, - temp_ext1=self.temp_ext1, - temp_ext2=self.temp_ext2, - voltage_3v3=self.voltage_3v3, - voltage_5v1=self.voltage_5v1, - voltage_5v2=self.voltage_5v2, - voltage_7v0=self.voltage_7v0, - timestamp=datetime.now(), - message_id=self.message_id, - to6_counter_lsb=self.to6_lsb, - to6_counter_msb=self.to6_msb, - ) - - -# ---- Protocol class -------------------------------------------------- - class Protocol: - """ - Encodes commands and decodes responses for the laser control board. + """Exclusive serial connection and the commands used by the application.""" - Can also manage a serial port connection when port is provided. - """ - - def __init__(self, port: Optional[str] = None): - self._port_name = port - self._serial: Optional[serial.Serial] = None + def __init__(self, port: str): + self.port = port + self._serial: serial.Serial | None = None # ---- Connection management def connect(self) -> None: - """Open the serial port. Auto-detects the device path when port is None. + """Open the serial port. The port is opened with ``exclusive=True`` so only one program can own the laser controller at a time. This is required because the board is a single @@ -162,7 +45,7 @@ class Protocol: """ if self.is_connected: return - port = self._port_name or self._detect_port() + port = self.port try: self._serial = serial.Serial( port=port, @@ -194,13 +77,6 @@ class Protocol: def is_connected(self) -> bool: return self._serial is not None and self._serial.is_open - def _detect_port(self) -> str: - """Return first available serial port device path.""" - ports = list(serial.tools.list_ports.comports()) - if not ports: - raise PortNotFoundError() - return ports[0].device - # ---- Raw I/O def send_raw(self, data: bytes) -> None: @@ -213,199 +89,45 @@ class Protocol: raise CommunicationError("Serial port is not connected") return self._serial.read(length) - # ---- Command encoders ----------------------------------------------- - @staticmethod def encode_decode_enable( - temp1: float, - temp2: float, - current1: float, - current2: float, - pi_coeff1_p: int, - pi_coeff1_i: int, - pi_coeff2_p: int, - pi_coeff2_i: int, + temp1: float, temp2: float, current1: float, current2: float, + pi_coeff1_p: int, pi_coeff1_i: int, pi_coeff2_p: int, pi_coeff2_i: int, message_id: int, ) -> bytes: - """ - Build DECODE_ENABLE command (0x1111). - - Sets temperature and current setpoints for both lasers. - Returns 30-byte bytearray. - """ - if current1 < 0 or current2 < 0: - raise ValueError("Current values must not be negative") - - data = _flipfour(_int_to_hex4(CMD_DECODE_ENABLE)) # Word 0 - data += _flipfour(_encode_setup()) # Word 1 - data += _flipfour(_int_to_hex4(temp_c_to_n(temp1))) # Word 2 - data += _flipfour(_int_to_hex4(temp_c_to_n(temp2))) # Word 3 - data += _flipfour('0000') * 3 # Words 4-6 - data += _flipfour(_int_to_hex4(pi_coeff1_p)) # Word 7 - data += _flipfour(_int_to_hex4(pi_coeff1_i)) # Word 8 - data += _flipfour(_int_to_hex4(pi_coeff2_p)) # Word 9 - data += _flipfour(_int_to_hex4(pi_coeff2_i)) # Word 10 - data += _flipfour(_int_to_hex4(message_id & 0xFFFF)) # Word 11 - data += _flipfour(_int_to_hex4(current_ma_to_n(current1))) # Word 12 - data += _flipfour(_int_to_hex4(current_ma_to_n(current2))) # Word 13 - data += _build_crc(data) # Word 14 - - result = bytearray.fromhex(data) - assert len(result) == SEND_PARAMS_TOTAL_LENGTH, \ - f"DECODE_ENABLE length mismatch: {len(result)}" - return bytes(result) + """DECODE_ENABLE: constant currents, temperatures and PI coefficients.""" + validate_manual(temp1, temp2, current1, current2) + # 0x37ff enables both channels and their regulators; SD recording is off. + return _command( + 0x1111, 0x37ff, temp_c_to_n(temp1), temp_c_to_n(temp2), 0, 0, 0, + pi_coeff1_p, pi_coeff1_i, pi_coeff2_p, pi_coeff2_i, message_id & 0xffff, + current_ma_to_n(current1), current_ma_to_n(current2), + ) @staticmethod def encode_task_enable( - task_type: TaskType, - static_temp1: float, - static_temp2: float, - static_current1: float, - static_current2: float, - min_value: float, - max_value: float, - step: float, - time_step: int, - delay_time: int, - message_id: int, - pi_coeff1_p: int = 1, - pi_coeff1_i: int = 1, - pi_coeff2_p: int = 1, - pi_coeff2_i: int = 1, + static_temp1: float, static_temp2: float, static_current1: float, static_current2: float, + min_value: float, max_value: float, step: float, time_step: int, delay_time: int, + pi_coeff1_p: int = 1, pi_coeff1_i: int = 1, pi_coeff2_p: int = 1, pi_coeff2_i: int = 1, + variation_type: str = "CHANGE_CURRENT_LD1", ) -> bytes: - """ - Build TASK_ENABLE command (0x7777). - - Starts a measurement task (current or temperature variation). - Returns 32-byte bytearray. - """ - if not isinstance(task_type, TaskType): - try: - task_type = TaskType(task_type) - except ValueError: - raise ValueError(f"Invalid task_type: {task_type}") - - data = _flipfour(_int_to_hex4(CMD_TASK_ENABLE)) # Word 0 - data += _flipfour(_encode_setup()) # Word 1 - data += _flipfour(_int_to_hex4(task_type.value)) # Word 2 - - match task_type: - case TaskType.CHANGE_CURRENT_LD1: - data += _flipfour(_int_to_hex4(current_ma_to_n(min_value))) # Word 3 - data += _flipfour(_int_to_hex4(current_ma_to_n(max_value))) # Word 4 - data += _flipfour(_int_to_hex4(current_ma_to_n(step))) # Word 5 - data += _flipfour(_int_to_hex4(int(time_step * 100))) # Word 6: Delta_Time_µs × 100 - data += _flipfour(_int_to_hex4(temp_c_to_n(static_temp1))) # Word 7 - data += _flipfour(_int_to_hex4(current_ma_to_n(static_current2)))# Word 8 - data += _flipfour(_int_to_hex4(temp_c_to_n(static_temp2))) # Word 9 - case TaskType.CHANGE_CURRENT_LD2: - data += _flipfour(_int_to_hex4(current_ma_to_n(min_value))) # Word 3 - data += _flipfour(_int_to_hex4(current_ma_to_n(max_value))) # Word 4 - # Word 5: current step uses the same current_ma_to_n scaling as - # min/max (and as LD1) so equal physical steps map to equal wire values. - data += _flipfour(_int_to_hex4(current_ma_to_n(step))) # Word 5 - data += _flipfour(_int_to_hex4(int(time_step * 100))) # Word 6: Delta_Time_µs × 100 - data += _flipfour(_int_to_hex4(temp_c_to_n(static_temp2))) # Word 7 - data += _flipfour(_int_to_hex4(current_ma_to_n(static_current1)))# Word 8 - data += _flipfour(_int_to_hex4(temp_c_to_n(static_temp1))) # Word 9 - case TaskType.CHANGE_TEMPERATURE_LD1 | TaskType.CHANGE_TEMPERATURE_LD2: - raise NotImplementedError("Temperature variation is not yet implemented in firmware") - case _: - raise ValueError(f"Unsupported task type: {task_type}") - - data += _flipfour(_int_to_hex4(int(delay_time))) # Word 10: Tau in ms (3-10) - data += _flipfour(_int_to_hex4(pi_coeff1_p)) # Word 11 - data += _flipfour(_int_to_hex4(pi_coeff1_i)) # Word 12 - data += _flipfour(_int_to_hex4(pi_coeff2_p)) # Word 13 - data += _flipfour(_int_to_hex4(pi_coeff2_i)) # Word 14 - data += _build_crc(data) # Word 15 - - result = bytearray.fromhex(data) - assert len(result) == TASK_ENABLE_COMMAND_LENGTH, \ - f"TASK_ENABLE length mismatch: {len(result)}" - return bytes(result) - - @staticmethod - def encode_trans_enable(message_id: int = 0) -> bytes: - """Build TRANS_ENABLE command (0x4444) — request last data.""" - return bytes.fromhex(_flipfour(_int_to_hex4(CMD_TRANS_ENABLE))) - - @staticmethod - def encode_state(message_id: int = 0) -> bytes: - """Build STATE command (0x6666) — request device state.""" - return bytes.fromhex(_flipfour(_int_to_hex4(CMD_STATE))) - - @staticmethod - def encode_default_enable(message_id: int = 0) -> bytes: - """Build DEFAULT_ENABLE command (0x2222) — reset device.""" - return bytes.fromhex(_flipfour(_int_to_hex4(CMD_DEFAULT_ENABLE))) - - # ---- Response decoders ----------------------------------------------- - - @staticmethod - def decode_response(data: bytes) -> Response: - """ - Decode a 30-byte DATA response from the device. - - Raises: - ProtocolError: If data length is wrong. - CRCError: If CRC check fails. - """ - if len(data) != GET_DATA_TOTAL_LENGTH: - raise ProtocolError( - f"Expected {GET_DATA_TOTAL_LENGTH} bytes, got {len(data)} bytes" - ) - - hex_str = data.hex() - - def get_word(num: int) -> str: - return _flipfour(hex_str[num*4: num*4+4]) - - def get_int_word(num: int) -> int: - return int(get_word(num), 16) - - # CRC check: XOR over words 1..13 (wire order), compare with word 14 (wire order) - crc_words = [hex_str[i:i+4] for i in range(4, len(hex_str)-4, 4)] - computed = int(crc_words[0], 16) - for w in crc_words[1:]: - computed ^= int(w, 16) - stored = int(hex_str[56:60], 16) - if computed != stored: - raise CRCError(expected=computed, received=stored) - - resp = Response() - resp.header = get_word(0) - resp.current1 = current_n_to_ma(get_int_word(1)) - resp.current2 = current_n_to_ma(get_int_word(2)) - resp.to6_lsb = get_int_word(3) - resp.to6_msb = get_int_word(4) - resp.temp1 = temp_n_to_c(get_int_word(5)) - resp.temp2 = temp_n_to_c(get_int_word(6)) - resp.temp_ext1 = temp_ext_n_to_c(get_int_word(7)) - resp.temp_ext2 = temp_ext_n_to_c(get_int_word(8)) - resp.voltage_3v3 = voltage_3v3_n_to_v(get_int_word(9)) - resp.voltage_5v1 = voltage_5v_n_to_v(get_int_word(10)) - resp.voltage_5v2 = voltage_5v_n_to_v(get_int_word(11)) - resp.voltage_7v0 = voltage_7v_n_to_v(get_int_word(12)) - resp.message_id = get_int_word(13) - - return resp + """TASK_ENABLE: sweep LD1 current while LD2 and both temperatures stay fixed.""" + if variation_type != "CHANGE_CURRENT_LD1": + raise ValueError("radar.laser_control.variation.variation_type must be CHANGE_CURRENT_LD1") + validate_manual(static_temp1, static_temp2, static_current1, static_current2) + validate_variation(min_value, max_value, step, time_step, delay_time) + low, high, delta = (current_ma_to_n(value) for value in (min_value, max_value, step)) + if delta == 0 or low >= high: + raise ValueError("Current variation range and step must be representable by the DAC") + return _command( + 0x7777, 0x37ff, 1, low, high, delta, int(time_step * 100), + temp_c_to_n(static_temp1), current_ma_to_n(static_current2), temp_c_to_n(static_temp2), + int(delay_time), pi_coeff1_p, pi_coeff1_i, pi_coeff2_p, pi_coeff2_i, + ) @staticmethod def decode_state(data: bytes) -> int: - """ - Decode a 2-byte STATE response from the device. - - Returns: - Integer state code; see STATE_* constants. - """ - if len(data) < 2: - raise ProtocolError(f"STATE response too short: {len(data)} bytes") - hex_str = data.hex() - state_hex = _flipfour(hex_str[0:4]) - return int(state_hex, 16) - - @staticmethod - def state_to_description(state_hex_str: str) -> str: - """Return human-readable description for a state hex string.""" - return STATE_DESCRIPTIONS.get(state_hex_str, "Unknown or reserved error.") + """STATE is a two-byte error bitmask; zero acknowledges the command.""" + if len(data) != 2: + raise ProtocolError(f"Expected 2 STATE bytes, got {len(data)}") + return int.from_bytes(data, "little") diff --git a/python_app/hardware_full/laser_control/validators.py b/python_app/hardware_full/laser_control/validators.py index 98aa288..bfd92cd 100644 --- a/python_app/hardware_full/laser_control/validators.py +++ b/python_app/hardware_full/laser_control/validators.py @@ -1,257 +1,33 @@ -""" -Parameter validation for laser control module. - -Validates all input parameters against physical constraints -and protocol limits before sending to device. -""" +"""Physical limits checked before command encoding or any board reset.""" import math -from typing import Dict, Any, Tuple from .constants import ( - TEMP_MIN_C, TEMP_MAX_C, - CURRENT_MIN_MA, CURRENT_MAX_MA, + TEMP_MIN_C, TEMP_MAX_C, CURRENT_MIN_MA, CURRENT_MAX_MA, CURRENT_STEP_MIN_MA, CURRENT_STEP_MAX_MA, - TEMP_STEP_MIN_C, TEMP_STEP_MAX_C, - TIME_STEP_MIN_US, TIME_STEP_MAX_US, - DELAY_TIME_MIN_MS, DELAY_TIME_MAX_MS, + TIME_STEP_MIN_US, TIME_STEP_MAX_US, DELAY_TIME_MIN_MS, DELAY_TIME_MAX_MS, ) -from .exceptions import ( - ValidationError, - TemperatureOutOfRangeError, - CurrentOutOfRangeError, - InvalidParameterError, -) -from .models import VariationType +from .exceptions import ValidationError -class ParameterValidator: - """Validates all input parameters for the laser controller.""" +def _range(value: float, name: str, low: float, high: float) -> None: + if not isinstance(value, (int, float)) or not math.isfinite(value) or not low <= value <= high: + raise ValidationError(f"{name} must be a finite number in [{low}, {high}], got {value!r}") - @staticmethod - def _check_numeric(value: Any, param_name: str) -> float: - """Check that value is a valid finite number. Returns float.""" - if value is None: - raise InvalidParameterError(param_name, "Value must not be None") - if not isinstance(value, (int, float)): - raise InvalidParameterError(param_name, "Value must be a number") - if math.isnan(value): - raise InvalidParameterError(param_name, "Value must not be NaN") - if math.isinf(value): - raise InvalidParameterError(param_name, "Value must not be infinite") - return float(value) - @staticmethod - def validate_temperature(value: Any, param_name: str) -> float: - """ - Validate a laser temperature value. +def validate_manual(temp1: float, temp2: float, current1: float, current2: float) -> None: + for name, value in (("temp1", temp1), ("temp2", temp2)): + _range(value, name, TEMP_MIN_C, TEMP_MAX_C) + for name, value in (("current1", current1), ("current2", current2)): + _range(value, name, CURRENT_MIN_MA, CURRENT_MAX_MA) - Args: - value: Temperature in °C. - param_name: Parameter name for error messages. - Returns: - Validated temperature as float. - - Raises: - InvalidParameterError: If value is not a valid number. - TemperatureOutOfRangeError: If value is outside [TEMP_MIN_C, TEMP_MAX_C]. - """ - value = ParameterValidator._check_numeric(value, param_name) - if value < TEMP_MIN_C or value > TEMP_MAX_C: - raise TemperatureOutOfRangeError( - param_name, value, TEMP_MIN_C, TEMP_MAX_C - ) - return value - - @staticmethod - def validate_current(value: Any, param_name: str) -> float: - """ - Validate a laser drive current value. - - Args: - value: Current in mA. - param_name: Parameter name for error messages. - - Returns: - Validated current as float. - - Raises: - InvalidParameterError: If value is not a valid number. - CurrentOutOfRangeError: If value is outside [CURRENT_MIN_MA, CURRENT_MAX_MA]. - """ - value = ParameterValidator._check_numeric(value, param_name) - if value < CURRENT_MIN_MA or value > CURRENT_MAX_MA: - raise CurrentOutOfRangeError( - param_name, value, CURRENT_MIN_MA, CURRENT_MAX_MA - ) - return value - - @staticmethod - def validate_time_params(time_step: Any, delay_time: Any) -> Tuple[int, int]: - """ - Validate time parameters for variation mode. - - Args: - time_step: Discretisation time step in microseconds. - delay_time: Delay between pulses in milliseconds. - - Returns: - Tuple (time_step, delay_time) as integers. - - Raises: - InvalidParameterError: If values are not numeric. - ValidationError: If values are outside allowed ranges. - """ - if not isinstance(time_step, (int, float)): - raise InvalidParameterError("time_step", "Value must be a number") - if not isinstance(delay_time, (int, float)): - raise InvalidParameterError("delay_time", "Value must be a number") - - time_step_int = int(time_step) - delay_time_int = int(delay_time) - - if time_step_int < TIME_STEP_MIN_US or time_step_int > TIME_STEP_MAX_US: - raise ValidationError( - f"time step {time_step_int} µs is out of range " - f"[{TIME_STEP_MIN_US} - {TIME_STEP_MAX_US}] µs" - ) - if delay_time_int < DELAY_TIME_MIN_MS or delay_time_int > DELAY_TIME_MAX_MS: - raise ValidationError( - f"delay time {delay_time_int} ms is out of range " - f"[{DELAY_TIME_MIN_MS} - {DELAY_TIME_MAX_MS}] ms" - ) - return time_step_int, delay_time_int - - @staticmethod - def validate_variation_params( - params: Dict[str, Any], - variation_type: Any - ) -> Dict[str, Any]: - """ - Validate parameters for variation mode. - - Args: - params: Dictionary with keys: - min_value, max_value, step, time_step, delay_time. - variation_type: A VariationType enum value. - - Returns: - Dictionary with validated and type-coerced values. - - Raises: - ValidationError: For any constraint violation. - InvalidParameterError: For wrong types. - """ - # Validate variation type - if not isinstance(variation_type, VariationType): - try: - variation_type = VariationType(variation_type) - except (ValueError, KeyError): - raise ValidationError( - f"Invalid variation type '{variation_type}'. " - f"Must be one of {[e.name for e in VariationType]}" - ) - - # Check required keys - required_keys = {'min_value', 'max_value', 'step', 'time_step', 'delay_time'} - missing = required_keys - params.keys() - if missing: - raise ValidationError( - f"Missing required parameters: {sorted(missing)}" - ) - - # Validate min/max - min_val = ParameterValidator._check_numeric(params['min_value'], 'min_value') - max_val = ParameterValidator._check_numeric(params['max_value'], 'max_value') - - if min_val >= max_val: - raise ValidationError( - f"min_value ({min_val}) must be less than max_value ({max_val})" - ) - - # Validate step based on variation type - step = ParameterValidator._check_numeric(params['step'], 'step') - - is_current_variation = variation_type in ( - VariationType.CHANGE_CURRENT_LD1, - VariationType.CHANGE_CURRENT_LD2 - ) - is_temp_variation = variation_type in ( - VariationType.CHANGE_TEMPERATURE_LD1, - VariationType.CHANGE_TEMPERATURE_LD2 - ) - - if is_current_variation: - step_min, step_max = CURRENT_STEP_MIN_MA, CURRENT_STEP_MAX_MA - unit = "mA" - # Also validate range against current limits - ParameterValidator.validate_current(min_val, 'min_value') - ParameterValidator.validate_current(max_val, 'max_value') - elif is_temp_variation: - step_min, step_max = TEMP_STEP_MIN_C, TEMP_STEP_MAX_C - unit = "°C" - # Also validate range against temperature limits - ParameterValidator.validate_temperature(min_val, 'min_value') - ParameterValidator.validate_temperature(max_val, 'max_value') - else: - raise ValidationError( - f"Variation type {variation_type.name} cannot be used in variation mode" - ) - - if step <= 0: - raise ValidationError( - f"step must be positive, got {step} {unit}" - ) - if step < step_min: - raise ValidationError( - f"step {step} {unit} is too small (minimum {step_min} {unit})" - ) - if step > step_max: - raise ValidationError( - f"step {step} {unit} is too large (maximum {step_max} {unit})" - ) - - # Validate time parameters - time_step, delay_time = ParameterValidator.validate_time_params( - params['time_step'], params['delay_time'] - ) - - return { - 'variation_type': variation_type, - 'min_value': min_val, - 'max_value': max_val, - 'step': step, - 'time_step': time_step, - 'delay_time': delay_time, - } - - @staticmethod - def validate_manual_mode_params( - temp1: Any, - temp2: Any, - current1: Any, - current2: Any, - ) -> Dict[str, float]: - """ - Validate all four manual mode parameters. - - Args: - temp1: Laser 1 temperature, °C. - temp2: Laser 2 temperature, °C. - current1: Laser 1 current, mA. - current2: Laser 2 current, mA. - - Returns: - Dict with validated floats: temp1, temp2, current1, current2. - - Raises: - ValidationError: For any out-of-range value. - InvalidParameterError: For wrong types. - """ - return { - 'temp1': ParameterValidator.validate_temperature(temp1, 'temp1'), - 'temp2': ParameterValidator.validate_temperature(temp2, 'temp2'), - 'current1': ParameterValidator.validate_current(current1, 'current1'), - 'current2': ParameterValidator.validate_current(current2, 'current2'), - } \ No newline at end of file +def validate_variation(min_value: float, max_value: float, step: float, + time_step: int, delay_time: int) -> None: + for name, value in (("min_value", min_value), ("max_value", max_value)): + _range(value, name, CURRENT_MIN_MA, CURRENT_MAX_MA) + if min_value >= max_value: + raise ValidationError("min_value must be less than max_value") + _range(step, "step", CURRENT_STEP_MIN_MA, CURRENT_STEP_MAX_MA) + _range(time_step, "time_step (us)", TIME_STEP_MIN_US, TIME_STEP_MAX_US) + _range(delay_time, "delay_time (ms)", DELAY_TIME_MIN_MS, DELAY_TIME_MAX_MS) diff --git a/python_app/hardware_full/librevna_multi_device_driver/controller.py b/python_app/hardware_full/librevna_multi_device_driver/controller.py index 09dc54c..4f2aa05 100644 --- a/python_app/hardware_full/librevna_multi_device_driver/controller.py +++ b/python_app/hardware_full/librevna_multi_device_driver/controller.py @@ -36,7 +36,7 @@ class MultiDeviceVnaController: self, master_serial_number: str, slave_serial_numbers: Sequence[str] = (), - force_external_reference: bool = True, + force_external_reference: bool = False, switch_substage_count: int = 1, ) -> None: """Open all configured devices and initialize controller state. diff --git a/python_app/hardware_full/multi_device_service.py b/python_app/hardware_full/multi_device_service.py index 1750315..5d66cc3 100644 --- a/python_app/hardware_full/multi_device_service.py +++ b/python_app/hardware_full/multi_device_service.py @@ -51,7 +51,7 @@ class MultiDeviceLibreVnaService: master_serial: str slave_serials: list[str] - force_external_reference: bool = True + force_external_reference: bool = False recovery_attempts: int = 3 # Stimulus switch positions stepped through WITHIN one frequency point. 1 keeps the # stock 2-output matrix; 2 doubles the outputs (4) without a second sweep, at the diff --git a/python_app/hardware_full/rph_radar/__init__.py b/python_app/hardware_full/rph_radar/__init__.py new file mode 100644 index 0000000..e317bb8 --- /dev/null +++ b/python_app/hardware_full/rph_radar/__init__.py @@ -0,0 +1 @@ +"""L-Card acquisition, reference-phase processing and laser configuration.""" diff --git a/python_app/hardware_full/rph_radar/laser.py b/python_app/hardware_full/rph_radar/laser.py new file mode 100644 index 0000000..79134f2 --- /dev/null +++ b/python_app/hardware_full/rph_radar/laser.py @@ -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() diff --git a/python_app/hardware_full/kamil_adc/processing.py b/python_app/hardware_full/rph_radar/processing.py similarity index 67% rename from python_app/hardware_full/kamil_adc/processing.py rename to python_app/hardware_full/rph_radar/processing.py index 0727bf4..d005b94 100644 --- a/python_app/hardware_full/kamil_adc/processing.py +++ b/python_app/hardware_full/rph_radar/processing.py @@ -1,8 +1,8 @@ -"""Signal processing for the Kamil ADC reference-channel acquisition. +"""Signal processing for the RadioPhotonic radar (RPH) reference-channel acquisition. Each sweep arrives as two aligned complex arrays — the *main* signal and a *reference* signal — sampled at the same step indices (see -:mod:`python_app.hardware_full.kamil_adc.protocol`). Turning that into a stable, +:mod:`python_app.hardware_full.rph_radar.protocol`). Turning that into a stable, comparable S21 trace is a fixed three-stage pipeline: 1. **Frequency axis from the reference phase.** The reference arm has a constant @@ -41,12 +41,13 @@ Everything here is pure and free of I/O so it can be unit-tested in isolation. from __future__ import annotations -from dataclasses import dataclass +from dataclasses import replace import numpy as np from numpy.typing import NDArray -from python_app.models.run_config_schema import KamilAdcModel +from python_app.models.run_config_schema import RphRadarModel +from python_app.models.run_config_validation import validate_rph_radar_model # A reference sample whose magnitude falls to (effectively) zero carries no usable # amplitude or phase; such points are dropped before normalization rather than @@ -61,94 +62,28 @@ _MIN_USABLE_POINTS = 2 # One full turn of reference phase. ``np.unwrap`` anchors each sweep's phase ramp # to the raw angle of the first sample on the (-pi, pi] branch, so a stray sweep # whose anchor crossed the +/-pi cut is offset by exactly this; the cross-sweep -# anchor tracking snaps it back (see ``KamilAdcSweepProcessor._align_phase_branch``). +# anchor tracking snaps it back (see ``RphRadarSweepProcessor._align_phase_branch``). _PHASE_BRANCH_PERIOD_RAD = 2.0 * np.pi -@dataclass(frozen=True, slots=True) -class KamilAdcProcessingParams: - """Immutable phase→frequency calibration and output-grid definition. - - Built from :class:`~python_app.models.run_config_schema.KamilAdcModel` via - :meth:`from_kamil_model`, but kept decoupled from the config types so the - processing can be exercised with plain numbers in tests. - """ - - phase0_rad: float - freq0_hz: float - phase1_rad: float - freq1_hz: float - band_start_hz: float - band_stop_hz: float - band_points: int - reference_amplitude_floor: float = _REFERENCE_AMPLITUDE_FLOOR - - def __post_init__(self) -> None: - if not np.isfinite([self.phase0_rad, self.phase1_rad, self.freq0_hz, self.freq1_hz]).all(): - raise ValueError("Kamil ADC phase calibration anchors must be finite") - if self.phase0_rad == self.phase1_rad: - raise ValueError("Kamil ADC phase calibration anchors must use distinct phases") - if self.freq0_hz == self.freq1_hz: - raise ValueError("Kamil ADC phase calibration anchors must map to distinct frequencies") - if not (np.isfinite(self.band_start_hz) and np.isfinite(self.band_stop_hz)): - raise ValueError("Kamil ADC band edges must be finite") - if self.band_stop_hz <= self.band_start_hz: - raise ValueError("Kamil ADC band stop_hz must be greater than start_hz") - if self.band_points < 2: - raise ValueError("Kamil ADC band points must be >= 2") - if self.reference_amplitude_floor <= 0.0: - raise ValueError("Kamil ADC reference amplitude floor must be > 0") - - @classmethod - def from_kamil_model(cls, kamil_adc: KamilAdcModel) -> KamilAdcProcessingParams: - """Build parameters from the ``radar.kamil_adc`` config section.""" - calibration = kamil_adc.phase_calibration - band = kamil_adc.band - return cls( - phase0_rad=float(calibration.phase0_rad), - freq0_hz=float(calibration.freq0_hz), - phase1_rad=float(calibration.phase1_rad), - freq1_hz=float(calibration.freq1_hz), - band_start_hz=float(band.start_hz), - band_stop_hz=float(band.stop_hz), - band_points=int(band.points), - ) - - @property - def hz_per_rad(self) -> float: - """Frequency change per radian of reference phase (the calibration slope).""" - return (self.freq1_hz - self.freq0_hz) / (self.phase1_rad - self.phase0_rad) - - -class KamilAdcSweepProcessor: +class RphRadarSweepProcessor: """Turns aligned (main, reference) sweeps into S21 traces on a fixed grid. The output grid is computed once from the parameters and reused for every sweep, so all traces this processor emits share one identical frequency axis. """ - __slots__ = ("_params", "_grid_hz", "_previous_anchor_rad") - - def __init__(self, params: KamilAdcProcessingParams) -> None: - self._params = params - self._grid_hz = np.linspace( - params.band_start_hz, params.band_stop_hz, params.band_points, dtype=np.float64 - ) - # Absolute (branch-tracked) reference phase of the last ACCEPTED sweep's - # first sample, carried across sweeps so a +/-pi anchor wrap can be undone. - # ``None`` until the first sweep is accepted; reset by building a new - # processor (i.e. on reconfigure). + def __init__(self, config: RphRadarModel) -> None: + validate_rph_radar_model(config) + self._calibration = replace(config.phase_calibration) + self._band = replace(config.band) + self._hz_per_rad = ((self._calibration.freq1_hz - self._calibration.freq0_hz) + / (self._calibration.phase1_rad - self._calibration.phase0_rad)) + self._grid_hz = np.linspace(self._band.start_hz, self._band.stop_hz, self._band.points) + self.grid_hz = self._grid_hz.astype(np.float32) + self.grid_hz.setflags(write=False) self._previous_anchor_rad: float | None = None - @property - def params(self) -> KamilAdcProcessingParams: - return self._params - - @property - def grid_hz(self) -> np.ndarray: - """The fixed output frequency axis (float32), identical for every sweep.""" - return self._grid_hz.astype(np.float32) - def reference_frequency_axis(self, reference: np.ndarray) -> np.ndarray: """Map a reference signal's absolute unwrapped phase to frequency (Hz). @@ -161,7 +96,7 @@ class KamilAdcSweepProcessor: def _phase_to_frequency(self, phase: np.ndarray) -> np.ndarray: """Apply the affine ``phase -> frequency`` calibration law.""" - return self._params.freq0_hz + (phase - self._params.phase0_rad) * self._params.hz_per_rad + return self._calibration.freq0_hz + (phase - self._calibration.phase0_rad) * self._hz_per_rad def _align_phase_branch(self, phase: np.ndarray) -> tuple[np.ndarray, float]: """Undo a stray +/-pi anchor wrap by unwrapping the anchor across sweeps. @@ -182,7 +117,7 @@ class KamilAdcSweepProcessor: reference_anchor = ( self._previous_anchor_rad if self._previous_anchor_rad is not None - else self._params.phase0_rad + else self._calibration.phase0_rad ) turns = round((reference_anchor - anchor) / _PHASE_BRANCH_PERIOD_RAD) if turns: @@ -213,7 +148,7 @@ class KamilAdcSweepProcessor: with np.errstate(divide="ignore", invalid="ignore"): s21 = main / reference_amplitude usable = ( - (reference_amplitude > self._params.reference_amplitude_floor) + (reference_amplitude > _REFERENCE_AMPLITUDE_FLOOR) & np.isfinite(freqs) & np.isfinite(s21.real) & np.isfinite(s21.imag) @@ -232,7 +167,7 @@ class KamilAdcSweepProcessor: # Reject sweeps that do not span the whole band: interpolating past the # measured edge would inject flat, non-physical points. - if freqs[0] > self._params.band_start_hz or freqs[-1] < self._params.band_stop_hz: + if freqs[0] > self._band.start_hz or freqs[-1] < self._band.stop_hz: return None # The sweep is accepted: commit its branch-aligned anchor so the next diff --git a/python_app/hardware_full/rph_radar/protocol.py b/python_app/hardware_full/rph_radar/protocol.py new file mode 100644 index 0000000..ffcb82a --- /dev/null +++ b/python_app/hardware_full/rph_radar/protocol.py @@ -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(" 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 diff --git a/python_app/hardware_full/kamil_adc/service.py b/python_app/hardware_full/rph_radar/service.py similarity index 61% rename from python_app/hardware_full/kamil_adc/service.py rename to python_app/hardware_full/rph_radar/service.py index f303509..0049989 100644 --- a/python_app/hardware_full/kamil_adc/service.py +++ b/python_app/hardware_full/rph_radar/service.py @@ -1,14 +1,15 @@ -"""Service that launches the Kamil ADC collector and serves processed sweeps. +"""Service that launches the RadioPhotonic radar (RPH) collector and serves processed sweeps. Owns the lifecycle of the external collector process and its TTY reader, and maps each raw (main, reference) sweep to an :class:`SweepResult` on the fixed -processing grid via :class:`KamilAdcSweepProcessor`. +processing grid via :class:`RphRadarSweepProcessor`. """ from __future__ import annotations from contextlib import suppress -from dataclasses import dataclass, field +from dataclasses import asdict, dataclass, field +import json import logging import os from pathlib import Path @@ -20,23 +21,21 @@ import time import numpy as np -from python_app.hardware_full.kamil_adc.processing import ( - KamilAdcProcessingParams, - KamilAdcSweepProcessor, -) -from python_app.hardware_full.kamil_adc.protocol import RawSweep -from python_app.hardware_full.kamil_adc.tty_reader import ( - KamilAdcTtyReader, +from python_app.hardware_full.rph_radar.processing import RphRadarSweepProcessor +from python_app.hardware_full.rph_radar.protocol import RawSweep +from python_app.hardware_full.rph_radar.tty_reader import ( + RphRadarTtyReader, raise_if_process_exited, ) from python_app.hardware_full.librevna_driver.models import SweepResult from python_app.models.dataset_model import ComboKey from python_app.models.run_config_schema import RadarSweepModel, RunConfigModel +from python_app.models.run_config_validation import validate_rph_radar_model logger = logging.getLogger(__name__) # Project root, used to resolve relative collector paths (e.g. -# ``build/bin/kamil_adc_collector``) independent of the launching CWD. +# ``build/bin/rph_radar_collector``) independent of the launching CWD. _REPO_ROOT = Path(__file__).resolve().parents[3] # Default home of the proprietary L-Card runtime libraries the collector loads # via dlopen. Prepended to LD_LIBRARY_PATH unless the config pins it explicitly. @@ -48,41 +47,36 @@ _REJECT_LOG_EVERY = 50 # brief window to release the device cleanly before escalating to SIGKILL. Caps # the configured stop_timeout_s so a stop can never hang. _STOP_KILL_GRACE_S = 0.5 -# Sweeps to skip after a Python-driven switch change before trusting a capture: one -# for the pre-switch sweep still in the mailbox, one for a possible transition -# straddler in flight. Calibration speed is not critical, so we err on safety. -_SWITCH_DRAIN_SWEEPS = 2 @dataclass(slots=True) -class KamilAdcService: - """Launch the external Kamil ADC collector and serve its processed sweeps.""" +class RphRadarService: + """Launch the external RadioPhotonic radar (RPH) collector and serve its processed sweeps.""" config: RunConfigModel - # When set, the collector is launched with ``config:`` so it drives the RF - # switches itself (switch-aware mode) from this run_config.json. ``None`` keeps - # the standalone collector that streams a single channel (calibration / mock). - switch_config_path: str | None = None + collector_driven: bool = False _process: subprocess.Popen[bytes] | None = field(init=False, default=None, repr=False) - _reader: KamilAdcTtyReader | None = field(init=False, default=None, repr=False) - _processor: KamilAdcSweepProcessor | None = field(init=False, default=None, repr=False) + _reader: RphRadarTtyReader | None = field(init=False, default=None, repr=False) + _processor: RphRadarSweepProcessor | None = field(init=False, default=None, repr=False) _rejected_count: int = field(init=False, default=0, repr=False) + _prepared_combo: ComboKey | None = field(init=False, default=None, repr=False) + last_cycle_id: int = field(init=False, default=0) + _output_thread: threading.Thread | None = field(init=False, default=None, repr=False) + _stop_event: threading.Event | None = field(init=False, default=None, repr=False) def __post_init__(self) -> None: self._validate_config() @property def command(self) -> list[str]: - """External collector command, including the generated ``tty:`` argument. - - In switch-aware mode (``switch_config_path`` set) the collector also gets - ``config:`` so it reads the switch/combo configuration and drives the - switches in lock-step with the sweeps. - """ - adc = self.config.radar.kamil_adc + """Pass the current typed settings once; the collector never rereads the profile.""" + adc = self.config.radar.rph_radar cmd = [str(self._resolve_executable()), *adc.args] - if self.switch_config_path is not None: - cmd.append(f"config:{self.switch_config_path}") + if self.collector_driven: + switching = {"input": asdict(self.config.input_switch), "output": asdict(self.config.output_switch), + "combos": [[combo.input, combo.output] for combo in self.config.combos], + "settling_ms": self.config.runtime.settling_ms} + cmd.append("switching:" + json.dumps(switching, separators=(",", ":"))) cmd.append(f"tty:{adc.tty_path}") return cmd @@ -94,51 +88,57 @@ class KamilAdcService: """ if self._reader is not None: return - previous_tty_identity = _prepare_tty_path_for_collector(self.config.radar.kamil_adc.tty_path) + if self._process is not None and self._process.poll() is None: + raise RuntimeError("Previous RPH collector did not stop; cannot start a fresh capture") + self._stop_event = stop_event + previous_tty_identity = _prepare_tty_path_for_collector(self.config.radar.rph_radar.tty_path) try: self._start_process() self._wait_for_tty(previous_tty_identity, stop_event=stop_event) - reader = KamilAdcTtyReader(self.config.radar.kamil_adc.tty_path) + reader = RphRadarTtyReader(self.config.radar.rph_radar.tty_path) reader.open() self._reader = reader - logger.info("Kamil ADC service opened") + logger.info("RadioPhotonic radar (RPH) service opened") except Exception: - logger.exception("Kamil ADC service failed to open; cleaning up") + logger.exception("RadioPhotonic radar (RPH) service failed to open; cleaning up") self.close() raise def close(self) -> None: """Stop the TTY reader and the external collector process.""" - logger.debug("Closing Kamil ADC service") + logger.debug("Closing RadioPhotonic radar (RPH) service") + self._prepared_combo = None if self._reader is not None: with suppress(Exception): self._reader.close() self._reader = None self._stop_process() + if self._output_thread is not None: + self._output_thread.join(timeout=1.0) + self._output_thread = None def configure(self, sweep: RadarSweepModel) -> None: - """Build the sweep processor from the ``radar.kamil_adc`` calibration/band. + """Build the sweep processor from the ``radar.rph_radar`` calibration/band. The generic ``sweep`` argument is accepted for interface parity with the - other radar services but is not used: the Kamil ADC frequency axis comes + other radar services but is not used: the RadioPhotonic radar (RPH) frequency axis comes from the reference-phase calibration, and the output grid from - ``radar.kamil_adc.band`` — never from the nominal sweep bounds. + ``radar.rph_radar.band`` — never from the nominal sweep bounds. """ - self._processor = KamilAdcSweepProcessor( - KamilAdcProcessingParams.from_kamil_model(self.config.radar.kamil_adc) - ) + adc = self.config.radar.rph_radar + self._processor = RphRadarSweepProcessor(adc) self._rejected_count = 0 - params = self._processor.params + phase, band = adc.phase_calibration, adc.band logger.debug( - "Kamil ADC configured: band %.6g-%.6g Hz, %d points; calibration " + "RadioPhotonic radar (RPH) configured: band %.6g-%.6g Hz, %d points; calibration " "(%.6g rad -> %.6g Hz, %.6g rad -> %.6g Hz)", - params.band_start_hz, params.band_stop_hz, params.band_points, - params.phase0_rad, params.freq0_hz, params.phase1_rad, params.freq1_hz, + band.start_hz, band.stop_hz, band.points, + phase.phase0_rad, phase.freq0_hz, phase.phase1_rad, phase.freq1_hz, ) def read_device_limits(self) -> dict[str, float | int]: - """Kamil ADC has no runtime-readable sweep-limit API.""" - raise RuntimeError("Kamil ADC device limits are not available") + """RadioPhotonic radar (RPH) has no runtime-readable sweep-limit API.""" + raise RuntimeError("RadioPhotonic radar (RPH) device limits are not available") def acquire(self, combo: tuple[int, int] | None = None) -> SweepResult: """Return the next sweep that covers the band, as S21 on the fixed grid. @@ -153,65 +153,45 @@ class KamilAdcService: single newest sweep is returned regardless of combination. """ if self._processor is None: - raise RuntimeError("Kamil ADC service is not configured") + raise RuntimeError("RadioPhotonic radar (RPH) service is not configured") if self._reader is None: - raise RuntimeError("Kamil ADC service is not open") + raise RuntimeError("RadioPhotonic radar (RPH) service is not open") process = self._process if process is None or process.poll() is not None: return_code = None if process is None else process.poll() - raise RuntimeError(f"Kamil ADC collector is not running (code={return_code})") + raise RuntimeError(f"RadioPhotonic radar (RPH) collector is not running (code={return_code})") grid = self._processor.grid_hz - points = int(grid.size) - deadline = time.monotonic() + self.config.radar.kamil_adc.sweep_timeout_s + deadline = time.monotonic() + self.config.radar.rph_radar.sweep_timeout_s while True: remaining_s = deadline - time.monotonic() if remaining_s <= 0.0: raise TimeoutError( - "Timed out waiting for a Kamil ADC sweep covering the configured band" + "Timed out waiting for a RadioPhotonic radar (RPH) sweep covering the configured band" ) - if combo is None: - raw = self._reader.read_sweep(timeout_s=remaining_s, process=process) - else: - raw = self._reader.read_sweep_for(combo, timeout_s=remaining_s, process=process) + raw = self._reader.read_sweep(timeout_s=remaining_s, combo=combo, process=process) s21 = self._processor.process(raw.main, raw.reference) if s21 is not None: + self.last_cycle_id = raw.cycle_id return SweepResult( - x=grid.copy(), + x=grid, traces={ "s21": s21, }, + capture_start_ns=raw.capture_start_ns, + capture_end_ns=raw.capture_end_ns, ) self._log_rejected_sweep(raw) def prepare_combo(self, combo: ComboKey) -> None: - """Discard buffered sweeps that predate or straddle the physical switch.""" - self.drain_after_switch() - - def drain_after_switch(self, sweeps: int = _SWITCH_DRAIN_SWEEPS) -> None: - """Discard sweeps captured before / across a just-applied switch change. - - The collector free-runs, so right after the RF switches move the reader - still holds a sweep captured in the *previous* combination, and a sweep that - straddles the transition may still be in flight. Without this, the next - :meth:`acquire` would return that stale data and the capture would be - attributed to the wrong combination (an off-by-one across the sequence). - - Block until ``sweeps`` freshly-published sweeps have gone by, so the next - :meth:`acquire` returns a sweep captured entirely in the new switch state. - Used by the Python-driven calibration capture, where the collector does not - tag sweeps; the switch-aware collector path handles this with combo tags - instead. No-op when the service is not open. - """ - if self._reader is None: + """Start a fresh ADC session after switching; retain phase branch tracking.""" + if self._prepared_combo == combo: return - target = self._reader.published_count + max(1, int(sweeps)) - deadline = time.monotonic() + self.config.radar.kamil_adc.sweep_timeout_s - while self._reader.published_count < target: - if time.monotonic() > deadline: - raise TimeoutError("Timed out draining Kamil ADC sweeps after a switch change") - raise_if_process_exited(self._process) - time.sleep(0.005) + self.close() + if self.config.runtime.settling_ms < 1: + time.sleep(.001) # The caller already waited any configured settling. + self.open(stop_event=self._stop_event) + self._prepared_combo = combo def read_raw_sweep(self) -> RawSweep: """Return the next raw (main, reference) sweep without any processing. @@ -221,9 +201,9 @@ class KamilAdcService: unprocessed reference samples. Raises if the service is not open. """ if self._reader is None: - raise RuntimeError("Kamil ADC service is not open") + raise RuntimeError("RadioPhotonic radar (RPH) service is not open") return self._reader.read_sweep( - timeout_s=self.config.radar.kamil_adc.sweep_timeout_s, + timeout_s=self.config.radar.rph_radar.sweep_timeout_s, process=self._process, ) @@ -234,34 +214,45 @@ class KamilAdcService: def _start_process(self) -> None: if self._process is not None and self._process.poll() is None: return - logger.info("Starting Kamil ADC collector: %s", " ".join(self.command)) + logger.info("Starting RadioPhotonic radar (RPH) collector: %s", " ".join(self.command)) self._process = subprocess.Popen( self.command, cwd=str(self._resolve_project_dir()), env=self._build_env(), stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True, ) + self._output_thread = threading.Thread(target=self._log_output, args=(self._process,), + name="rph-radar-log", daemon=True) + self._output_thread.start() + + @staticmethod + def _log_output(process: subprocess.Popen[bytes]) -> None: + if process.stdout is not None: + with process.stdout: + for line in process.stdout: + logger.info("Collector: %s", line.decode("utf-8", errors="replace").rstrip()) def _stop_process(self) -> None: process = self._process - self._process = None if process is None or process.poll() is not None: + self._process = None return - logger.info("Stopping Kamil ADC collector (pid=%d)", process.pid) + logger.info("Stopping RadioPhotonic radar (RPH) collector (pid=%d)", process.pid) # The collector's graceful X502 teardown can block, so give it only a brief # window to release the device cleanly, then SIGKILL the whole group hard. - grace_s = min(self.config.radar.kamil_adc.stop_timeout_s, _STOP_KILL_GRACE_S) + grace_s = min(self.config.radar.rph_radar.stop_timeout_s, _STOP_KILL_GRACE_S) with suppress(ProcessLookupError): os.killpg(process.pid, signal.SIGTERM) try: process.wait(timeout=grace_s) + self._process = None return except subprocess.TimeoutExpired: pass - logger.warning("Kamil ADC collector (pid=%d) did not stop in %.1fs; sending SIGKILL", process.pid, grace_s) + logger.warning("RadioPhotonic radar (RPH) collector (pid=%d) did not stop in %.1fs; sending SIGKILL", process.pid, grace_s) with suppress(ProcessLookupError): os.killpg(process.pid, signal.SIGKILL) # close() must never raise: a collector wedged in uninterruptible I/O @@ -269,6 +260,9 @@ class KamilAdcService: # window even after SIGKILL. Best-effort wait; the OS reaps it eventually. with suppress(subprocess.TimeoutExpired): process.wait(timeout=1.0) + # Retain ownership in D-state so retries cannot launch a second collector. + if process.poll() is not None: + self._process = None def _wait_for_tty( self, @@ -276,22 +270,22 @@ class KamilAdcService: *, stop_event: threading.Event | None = None, ) -> None: - adc = self.config.radar.kamil_adc + adc = self.config.radar.rph_radar deadline = time.monotonic() + adc.startup_timeout_s while time.monotonic() < deadline: if stop_event is not None and stop_event.is_set(): - raise RuntimeError("Kamil ADC startup aborted before TTY became available") + raise RuntimeError("RadioPhotonic radar (RPH) startup aborted before TTY became available") raise_if_process_exited(self._process) identity = _tty_identity(adc.tty_path) if identity is not None and identity != previous_identity: return if stop_event is not None: if stop_event.wait(0.05): - raise RuntimeError("Kamil ADC startup aborted before TTY became available") + raise RuntimeError("RadioPhotonic radar (RPH) startup aborted before TTY became available") else: time.sleep(0.05) raise TimeoutError( - f"Timed out waiting for Kamil ADC TTY `{adc.tty_path}` to be created by the collector" + f"Timed out waiting for RadioPhotonic radar (RPH) TTY `{adc.tty_path}` to be created by the collector" ) # ------------------------------------------------------------------ @@ -299,10 +293,10 @@ class KamilAdcService: # ------------------------------------------------------------------ def _resolve_executable(self) -> Path: - return self._resolve_path(self.config.radar.kamil_adc.executable_path) + return self._resolve_path(self.config.radar.rph_radar.executable_path) def _resolve_project_dir(self) -> Path: - project_dir = self.config.radar.kamil_adc.project_dir + project_dir = self.config.radar.rph_radar.project_dir return self._resolve_path(project_dir) if project_dir else _REPO_ROOT @staticmethod @@ -318,7 +312,7 @@ class KamilAdcService: ``LD_LIBRARY_PATH`` itself, the default L-Card library directory is prepended so the collector's dlopen of libx502api/libe502api succeeds. """ - adc = self.config.radar.kamil_adc + adc = self.config.radar.rph_radar env = os.environ.copy() for key, value in adc.env.items(): env[key] = os.path.expandvars(os.path.expanduser(value)) @@ -333,33 +327,29 @@ class KamilAdcService: # ------------------------------------------------------------------ def _validate_config(self) -> None: - if not self.config.is_kamil_adc: - raise RuntimeError("KamilAdcService requires radar.model='kamil_adc'") + if not self.config.is_rph_radar: + raise RuntimeError("RphRadarService requires radar.model='rph_radar'") if self.config.radar.driver_mode != "native": - raise RuntimeError("Kamil ADC requires radar.driver_mode='native'") + raise RuntimeError("RadioPhotonic radar (RPH) requires radar.driver_mode='native'") - adc = self.config.radar.kamil_adc + adc = self.config.radar.rph_radar + validate_rph_radar_model(adc) if not adc.executable_path: - raise ValueError("radar.kamil_adc.executable_path is required") + raise ValueError("radar.rph_radar.executable_path is required") if not adc.tty_path: - raise ValueError("radar.kamil_adc.tty_path is required") + raise ValueError("radar.rph_radar.tty_path is required") if any(arg.startswith("tty:") for arg in adc.args): - raise ValueError("radar.kamil_adc.args must not contain tty:; use tty_path instead") - for name in ("startup_timeout_s", "sweep_timeout_s", "stop_timeout_s"): - if getattr(adc, name) <= 0.0: - raise ValueError(f"radar.kamil_adc.{name} must be > 0") + raise ValueError("radar.rph_radar.args must not contain tty:; use tty_path instead") project_dir = self._resolve_project_dir() if not project_dir.is_dir(): - raise RuntimeError(f"radar.kamil_adc.project_dir is not a directory: {project_dir}") + raise RuntimeError(f"radar.rph_radar.project_dir is not a directory: {project_dir}") executable_path = self._resolve_executable() if not executable_path.is_file(): - raise RuntimeError(f"radar.kamil_adc.executable_path is not a file: {executable_path}") + raise RuntimeError(f"radar.rph_radar.executable_path is not a file: {executable_path}") if not os.access(executable_path, os.X_OK): - raise RuntimeError(f"radar.kamil_adc.executable_path is not executable: {executable_path}") + raise RuntimeError(f"radar.rph_radar.executable_path is not executable: {executable_path}") - # Surface a malformed calibration/band at open time, not mid-acquisition. - KamilAdcProcessingParams.from_kamil_model(adc) def _log_rejected_sweep(self, raw) -> None: """Log a band-coverage rejection (throttled) with the measured span.""" @@ -367,16 +357,16 @@ class KamilAdcService: if self._rejected_count != 1 and self._rejected_count % _REJECT_LOG_EVERY != 0: return assert self._processor is not None - params = self._processor.params + band = self.config.radar.rph_radar.band try: freqs = self._processor.reference_frequency_axis(raw.reference) covered = f"[{float(np.min(freqs)):.6g}, {float(np.max(freqs)):.6g}]" except Exception: # noqa: BLE001 — diagnostics must never raise covered = "" logger.warning( - "Kamil ADC sweep rejected (count=%d): covered %s Hz does not span band " + "RadioPhotonic radar (RPH) sweep rejected (count=%d): covered %s Hz does not span band " "[%.6g, %.6g] Hz (usable points=%d). Check phase_calibration/band.", - self._rejected_count, covered, params.band_start_hz, params.band_stop_hz, raw.size, + self._rejected_count, covered, band.start_hz, band.stop_hz, raw.size, ) diff --git a/python_app/hardware_full/kamil_adc/tty_reader.py b/python_app/hardware_full/rph_radar/tty_reader.py similarity index 70% rename from python_app/hardware_full/kamil_adc/tty_reader.py rename to python_app/hardware_full/rph_radar/tty_reader.py index 28873c1..6a1751e 100644 --- a/python_app/hardware_full/kamil_adc/tty_reader.py +++ b/python_app/hardware_full/rph_radar/tty_reader.py @@ -1,8 +1,8 @@ -"""Background TTY reader publishing the latest completed Kamil ADC sweep. +"""Background TTY reader publishing the latest completed RadioPhotonic radar (RPH) sweep. The collector emits sweeps continuously, faster than callers invoke -:meth:`KamilAdcService.acquire`. A daemon thread drains the device end of the -TTY non-stop, feeds the bytes to a :class:`KamilAdcStreamParser`, and stores the +:meth:`RphRadarService.acquire`. A daemon thread drains the device end of the +TTY non-stop, feeds the bytes to a :class:`RphRadarStreamParser`, and stores the most recent :class:`RawSweep` in a single-slot mailbox. :meth:`read_sweep` returns the freshest sweep; if a newer one arrives before the consumer reads, it overwrites the previous unread value — by design, since consumers always want the @@ -21,7 +21,7 @@ import subprocess import threading import time -from python_app.hardware_full.kamil_adc.protocol import KamilAdcStreamParser, RawSweep +from python_app.hardware_full.rph_radar.protocol import RphRadarStreamParser, RawSweep logger = logging.getLogger(__name__) @@ -39,11 +39,11 @@ def raise_if_process_exited(process: subprocess.Popen[bytes] | None) -> None: return return_code = process.poll() if return_code is not None: - raise RuntimeError(f"Kamil ADC collector exited with code {return_code}") + raise RuntimeError(f"RadioPhotonic radar (RPH) collector exited with code {return_code}") @dataclass(slots=True) -class KamilAdcTtyReader: +class RphRadarTtyReader: """Daemon-thread TTY reader publishing the latest completed sweep.""" tty_path: str @@ -54,7 +54,7 @@ class KamilAdcTtyReader: _latest_sweep: RawSweep | None = field(init=False, default=None, repr=False) # Latest clean sweep per switch combination, for the switch-aware collector. # read_sweep() ignores this and serves the single newest sweep (calibration / - # non-switch mode); read_sweep_for() serves a specific combination. + # non-switch mode); an optional combo selects its own slot. _combo_slots: dict[tuple[int, int], RawSweep] = field(init=False, default_factory=dict, repr=False) _reader_error: Exception | None = field(init=False, default=None, repr=False) _published_count: int = field(init=False, default=0, repr=False) @@ -71,7 +71,7 @@ class KamilAdcTtyReader: self._published_count = 0 self._thread = threading.Thread( target=self._reader_loop, - name=f"kamil-adc-tty-reader[{self.tty_path}]", + name=f"rph-radar-tty-reader[{self.tty_path}]", daemon=True, ) try: @@ -81,18 +81,18 @@ class KamilAdcTtyReader: fd, self._fd = self._fd, None os.close(fd) raise - logger.info("Kamil ADC TTY reader started on %s", self.tty_path) + logger.info("RadioPhotonic radar (RPH) TTY reader started on %s", self.tty_path) def close(self) -> None: """Stop the reader thread and close the TTY descriptor.""" - logger.debug("Stopping Kamil ADC TTY reader on %s", self.tty_path) + logger.debug("Stopping RadioPhotonic radar (RPH) TTY reader on %s", self.tty_path) self._stop_event.set() with self._mailbox_cv: self._mailbox_cv.notify_all() if self._thread is not None: self._thread.join(timeout=1.0) if self._thread.is_alive(): - logger.warning("Kamil ADC reader thread did not stop within 1.0s") + logger.warning("RadioPhotonic radar (RPH) reader thread did not stop within 1.0s") self._thread = None if self._fd is not None: try: @@ -113,6 +113,7 @@ class KamilAdcTtyReader: self, *, timeout_s: float, + combo: tuple[int, int] | None = None, process: subprocess.Popen[bytes] | None = None, ) -> RawSweep: """Wait for and return the next published sweep. @@ -122,52 +123,19 @@ class KamilAdcTtyReader: error caught by the reader thread. """ if self._thread is None: - raise RuntimeError("Kamil ADC TTY reader is not open") + raise RuntimeError("RadioPhotonic radar (RPH) TTY reader is not open") deadline = time.monotonic() + float(timeout_s) with self._mailbox_cv: while True: if self._stop_event.is_set(): - raise RuntimeError("Kamil ADC TTY reader is closed") + raise RuntimeError("RadioPhotonic radar (RPH) TTY reader is closed") # Deliver a pending sweep first: if the reader both published a # sweep and then died, the consumer still sees the good data and # only meets the error on the next call. - if self._latest_sweep is not None: - sweep = self._latest_sweep - self._latest_sweep = None - return sweep - if self._reader_error is not None: - raise self._reader_error - raise_if_process_exited(process) - remaining_s = deadline - time.monotonic() - if remaining_s <= 0.0: - raise TimeoutError( - f"Timed out waiting for Kamil ADC sweep after {float(timeout_s):.3f}s" - ) - self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s)) - - def read_sweep_for( - self, - combo: tuple[int, int], - *, - timeout_s: float, - process: subprocess.Popen[bytes] | None = None, - ) -> RawSweep: - """Wait for and return the latest clean sweep for ``combo``. - - Used in switch-aware mode, where the collector drives the switches and tags - each sweep with its combination. Only clean sweeps are delivered (the reader - thread drops the dirty ones); the slot is consumed on read so each caller - gets a fresh capture. Raises like :meth:`read_sweep`. - """ - if self._thread is None: - raise RuntimeError("Kamil ADC TTY reader is not open") - deadline = time.monotonic() + float(timeout_s) - with self._mailbox_cv: - while True: - if self._stop_event.is_set(): - raise RuntimeError("Kamil ADC TTY reader is closed") - sweep = self._combo_slots.pop(combo, None) + sweep = self._latest_sweep if combo is None else self._combo_slots.pop(combo, None) if sweep is not None: + if combo is None: + self._latest_sweep = None return sweep if self._reader_error is not None: raise self._reader_error @@ -175,18 +143,13 @@ class KamilAdcTtyReader: remaining_s = deadline - time.monotonic() if remaining_s <= 0.0: raise TimeoutError( - f"Timed out waiting for Kamil ADC sweep for combo {combo} " - f"after {float(timeout_s):.3f}s" + f"Timed out waiting for RadioPhotonic radar (RPH) sweep after {float(timeout_s):.3f}s" ) self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s)) - # ------------------------------------------------------------------ - # Reader-thread internals - # ------------------------------------------------------------------ - def _reader_loop(self) -> None: """Drain the TTY, parse sweeps, and publish each completed one until stop.""" - parser = KamilAdcStreamParser() + parser = RphRadarStreamParser() try: while not self._stop_event.is_set(): chunk = self._read_available() @@ -195,7 +158,7 @@ class KamilAdcTtyReader: for sweep in parser.feed(chunk): self._publish_sweep(sweep) except Exception as exc: # noqa: BLE001 — surfaced to the consumer via read_sweep - logger.exception("Kamil ADC reader thread failed on %s", self.tty_path) + logger.exception("RadioPhotonic radar (RPH) reader thread failed on %s", self.tty_path) self._publish_error(exc) def _read_available(self) -> bytes: @@ -220,9 +183,9 @@ class KamilAdcTtyReader: except OSError as exc: if exc.errno in {errno.EAGAIN, errno.EWOULDBLOCK}: return b"" - raise RuntimeError(f"Failed to read Kamil ADC TTY `{self.tty_path}`: {exc}") from exc + raise RuntimeError(f"Failed to read RadioPhotonic radar (RPH) TTY `{self.tty_path}`: {exc}") from exc if not chunk: - raise RuntimeError(f"Kamil ADC TTY `{self.tty_path}` closed while reading") + raise RuntimeError(f"RadioPhotonic radar (RPH) TTY `{self.tty_path}` closed while reading") return chunk def _publish_sweep(self, sweep: RawSweep) -> None: diff --git a/python_app/hardware_full/single_radar_service.py b/python_app/hardware_full/single_radar_service.py index 5d688f0..47bcb4a 100644 --- a/python_app/hardware_full/single_radar_service.py +++ b/python_app/hardware_full/single_radar_service.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging from typing import Protocol -from python_app.hardware_full.kamil_adc import KamilAdcService +from python_app.hardware_full.rph_radar.service import RphRadarService from python_app.hardware_full.librevna_driver.models import SweepResult from python_app.hardware_full.librevna_service import LibreVnaService from python_app.models.dataset_model import ComboKey @@ -55,9 +55,9 @@ def create_single_radar_service(config: RunConfigModel) -> SingleRadarService: return PointTargetMockService(config) return LibreVnaService(serial=config.radar.serial or None) - if model == RunConfigModel.KAMIL_ADC_MODEL: + if model == RunConfigModel.RPH_RADAR_MODEL: if config.radar.driver_mode != "native": - raise RuntimeError("Kamil ADC requires radar.driver_mode='native'") - return KamilAdcService(config) + raise RuntimeError("RadioPhotonic radar (RPH) requires radar.driver_mode='native'") + return RphRadarService(config) raise RuntimeError(f"Unsupported single-radar model: {model}") diff --git a/python_app/models/run_config_codec.py b/python_app/models/run_config_codec.py index 4dffb08..2068bd7 100644 --- a/python_app/models/run_config_codec.py +++ b/python_app/models/run_config_codec.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import asdict import logging from typing import Any @@ -105,8 +106,10 @@ def run_config_from_dict(payload: object) -> RunConfigModel: run_payload.get("locator_server", run_payload.get("locator")), "run.locator_server", ) + if radar_payload.get("model") == "kamil_adc": + raise ValueError("radar.model kamil_adc was renamed: use rph_radar and move radar.kamil_adc to radar.rph_radar") multi_device_payload = _as_dict(radar_payload.get("multi_device"), "radar.multi_device") - kamil_adc_payload = _as_dict(radar_payload.get("kamil_adc"), "radar.kamil_adc") + rph_radar_payload = _as_dict(radar_payload.get("rph_radar"), "radar.rph_radar") laser_control_payload = _as_dict(radar_payload.get("laser_control"), "radar.laser_control") model.radar.model = _read_str(radar_payload, "model", model.radar.model) @@ -162,36 +165,36 @@ def run_config_from_dict(payload: object) -> RunConfigModel: "stimulus_switch_positions", model.radar.multi_device.stimulus_switch_positions, ) - model.radar.kamil_adc.project_dir = _read_str( - kamil_adc_payload, "project_dir", model.radar.kamil_adc.project_dir + model.radar.rph_radar.project_dir = _read_str( + rph_radar_payload, "project_dir", model.radar.rph_radar.project_dir ) - model.radar.kamil_adc.executable_path = _read_str( - kamil_adc_payload, "executable_path", model.radar.kamil_adc.executable_path + model.radar.rph_radar.executable_path = _read_str( + rph_radar_payload, "executable_path", model.radar.rph_radar.executable_path ) - model.radar.kamil_adc.tty_path = _read_str( - kamil_adc_payload, "tty_path", model.radar.kamil_adc.tty_path + model.radar.rph_radar.tty_path = _read_str( + rph_radar_payload, "tty_path", model.radar.rph_radar.tty_path ) - model.radar.kamil_adc.args = _load_string_list(kamil_adc_payload, "args", "radar.kamil_adc") - model.radar.kamil_adc.env = _load_string_dict(kamil_adc_payload, "env", "radar.kamil_adc") - model.radar.kamil_adc.startup_timeout_s = _read_float( - kamil_adc_payload, "startup_timeout_s", model.radar.kamil_adc.startup_timeout_s + model.radar.rph_radar.args = _load_string_list(rph_radar_payload, "args", "radar.rph_radar") + model.radar.rph_radar.env = _load_string_dict(rph_radar_payload, "env", "radar.rph_radar") + model.radar.rph_radar.startup_timeout_s = _read_float( + rph_radar_payload, "startup_timeout_s", model.radar.rph_radar.startup_timeout_s ) - model.radar.kamil_adc.sweep_timeout_s = _read_float( - kamil_adc_payload, "sweep_timeout_s", model.radar.kamil_adc.sweep_timeout_s + model.radar.rph_radar.sweep_timeout_s = _read_float( + rph_radar_payload, "sweep_timeout_s", model.radar.rph_radar.sweep_timeout_s ) - model.radar.kamil_adc.stop_timeout_s = _read_float( - kamil_adc_payload, "stop_timeout_s", model.radar.kamil_adc.stop_timeout_s + model.radar.rph_radar.stop_timeout_s = _read_float( + rph_radar_payload, "stop_timeout_s", model.radar.rph_radar.stop_timeout_s ) phase_calibration_payload = _as_dict( - kamil_adc_payload.get("phase_calibration"), "radar.kamil_adc.phase_calibration" + rph_radar_payload.get("phase_calibration"), "radar.rph_radar.phase_calibration" ) - calibration = model.radar.kamil_adc.phase_calibration + calibration = model.radar.rph_radar.phase_calibration calibration.phase0_rad = _read_float(phase_calibration_payload, "phase0_rad", calibration.phase0_rad) calibration.freq0_hz = _read_float(phase_calibration_payload, "freq0_hz", calibration.freq0_hz) calibration.phase1_rad = _read_float(phase_calibration_payload, "phase1_rad", calibration.phase1_rad) calibration.freq1_hz = _read_float(phase_calibration_payload, "freq1_hz", calibration.freq1_hz) - band_payload = _as_dict(kamil_adc_payload.get("band"), "radar.kamil_adc.band") - band = model.radar.kamil_adc.band + band_payload = _as_dict(rph_radar_payload.get("band"), "radar.rph_radar.band") + band = model.radar.rph_radar.band band.start_hz = _read_float(band_payload, "start_hz", band.start_hz) band.stop_hz = _read_float(band_payload, "stop_hz", band.stop_hz) band.points = _read_int(band_payload, "points", band.points) @@ -274,11 +277,6 @@ def run_config_from_dict(payload: object) -> RunConfigModel: model.radar.laser_control.variation.delay_time = _read_int( laser_variation_payload, "delay_time", model.radar.laser_control.variation.delay_time ) - model.radar.laser_control.variation.temp_tolerance_c = _read_float( - laser_variation_payload, - "temp_tolerance_c", - model.radar.laser_control.variation.temp_tolerance_c, - ) load_switch_payload(port1_payload, model.output_switch) load_switch_payload(port2_payload, model.input_switch) @@ -410,200 +408,22 @@ def run_config_from_dict(payload: object) -> RunConfigModel: def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]: - """Encode :class:`RunConfigModel` to C++ pipeline-compatible JSON structure.""" + """Encode the model, spelling only fields whose wire names differ.""" model.ensure_combos() - sweep_payload = { - "start_hz": model.radar.sweep.start_hz, - "stop_hz": model.radar.sweep.stop_hz, - "if_bandwidth_hz": model.radar.sweep.if_bandwidth_hz, - "stimulus_power_dbm": model.radar.sweep.power_dbm, - "dwell_us": model.radar.sweep.dwell_us, - } - if not model.is_kamil_adc: - sweep_payload["points"] = model.radar.sweep.points - + radar = asdict(model.radar) + sweep = radar["sweep"] + sweep["stimulus_power_dbm"] = sweep.pop("power_dbm") + if model.is_rph_radar: + sweep.pop("points") + preprocess = asdict(model.preprocess) + preprocess["notch"]["bands_hz"] = [list(band) for band in model.preprocess.notch.bands_hz] return { - "radar": { - "model": model.radar.model, - "serial": model.radar.serial, - "driver_mode": model.radar.driver_mode, - "multi_device": { - "slave_serials": list(model.radar.multi_device.slave_serials), - "force_external_reference": model.radar.multi_device.force_external_reference, - "recovery_attempts": model.radar.multi_device.recovery_attempts, - "output_switch_positions": model.radar.multi_device.output_switch_positions, - "input_switch_positions": model.radar.multi_device.input_switch_positions, - "stimulus_switch_positions": model.radar.multi_device.stimulus_switch_positions - }, - "kamil_adc": { - "project_dir": model.radar.kamil_adc.project_dir, - "executable_path": model.radar.kamil_adc.executable_path, - "tty_path": model.radar.kamil_adc.tty_path, - "args": list(model.radar.kamil_adc.args), - "env": dict(model.radar.kamil_adc.env), - "startup_timeout_s": model.radar.kamil_adc.startup_timeout_s, - "sweep_timeout_s": model.radar.kamil_adc.sweep_timeout_s, - "stop_timeout_s": model.radar.kamil_adc.stop_timeout_s, - "phase_calibration": { - "phase0_rad": model.radar.kamil_adc.phase_calibration.phase0_rad, - "freq0_hz": model.radar.kamil_adc.phase_calibration.freq0_hz, - "phase1_rad": model.radar.kamil_adc.phase_calibration.phase1_rad, - "freq1_hz": model.radar.kamil_adc.phase_calibration.freq1_hz, - }, - "band": { - "start_hz": model.radar.kamil_adc.band.start_hz, - "stop_hz": model.radar.kamil_adc.band.stop_hz, - "points": model.radar.kamil_adc.band.points, - }, - }, - "laser_control": { - "enabled": model.radar.laser_control.enabled, - "port": model.radar.laser_control.port, - "mode": model.radar.laser_control.mode, - "pi_coeff1_p": model.radar.laser_control.pi_coeff1_p, - "pi_coeff1_i": model.radar.laser_control.pi_coeff1_i, - "pi_coeff2_p": model.radar.laser_control.pi_coeff2_p, - "pi_coeff2_i": model.radar.laser_control.pi_coeff2_i, - "manual": { - "temp1": model.radar.laser_control.manual.temp1, - "temp2": model.radar.laser_control.manual.temp2, - "current1": model.radar.laser_control.manual.current1, - "current2": model.radar.laser_control.manual.current2, - }, - "variation": { - "variation_type": model.radar.laser_control.variation.variation_type, - "static_temp1": model.radar.laser_control.variation.static_temp1, - "static_temp2": model.radar.laser_control.variation.static_temp2, - "static_current1": model.radar.laser_control.variation.static_current1, - "static_current2": model.radar.laser_control.variation.static_current2, - "min_value": model.radar.laser_control.variation.min_value, - "max_value": model.radar.laser_control.variation.max_value, - "step": model.radar.laser_control.variation.step, - "time_step": model.radar.laser_control.variation.time_step, - "delay_time": model.radar.laser_control.variation.delay_time, - "temp_tolerance_c": model.radar.laser_control.variation.temp_tolerance_c, - }, - }, - "sweep": sweep_payload, - }, - "switches": { - "port1": { - "name": model.output_switch.name, - "driver_mode": model.output_switch.driver_mode, - "radar_port": model.output_switch.radar_port, - "positions": model.output_switch.positions, - "default_position": model.output_switch.default_position, - "gpio_chip": model.output_switch.gpio_chip, - "pin_a": model.output_switch.pin_a, - "pin_b": model.output_switch.pin_b, - }, - "port2": { - "name": model.input_switch.name, - "driver_mode": model.input_switch.driver_mode, - "radar_port": model.input_switch.radar_port, - "positions": model.input_switch.positions, - "default_position": model.input_switch.default_position, - "gpio_chip": model.input_switch.gpio_chip, - "pin_a": model.input_switch.pin_a, - "pin_b": model.input_switch.pin_b, - }, - }, - "control_button": { - "enabled": model.control_button.enabled, - "gpio_chip": model.control_button.gpio_chip, - "pin": model.control_button.pin, - "active_low": model.control_button.active_low, - "bias": model.control_button.bias, - "debounce_ms": model.control_button.debounce_ms, - "action": model.control_button.action, - }, - "logging": { - "level": model.logging.level, - }, - "run": { - "settling_ms": model.runtime.settling_ms, - "idle_sleep_ms": model.runtime.idle_sleep_ms, - "continuous": model.runtime.continuous, - "processing_live_config_path": model.runtime.processing_live_config_path, - "locator_server": { - "device_id": model.runtime.locator_server.device_id, - "protocol_version": model.runtime.locator_server.protocol_version, - "host": model.runtime.locator_server.host, - "port": model.runtime.locator_server.port, - "max_payload_bytes": model.runtime.locator_server.max_payload_bytes, - "client_queue_size": model.runtime.locator_server.client_queue_size, - "logger_name": model.runtime.locator_server.logger_name, - }, - "combos": [{"input": combo.input, "output": combo.output} for combo in model.combos], - }, - "preprocess": { - "s21": { - "calibration": { - "set_name": model.preprocess.s21.calibration.set_name, - "bundle_path": model.preprocess.s21.calibration.bundle_path, - }, - "reference": { - "set_name": model.preprocess.s21.reference.set_name, - "bundle_path": model.preprocess.s21.reference.bundle_path, - }, - "object_calibration": { - "set_name": model.preprocess.s21.object_calibration.set_name, - "bundle_path": model.preprocess.s21.object_calibration.bundle_path, - }, - }, - "notch": { - "enabled": model.preprocess.notch.enabled, - "bands_hz": [[low_hz, high_hz] for low_hz, high_hz in model.preprocess.notch.bands_hz], - "taper_width_hz": model.preprocess.notch.taper_width_hz, - "taper_type": model.preprocess.notch.taper_type, - }, - }, - "gpr": { - "relative_permittivity": model.gpr.relative_permittivity, - "tx_geometry": [ - { - "output_pos": entry.output_pos, - "x_m": entry.x_m, - "y_m": entry.y_m, - "z_m": entry.z_m, - } - for entry in model.gpr.tx_geometry - ], - "rx_geometry": [ - { - "input_pos": entry.input_pos, - "x_m": entry.x_m, - "y_m": entry.y_m, - "z_m": entry.z_m, - } - for entry in model.gpr.rx_geometry - ], - }, - "rings": { - "raw": { - "name": model.rings.raw.name, - "capacity": model.rings.raw.capacity, - "slot_size_bytes": model.rings.raw.slot_size_bytes, - }, - "raw_tap": { - "name": model.rings.raw_tap.name, - "capacity": model.rings.raw_tap.capacity, - "slot_size_bytes": model.rings.raw_tap.slot_size_bytes, - }, - "preprocessed": { - "name": model.rings.preprocessed.name, - "capacity": model.rings.preprocessed.capacity, - "slot_size_bytes": model.rings.preprocessed.slot_size_bytes, - }, - "preprocessed_tap": { - "name": model.rings.preprocessed_tap.name, - "capacity": model.rings.preprocessed_tap.capacity, - "slot_size_bytes": model.rings.preprocessed_tap.slot_size_bytes, - }, - "results": { - "name": model.rings.results.name, - "capacity": model.rings.results.capacity, - "slot_size_bytes": model.rings.results.slot_size_bytes, - }, - }, + "radar": radar, + "switches": {"port1": asdict(model.output_switch), "port2": asdict(model.input_switch)}, + "control_button": asdict(model.control_button), + "logging": asdict(model.logging), + "run": {**asdict(model.runtime), "combos": [asdict(combo) for combo in model.combos]}, + "preprocess": preprocess, + "gpr": asdict(model.gpr), + "rings": asdict(model.rings), } diff --git a/python_app/models/run_config_schema.py b/python_app/models/run_config_schema.py index d24e587..3f645ad 100644 --- a/python_app/models/run_config_schema.py +++ b/python_app/models/run_config_schema.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import asdict, dataclass, field import hashlib import json import logging @@ -45,7 +45,7 @@ class RadarMultiDeviceModel: """Multi-device LibreVNA topology settings.""" slave_serials: list[str] = field(default_factory=list) - force_external_reference: bool = True + force_external_reference: bool = False recovery_attempts: int = 3 output_switch_positions: int = 1 # 1 = свитча нет input_switch_positions: int = 1 # 1 = свитча нет @@ -56,7 +56,7 @@ class RadarMultiDeviceModel: @dataclass(slots=True) -class KamilAdcPhaseCalibrationModel: +class RphRadarPhaseCalibrationModel: """Affine law mapping the reference signal's unwrapped phase to frequency. Two fixed anchor points ``(phase0_rad, freq0_hz)`` and ``(phase1_rad, freq1_hz)`` @@ -73,7 +73,7 @@ class KamilAdcPhaseCalibrationModel: @dataclass(slots=True) -class KamilAdcBandModel: +class RphRadarBandModel: """Fixed frequency window every sweep is cropped to and resampled onto. Each sweep is resampled onto ``linspace(start_hz, stop_hz, points)`` so all @@ -88,8 +88,8 @@ class KamilAdcBandModel: @dataclass(slots=True) -class KamilAdcModel: - """External Kamil ADC acquisition process settings.""" +class RphRadarModel: + """External RadioPhotonic radar (RPH) acquisition process settings.""" project_dir: str = "" executable_path: str = "" @@ -99,10 +99,10 @@ class KamilAdcModel: startup_timeout_s: float = 5.0 sweep_timeout_s: float = 5.0 stop_timeout_s: float = 2.0 - phase_calibration: KamilAdcPhaseCalibrationModel = field( - default_factory=KamilAdcPhaseCalibrationModel + phase_calibration: RphRadarPhaseCalibrationModel = field( + default_factory=RphRadarPhaseCalibrationModel ) - band: KamilAdcBandModel = field(default_factory=KamilAdcBandModel) + band: RphRadarBandModel = field(default_factory=RphRadarBandModel) @dataclass(slots=True) @@ -129,14 +129,11 @@ class LaserVariationModeModel: step: float = 0.1 time_step: int = 20 delay_time: int = 3 - # Max allowed |measured - target| laser temperature before the temperature - # checker warns, °C. Applied independently to both lasers (temp1/temp2). - temp_tolerance_c: float = 0.03 @dataclass(slots=True) class LaserControlModel: - """Laser-control board settings applied before Kamil ADC acquisition.""" + """Laser-control board settings applied before RadioPhotonic radar (RPH) acquisition.""" enabled: bool = False port: str = "" @@ -158,7 +155,7 @@ class RadarModel: driver_mode: str = "mock" sweep: RadarSweepModel = field(default_factory=RadarSweepModel) multi_device: RadarMultiDeviceModel = field(default_factory=RadarMultiDeviceModel) - kamil_adc: KamilAdcModel = field(default_factory=KamilAdcModel) + rph_radar: RphRadarModel = field(default_factory=RphRadarModel) laser_control: LaserControlModel = field(default_factory=LaserControlModel) @@ -337,7 +334,7 @@ class RunConfigModel: LIBREVNA_MODEL = "librevna" LIBREVNA_MULTI_MODEL = "librevna_multi" - KAMIL_ADC_MODEL = "kamil_adc" + RPH_RADAR_MODEL = "rph_radar" MULTI_DEVICE_INPUT_POSITIONS = 4 MULTI_DEVICE_OUTPUT_POSITIONS = 2 # Stimulus ports the master itself owns. The virtual output axis is this times the @@ -400,72 +397,40 @@ class RunConfigModel: def build_runtime_combos(self) -> list[ComboModel]: """Build the combo matrix from the effective switch axis sizes.""" return self.build_full_combos(self.input_switch.positions, self.output_switch.positions) - + @property - def is_kamil_adc(self) -> bool: - """Return whether this config targets the external Kamil ADC acquisition path.""" - return self.radar.model == self.KAMIL_ADC_MODEL + def is_rph_radar(self) -> bool: + """Return whether this config targets the external RadioPhotonic radar (RPH) acquisition path.""" + return self.radar.model == self.RPH_RADAR_MODEL def radar_key(self) -> str: """Stable hardware/sweep identity used to find saved preprocess sets.""" radar, sweep = self.radar, self.radar.sweep + if self.is_rph_radar: + laser = radar.laser_control + mode = laser.mode.strip().lower() + optical = asdict(laser.variation if mode == "variation" else laser.manual) + physical = (asdict(radar.rph_radar.band), asdict(radar.rph_radar.phase_calibration), + radar.rph_radar.args, (self.input_switch.positions, self.output_switch.positions), + (mode, optical, laser.pi_coeff1_p, laser.pi_coeff1_i, + laser.pi_coeff2_p, laser.pi_coeff2_i) if laser.enabled else None) + digest = hashlib.sha256(json.dumps(physical, sort_keys=True).encode()).hexdigest()[:16] + return f"{radar.model}_{digest}" serials = [radar.serial or "no_serial"] - serials.extend(value.strip() or "no_serial" for value in self._radar_key_extra_parts()) + if self.is_multi_device: + serials.extend(value.strip() or "no_serial" for value in radar.multi_device.slave_serials) serial = "_".join(sanitize_path_component(value) for value in serials) - points = "adc" if self.is_kamil_adc else str(int(sweep.points)) + points = str(int(sweep.points)) + # Switch positions decide what each combo index means, so sets from another matrix never match. + matrix = (f"_out{self.matrix_output_switch_positions}_in{self.matrix_input_switch_positions}" + f"_stim{self.matrix_stimulus_switch_positions}") if self.is_multi_device else "" return ( f"{radar.model}_{serial}" f"_st{_format_float_for_key(sweep.start_hz)}_sp{_format_float_for_key(sweep.stop_hz)}" f"_p{points}_if{_format_float_for_key(sweep.if_bandwidth_hz)}" - f"_pw{_format_float_for_key(sweep.power_dbm)}" + f"_pw{_format_float_for_key(sweep.power_dbm)}{matrix}" ) - def _radar_key_extra_parts(self) -> list[str]: - """Return model-specific identity parts that affect captured data.""" - if self.is_multi_device: - return list(self.radar.multi_device.slave_serials) - if self.is_kamil_adc: - payload = { - "kamil_adc": { - "project_dir": self.radar.kamil_adc.project_dir, - "executable_path": self.radar.kamil_adc.executable_path, - "tty_path": self.radar.kamil_adc.tty_path, - "args": list(self.radar.kamil_adc.args), - "env": dict(sorted(self.radar.kamil_adc.env.items())), - }, - "laser_control": { - "enabled": self.radar.laser_control.enabled, - "port": self.radar.laser_control.port, - "mode": self.radar.laser_control.mode, - "pi_coeff1_p": self.radar.laser_control.pi_coeff1_p, - "pi_coeff1_i": self.radar.laser_control.pi_coeff1_i, - "pi_coeff2_p": self.radar.laser_control.pi_coeff2_p, - "pi_coeff2_i": self.radar.laser_control.pi_coeff2_i, - "manual": { - "temp1": self.radar.laser_control.manual.temp1, - "temp2": self.radar.laser_control.manual.temp2, - "current1": self.radar.laser_control.manual.current1, - "current2": self.radar.laser_control.manual.current2, - }, - "variation": { - "variation_type": self.radar.laser_control.variation.variation_type, - "static_temp1": self.radar.laser_control.variation.static_temp1, - "static_temp2": self.radar.laser_control.variation.static_temp2, - "static_current1": self.radar.laser_control.variation.static_current1, - "static_current2": self.radar.laser_control.variation.static_current2, - "min_value": self.radar.laser_control.variation.min_value, - "max_value": self.radar.laser_control.variation.max_value, - "step": self.radar.laser_control.variation.step, - "time_step": self.radar.laser_control.variation.time_step, - "delay_time": self.radar.laser_control.variation.delay_time, - }, - }, - } - encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")) - digest = hashlib.sha256(encoded.encode("utf-8")).hexdigest()[:16] - return [f"kamil_{digest}"] - return [] - def apply_device_model_constraints(self) -> None: """Apply only required wire-format constraints for the selected device model.""" if not self.is_multi_device: diff --git a/python_app/models/run_config_validation.py b/python_app/models/run_config_validation.py index 31f2d16..34e8565 100644 --- a/python_app/models/run_config_validation.py +++ b/python_app/models/run_config_validation.py @@ -20,6 +20,7 @@ from python_app.models.run_config_schema import ( GprModel, GprRxGeometryModel, GprTxGeometryModel, + RphRadarModel, RadarSweepModel, RingEndpointModel, RunConfigModel, @@ -129,12 +130,15 @@ def validate_acquisition_config(config: RunConfigModel) -> None: if mode not in ("mock", "native"): raise ValueError("radar.driver_mode must be mock or native") if config.radar.model not in ( - config.LIBREVNA_MODEL, config.LIBREVNA_MULTI_MODEL, config.KAMIL_ADC_MODEL, + config.LIBREVNA_MODEL, config.LIBREVNA_MULTI_MODEL, config.RPH_RADAR_MODEL, ): raise ValueError(f"Unsupported radar model: {config.radar.model}") - if mode != "native" and config.is_kamil_adc: + if mode != "native" and config.is_rph_radar: raise ValueError(f"{config.radar.model} requires native driver mode") + if config.is_rph_radar: + validate_rph_radar_model(config.radar.rph_radar) + for switch, physical_positions in ( (config.input_switch, config.matrix_input_switch_positions), (config.output_switch, config.matrix_output_switch_positions), @@ -155,7 +159,7 @@ def validate_acquisition_config(config: RunConfigModel) -> None: if not switch.gpio_chip or switch.pin_a < 0 or switch.pin_b < 0 or switch.pin_b == switch.pin_a: raise ValueError(f"{context}: GPIO chip and distinct nonnegative control pins are required") - points = config.radar.kamil_adc.band.points if config.is_kamil_adc else config.radar.sweep.points + points = config.radar.rph_radar.band.points if config.is_rph_radar else config.radar.sweep.points # RAW3/PRP3 header, frequency/S21 arrays and capture windows. minimum = 44 + len(config.combos) * (28 + 12 * points) for name in ("raw", "raw_tap", "preprocessed", "preprocessed_tap"): @@ -166,6 +170,24 @@ def validate_acquisition_config(config: RunConfigModel) -> None: raise ValueError(f"rings.{name}.slot_size_bytes must be at least {minimum} for this sweep") +def validate_rph_radar_model(adc: RphRadarModel) -> None: + """Reject phase/grid errors before stopping a running pipeline.""" + phase, band = adc.phase_calibration, adc.band + for name, value in {**asdict(phase), **asdict(band)}.items(): + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + raise ValueError(f"radar.rph_radar.{name} must be finite") + if phase.phase0_rad == phase.phase1_rad or phase.freq0_hz == phase.freq1_hz: + raise ValueError("radar.rph_radar.phase_calibration requires distinct phases and frequencies") + if not 0 <= band.start_hz < band.stop_hz <= 3.4e38: + raise ValueError("radar.rph_radar.band requires 0 <= start_hz < stop_hz within float32") + if not isinstance(band.points, int) or not 2 <= band.points <= _UINT32_MAX: + raise ValueError("radar.rph_radar.band.points must be an integer in [2, uint32 max]") + for name in ("startup_timeout_s", "sweep_timeout_s", "stop_timeout_s"): + value = getattr(adc, name) + if not math.isfinite(value) or value <= 0: + raise ValueError(f"radar.rph_radar.{name} must be finite and positive") + + def validate_sweep_model(sweep: RadarSweepModel) -> None: """Validate radar sweep bounds (point count and frequency span). diff --git a/python_app/orchestration/process_supervisor.py b/python_app/orchestration/process_supervisor.py index 6f221ee..daaad06 100644 --- a/python_app/orchestration/process_supervisor.py +++ b/python_app/orchestration/process_supervisor.py @@ -24,10 +24,7 @@ logger = logging.getLogger(__name__) _LOG_MAX_BYTES = 8 * 1024 * 1024 # Per-process force-kill deadline used on stop; each child gets its own window. _STOP_GRACE_SECONDS = 2.0 -# Cmdline marker for the Kamil ADC collector binary. The collector runs in its own -# session, so it can outlive a force-killed acquisition producer while still holding -# the L-Card E-502; we hunt it by name as a guaranteed device-release backstop. -_KAMIL_COLLECTOR_MARKER = b"kamil_adc_collector" + @dataclass(slots=True) @@ -143,9 +140,6 @@ class ProcessSupervisor: """Start required pipeline binaries and wait until they are ready.""" if self.is_running(): raise RuntimeError("Acquisition processes are already running") - # Clean slate: an ADC collector orphaned by a prior crash/force-kill can - # still hold the E-502 and make a fresh acquisition fail to open. - self._kill_kamil_collectors() logger.info( "Starting pipeline from config %s (allow_clean_acquisition_exit=%s)", @@ -197,7 +191,6 @@ class ProcessSupervisor: def stop(self) -> None: """Stop acquisition-side processes, keep processor process intact.""" self._stop_processes(["acquisition", "data_preprocessor"]) - self._kill_kamil_collectors() def stop_acquisition(self) -> None: """Stop acquisition process only.""" @@ -210,7 +203,6 @@ class ProcessSupervisor: def stop_all(self) -> None: """Stop all managed processes.""" self._stop_processes(["acquisition", "data_preprocessor", "data_processor"]) - self._kill_kamil_collectors() def _spawn(self, name: str, command: list[str], *, allow_clean_exit: bool) -> None: """Spawn one process unless same process is already alive. @@ -339,34 +331,6 @@ class ProcessSupervisor: except (ProcessLookupError, PermissionError): pass - @staticmethod - def _kill_kamil_collectors() -> None: - """SIGKILL any Kamil ADC collector found by name — a guaranteed device backstop. - - The collector survives a force-killed producer (own session) and can hang - the E-502, so it is hunted by cmdline and its group killed hard, regardless - of who its parent is. Best-effort and never raises. - """ - own_pid = os.getpid() - try: - pids = [int(entry.name) for entry in Path("/proc").iterdir() if entry.name.isdigit()] - except OSError: - return - killed = 0 - for pid in pids: - if pid <= 1 or pid == own_pid: - continue - try: - cmdline = (Path("/proc") / str(pid) / "cmdline").read_bytes() - except OSError: - continue - executable = cmdline.split(b"\x00", 1)[0].rsplit(b"/", 1)[-1] - if executable == _KAMIL_COLLECTOR_MARKER: - ProcessSupervisor._signal_group(pid, signal.SIGKILL) - killed += 1 - if killed: - logger.warning("Force-killed %d orphaned Kamil ADC collector process(es)", killed) - @staticmethod def _log_abnormal_stop_exit(process: ManagedProcess) -> None: """Log a process that self-exited abnormally around stop time. @@ -542,9 +506,6 @@ class ProcessSupervisor: atomic_write_text(self._pidfile_path, "") except OSError: pass - # The ADC collector is a grandchild (spawned by the producer), not in the - # pidfile, so reap it separately by name. - self._kill_kamil_collectors() def _is_stale_pipeline_pid(self, pid: int) -> bool: """Return whether `pid` still runs one of our pipeline binaries/scripts. @@ -555,7 +516,7 @@ class ProcessSupervisor: native_names = {"data_processor", "data_preprocessor"} producer_modules = { "python_app.scripts.matrix_raw_producer", - "python_app.scripts.kamil_adc_raw_producer", + "python_app.scripts.rph_radar_raw_producer", } try: raw = (Path("/proc") / str(pid) / "cmdline").read_bytes() diff --git a/python_app/orchestration/producer_selection.py b/python_app/orchestration/producer_selection.py index 70c0169..a3d3b7c 100644 --- a/python_app/orchestration/producer_selection.py +++ b/python_app/orchestration/producer_selection.py @@ -18,7 +18,7 @@ def acquisition_command(config_path: Path) -> list[str]: producers = { "librevna": "matrix_raw_producer", "librevna_multi": "matrix_raw_producer", - "kamil_adc": "kamil_adc_raw_producer", + "rph_radar": "rph_radar_raw_producer", } if model not in producers: raise ValueError(f"Unsupported radar model: {model}") diff --git a/python_app/scripts/laser_temp_checker.py b/python_app/scripts/laser_temp_checker.py deleted file mode 100644 index 5151c15..0000000 --- a/python_app/scripts/laser_temp_checker.py +++ /dev/null @@ -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()) diff --git a/python_app/scripts/laser_temp_monitor.py b/python_app/scripts/laser_temp_monitor.py deleted file mode 100644 index ed6d242..0000000 --- a/python_app/scripts/laser_temp_monitor.py +++ /dev/null @@ -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:'") - 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()) diff --git a/python_app/scripts/rph_radar_calibrate.py b/python_app/scripts/rph_radar_calibrate.py new file mode 100644 index 0000000..3876fc6 --- /dev/null +++ b/python_app/scripts/rph_radar_calibrate.py @@ -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()) diff --git a/python_app/scripts/kamil_adc_raw_producer.py b/python_app/scripts/rph_radar_raw_producer.py similarity index 71% rename from python_app/scripts/kamil_adc_raw_producer.py rename to python_app/scripts/rph_radar_raw_producer.py index 249e746..f5ab460 100644 --- a/python_app/scripts/kamil_adc_raw_producer.py +++ b/python_app/scripts/rph_radar_raw_producer.py @@ -1,4 +1,4 @@ -"""Raw acquisition producer for the external Kamil ADC radar mode.""" +"""Raw acquisition producer for RadioPhotonic radar (RPH).""" from __future__ import annotations @@ -9,7 +9,7 @@ import time import numpy as np -from python_app.hardware_full.kamil_adc import KamilAdcService +from python_app.hardware_full.rph_radar.service import RphRadarService from python_app.hardware_full.switch_service import SwitchService, select_and_settle from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData from python_app.models.run_config_schema import RunConfigModel @@ -20,7 +20,7 @@ logger = logging.getLogger(__name__) def _open_radar_with_retry( config: RunConfigModel, - radar: KamilAdcService, + radar: RphRadarService, input_switch: SwitchService | None, output_switch: SwitchService | None, stop_requested: threading.Event, @@ -28,7 +28,7 @@ def _open_radar_with_retry( """Reopen the ADC and Python-owned switches, cleaning partial opens before retry.""" close_devices(input_switch, output_switch, radar) - def attempt() -> KamilAdcService: + def attempt() -> RphRadarService: try: radar.open(stop_event=stop_requested) radar.configure(config.radar.sweep) @@ -41,16 +41,16 @@ def _open_radar_with_retry( raise return radar - return retry_open(attempt, stop_requested, "Kamil ADC") is not None + return retry_open(attempt, stop_requested, "RadioPhotonic radar (RPH)") is not None def main() -> int: """Run producer process until config or signal requests exit.""" - config_path, config, stop_requested = load_producer_config("Publish Kamil ADC raw sweeps to SHM rings") - if not config.is_kamil_adc: - raise RuntimeError("kamil_adc_raw_producer requires radar.model='kamil_adc'") + config_path, config, stop_requested = load_producer_config("Publish RadioPhotonic radar (RPH) raw sweeps to SHM rings") + if not config.is_rph_radar: + raise RuntimeError("rph_radar_raw_producer requires radar.model='rph_radar'") logger.info( - "Kamil ADC raw producer starting: config=%s, combos=%d, continuous=%s", + "RadioPhotonic radar (RPH) raw producer starting: config=%s, combos=%d, continuous=%s", config_path, len(config.combos), config.runtime.continuous, ) @@ -59,24 +59,21 @@ def main() -> int: try: publisher = RawPublisher(config.rings, cleanup) # Switch-aware mode: with native switches the collector drives the RF switches - # itself, in the hardware gap between sweeps, and tags each sweep with its combo - # — no sweep lost at a switch boundary. The producer then only reads tagged - # sweeps and must not touch the GPIO lines the collector owns. With mock switches - # (dev/tests) we keep the Python-driven path, which is fine where speed and the - # in-gap timing do not matter. + # itself and tags each sweep with its combo and source cycle. Otherwise Python + # owns the switches and starts a fresh collector session after each changed pair. collector_driven = ( config.input_switch.driver_mode == "native" and config.output_switch.driver_mode == "native" ) - radar = KamilAdcService( + radar = RphRadarService( config, - switch_config_path=str(config_path) if collector_driven else None, + collector_driven=collector_driven, ) input_switch = None if collector_driven else SwitchService.from_model(config.input_switch) output_switch = None if collector_driven else SwitchService.from_model(config.output_switch) logger.info( - "Kamil ADC switch control: %s", - "collector-driven (in-gap, lossless)" if collector_driven else "producer-driven", + "RadioPhotonic radar (RPH) switch control: %s", + "collector-driven" if collector_driven else "producer-driven", ) if not _open_radar_with_retry(config, radar, input_switch, output_switch, stop_requested): @@ -85,8 +82,8 @@ def main() -> int: collection_id = 1 while not stop_requested.is_set(): collection_start = time.monotonic() - capture_start_ns = time.monotonic_ns() traces: list[TraceData] = [] + cycle_id = None try: for combo in config.combos: @@ -95,30 +92,30 @@ def main() -> int: if collector_driven: # The collector already switched and tagged the sweep; just # read the clean capture for this combination. - sweep_start_ns = time.monotonic_ns() sweep = radar.acquire(combo=(combo.input, combo.output)) + if cycle_id is not None and cycle_id != radar.last_cycle_id: + break # A dropped or overwritten pair invalidates this cycle. + cycle_id = radar.last_cycle_id else: if input_switch is None or output_switch is None: raise RuntimeError("Producer-driven switches are not initialized") if not select_and_settle(output_switch, input_switch, combo.output, combo.input, config.runtime.settling_ms, wait=stop_requested.wait): break - # Stamped after switching and settling so the window covers - # the sweep alone, not the dead time before it. - sweep_start_ns = time.monotonic_ns() + radar.prepare_combo(ComboKey(combo.input, combo.output)) sweep = radar.acquire() traces.append( TraceData( combo=ComboKey(input=combo.input, output=combo.output), frequency_hz=np.asarray(sweep.x, dtype=np.float32), s21=np.asarray(sweep.trace("s21"), dtype=np.complex64), - capture_start_ns=sweep_start_ns, - capture_end_ns=time.monotonic_ns(), + capture_start_ns=sweep.capture_start_ns, + capture_end_ns=sweep.capture_end_ns, ) ) except Exception as exc: # noqa: BLE001 — reconnect forever, never give up logger.warning( - "Kamil ADC acquisition failed; reconnecting and waiting for the device: %s", + "RadioPhotonic radar (RPH) acquisition failed; reconnecting and waiting for the device: %s", exc, exc_info=True, ) @@ -132,8 +129,10 @@ def main() -> int: if len(traces) != len(config.combos): if stop_requested.is_set(): break + if collector_driven: + continue # Latest-wins transport skipped part of a source cycle. logger.warning( - "Kamil ADC produced an incomplete collection (%d of %d combos); " + "RadioPhotonic radar (RPH) produced an incomplete collection (%d of %d combos); " "reconnecting and waiting for the device", len(traces), len(config.combos), @@ -143,11 +142,11 @@ def main() -> int: continue collection = SweepCollection( - collection_id=collection_id, + collection_id=cycle_id if cycle_id is not None else collection_id, monotonic_ns=time.monotonic_ns(), traces=traces, - capture_start_ns=capture_start_ns, - capture_end_ns=time.monotonic_ns(), + capture_start_ns=min(trace.capture_start_ns for trace in traces), + capture_end_ns=max(trace.capture_end_ns for trace in traces), ) publisher.publish(collection) @@ -155,13 +154,13 @@ def main() -> int: break collection_duration_s = time.monotonic() - collection_start if collection_id == 1 or collection_id % 20 == 0 or collection_duration_s > 2.0: - logger.info("Kamil ADC collection %d acquired in %.3f s", collection_id, collection_duration_s) + logger.info("RadioPhotonic radar (RPH) collection %d acquired in %.3f s", collection_id, collection_duration_s) collection_id += 1 finally: close_devices(output_switch, input_switch, radar) cleanup.close() - logger.info("Kamil ADC raw producer stopped") + logger.info("RadioPhotonic radar (RPH) raw producer stopped") return 0 diff --git a/python_app/storage/npz/paths.py b/python_app/storage/npz/paths.py index 4959c11..0c81ce7 100644 --- a/python_app/storage/npz/paths.py +++ b/python_app/storage/npz/paths.py @@ -18,7 +18,7 @@ def radar_config_filename_prefix( dataset's name carries the configuration it was captured with. ADC-mode radars have no fixed sweep, so the point count is reported as ``adc``. """ - points_token = "adc" if model_name.strip().lower() == "kamil_adc" else f"{int(sweep_points)}pts" + points_token = "adc" if model_name.strip().lower() == "rph_radar" else f"{int(sweep_points)}pts" tokens = ( model_name, _format_frequency_span(sweep_start_hz, sweep_stop_hz), diff --git a/python_app/tests/test_combo_controls.py b/python_app/tests/test_combo_controls.py index 1a418d6..703bb75 100644 --- a/python_app/tests/test_combo_controls.py +++ b/python_app/tests/test_combo_controls.py @@ -53,7 +53,7 @@ class ComboNavigationTest(unittest.TestCase): self.w._plots.draw(result) def test_next_button_draws_cached_combo_without_changing_acquisition(self) -> None: - for model in ("librevna", "librevna_multi", "kamil_adc"): + for model in ("librevna", "librevna_multi", "rph_radar"): with self.subTest(model=model): self.w._profile.state.config.radar.model = model self._seed_bscan() diff --git a/python_app/tests/test_deploy_scripts.py b/python_app/tests/test_deploy_scripts.py index 02b1554..2aa1a6d 100644 --- a/python_app/tests/test_deploy_scripts.py +++ b/python_app/tests/test_deploy_scripts.py @@ -63,4 +63,4 @@ printf 'updated environment' > "$(dirname -- "$0")/../state" self.assertTrue((target / 'run_config_examples/device.json.new').exists()) self.assertEqual((target / 'python_app/runtime/gui_session_state.json').read_text(), 'session') self.assertFalse((target / 'python_app/runtime/logs').exists()) - self.assertIn('kamil_adc_collector', log.read_text().splitlines()) + self.assertIn('rph_radar_collector', log.read_text().splitlines()) diff --git a/python_app/tests/test_gui_action_lifecycle.py b/python_app/tests/test_gui_action_lifecycle.py index a9f63bd..83c0a6e 100644 --- a/python_app/tests/test_gui_action_lifecycle.py +++ b/python_app/tests/test_gui_action_lifecycle.py @@ -303,10 +303,10 @@ class ShutdownReentryTest(unittest.TestCase): _mock(pipeline._status).assert_called_with("Status: error") self.assertFalse(pipeline.can_resume(expected_stop_seq)) - def test_unsupported_kamil_adc_mock_fails_before_starting_any_process(self) -> None: + def test_unsupported_rph_radar_mock_fails_before_starting_any_process(self) -> None: pipeline = _pipeline(self) config = RunConfigModel() - config.radar.model = "kamil_adc" + config.radar.model = "rph_radar" config.apply_device_model_constraints() _mock(pipeline._build_config).return_value = config pipeline.stop_all = mock.Mock() diff --git a/python_app/tests/test_hardware_lifecycle.py b/python_app/tests/test_hardware_lifecycle.py index 462cfac..167e611 100644 --- a/python_app/tests/test_hardware_lifecycle.py +++ b/python_app/tests/test_hardware_lifecycle.py @@ -16,7 +16,7 @@ from unittest.mock import Mock, patch import numpy as np -from python_app.hardware_full.kamil_adc.tty_reader import KamilAdcTtyReader +from python_app.hardware_full.rph_radar.tty_reader import RphRadarTtyReader from python_app.hardware_full.laser_control.protocol import Protocol from python_app.hardware_full.librevna_service import LibreVnaService from python_app.hardware_full.librevna_driver.api.vna import VNAController @@ -30,7 +30,7 @@ from python_app.hardware_full.switched_matrix_radar_service import SwitchedMatri from python_app.models.run_config_schema import RadarSweepModel, RunConfigModel from python_app.models.dataset_model import SweepCollection from python_app.orchestration.shm.decoder import RAW_MAGIC, decode_trace_collection -from python_app.scripts import kamil_adc_raw_producer, matrix_raw_producer, producer_common +from python_app.scripts import rph_radar_raw_producer, matrix_raw_producer, producer_common from python_app.workflows.radar_config_variants import scan_radar_config_variants @@ -252,7 +252,7 @@ class TtyLifecycleTest(unittest.TestCase): with self.subTest(combination=combination): master, slave = pty.openpty() tty.setraw(slave) - reader = KamilAdcTtyReader(os.ttyname(slave)) + reader = RphRadarTtyReader(os.ttyname(slave)) errors = [] started = threading.Event() reader.open() @@ -260,10 +260,7 @@ class TtyLifecycleTest(unittest.TestCase): def wait(): started.set() try: - if combination is None: - reader.read_sweep(timeout_s=20) - else: - reader.read_sweep_for(combination, timeout_s=20) + reader.read_sweep(timeout_s=20, combo=combination) except Exception as exc: errors.append(exc) @@ -283,7 +280,7 @@ class TtyLifecycleTest(unittest.TestCase): def test_failed_reader_thread_start_closes_descriptor(self) -> None: read_fd, write_fd = os.pipe() - reader = KamilAdcTtyReader("/dev/test") + reader = RphRadarTtyReader("/dev/test") try: with patch("os.open", return_value=read_fd), patch("threading.Thread.start", side_effect=RuntimeError("thread failed")): with self.assertRaises(RuntimeError): @@ -298,7 +295,7 @@ class TtyLifecycleTest(unittest.TestCase): class ProducerCleanupTest(unittest.TestCase): def test_partial_ring_initialization_closes_first_writer(self) -> None: - for module, model in ((matrix_raw_producer, "librevna_multi"), (kamil_adc_raw_producer, "kamil_adc")): + for module, model in ((matrix_raw_producer, "librevna_multi"), (rph_radar_raw_producer, "rph_radar")): with self.subTest(producer=module.__name__): config = RunConfigModel() config.radar.model = model diff --git a/python_app/tests/test_kamil_adc_protocol.py b/python_app/tests/test_kamil_adc_protocol.py deleted file mode 100644 index cdd5eca..0000000 --- a/python_app/tests/test_kamil_adc_protocol.py +++ /dev/null @@ -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(" bytes: - return struct.pack(" bytes: - return struct.pack(" bytes: - return struct.pack(" 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(" 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() diff --git a/python_app/tests/test_kamil_adc_service.py b/python_app/tests/test_kamil_adc_service.py deleted file mode 100644 index c9e0ebe..0000000 --- a/python_app/tests/test_kamil_adc_service.py +++ /dev/null @@ -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(" bytes: - return struct.pack(" bytes: - return struct.pack(" bytes: - return struct.pack(" 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(" 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() diff --git a/python_app/tests/test_laser_control_protocol.py b/python_app/tests/test_laser_control_protocol.py index 0ab1b39..157639e 100644 --- a/python_app/tests/test_laser_control_protocol.py +++ b/python_app/tests/test_laser_control_protocol.py @@ -8,9 +8,9 @@ import unittest from unittest.mock import Mock, patch from python_app.hardware_full.laser_control.controller import DEVICE_MAIN_MESSAGE_ID, LaserController -from python_app.hardware_full.laser_control.models import VariationType -from python_app.hardware_full.laser_control.protocol import Protocol, TaskType -from python_app.hardware_full.kamil_adc import apply_kamil_adc_laser_control +from python_app.hardware_full.laser_control.exceptions import CommunicationError, ValidationError +from python_app.hardware_full.laser_control.protocol import Protocol +from python_app.hardware_full.rph_radar.laser import apply_rph_radar_laser_control from python_app.models.run_config_schema import RunConfigModel @@ -18,11 +18,6 @@ DEVICE_MAIN_MANUAL_HEX = "1111ff37ffa518ab000000000000000a8000000a8000ff003d2acc DEVICE_MAIN_CHANGE_CURRENT_LD1_HEX = ( "7777ff3701003d2acc2c10008813ffa5cc2c18ab0a00000a8000000a8000b600" ) -DEVICE_MAIN_CHANGE_CURRENT_LD2_HEX = ( - # Word 5 (step) = current_ma_to_n(0.05)=0x0010, matching LD1 and min/max — see the - # step-encoding fix in protocol.py (was the inconsistent int(step*100)=0x0005). - "7777ff3702003d2acc2c1000881318ab3d2affa50a00000a8000000a80004406" -) class _FakeProtocol: @@ -43,11 +38,11 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase): self.controller.connect.return_value = True @staticmethod - def _kamil_config(laser_payload: dict[str, object]) -> RunConfigModel: + def _rph_config(laser_payload: dict[str, object]) -> RunConfigModel: return RunConfigModel.from_dict( { "radar": { - "model": "kamil_adc", + "model": "rph_radar", "driver_mode": "native", "laser_control": laser_payload, } @@ -71,7 +66,6 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase): def test_variation_commands_match_device_main_bytes(self) -> None: ld1_command = Protocol.encode_task_enable( - task_type=TaskType.CHANGE_CURRENT_LD1, static_temp1=28.0, static_temp2=28.9, static_current1=33.0, @@ -81,50 +75,18 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase): step=0.05, time_step=50, delay_time=10, - message_id=DEVICE_MAIN_MESSAGE_ID, pi_coeff1_p=2560, pi_coeff1_i=128, pi_coeff2_p=2560, pi_coeff2_i=128, ) - ld2_command = Protocol.encode_task_enable( - task_type=TaskType.CHANGE_CURRENT_LD2, - static_temp1=28.0, - static_temp2=28.9, - static_current1=33.0, - static_current2=35.0, - min_value=33.0, - max_value=35.0, - step=0.05, - time_step=50, - delay_time=10, - message_id=DEVICE_MAIN_MESSAGE_ID, - pi_coeff1_p=2560, - pi_coeff1_i=128, - pi_coeff2_p=2560, - pi_coeff2_i=128, - ) - self.assertEqual(ld1_command.hex(), DEVICE_MAIN_CHANGE_CURRENT_LD1_HEX) - self.assertEqual(ld2_command.hex(), DEVICE_MAIN_CHANGE_CURRENT_LD2_HEX) - - def test_ld1_and_ld2_encode_current_step_identically(self) -> None: - # Regression guard for the LD2 step-scale bug: both current-variation channels - # must encode the step with the same scale (current_ma_to_n), like min/max. - ld1, ld2 = [Protocol.encode_task_enable( - task_type=task_type, static_temp1=28.0, static_temp2=28.9, - static_current1=33.0, static_current2=35.0, min_value=33.0, max_value=35.0, - step=0.05, time_step=50, delay_time=10, message_id=DEVICE_MAIN_MESSAGE_ID, - pi_coeff1_p=2560, pi_coeff1_i=128, pi_coeff2_p=2560, pi_coeff2_i=128, - ) for task_type in (TaskType.CHANGE_CURRENT_LD1, TaskType.CHANGE_CURRENT_LD2)] - # Word 5 (step) is at bytes 10:12 in both frames (sync, header, task, min, max, step). - self.assertEqual(ld1[10:12], ld2[10:12]) def test_start_sequence_matches_device_main_order(self) -> None: cleanup = ExitStack() self.addCleanup(cleanup.close) fake_protocol = _FakeProtocol() - controller = LaserController(pi_coeff1_p=2560, pi_coeff1_i=128, pi_coeff2_p=2560, pi_coeff2_i=128) + controller = LaserController("/dev/ttyLASER", pi_coeff1_p=2560, pi_coeff1_i=128, pi_coeff2_p=2560, pi_coeff2_i=128) cleanup.enter_context(patch.object(controller, "_protocol", fake_protocol)) with patch("python_app.hardware_full.laser_control.controller.time.sleep", return_value=None): @@ -137,7 +99,6 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase): message_id=DEVICE_MAIN_MESSAGE_ID, ) controller.start_variation( - variation_type=VariationType.CHANGE_CURRENT_LD1, params={ "static_temp1": 28.0, "static_temp2": 28.9, @@ -160,34 +121,8 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase): ], ) - def test_stop_sequence_restores_device_main_manual_bytes(self) -> None: - cleanup = ExitStack() - self.addCleanup(cleanup.close) - fake_protocol = _FakeProtocol() - controller = LaserController(pi_coeff1_p=2560, pi_coeff1_i=128, pi_coeff2_p=2560, pi_coeff2_i=128) - cleanup.enter_context(patch.object(controller, "_protocol", fake_protocol)) - - with patch("python_app.hardware_full.laser_control.controller.time.sleep", return_value=None): - controller.set_manual_mode( - temp1=28.0, - temp2=28.9, - current1=33.0, - current2=35.0, - message_id=DEVICE_MAIN_MESSAGE_ID, - ) - fake_protocol.sent.clear() - controller.stop_task(restore_message_id=DEVICE_MAIN_MESSAGE_ID) - - self.assertEqual( - [command.hex() for command in fake_protocol.sent], - [ - "2222", - DEVICE_MAIN_MANUAL_HEX, - ], - ) - def test_apply_radar_variation_sequence_matches_device_main_order(self) -> None: - config = self._kamil_config( + config = self._rph_config( { "enabled": True, "port": "/dev/ttyUSB0", @@ -211,8 +146,8 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase): } ) - with patch("python_app.hardware_full.laser_control.controller.LaserController", return_value=self.controller): - applied = apply_kamil_adc_laser_control(config) + with patch("python_app.hardware_full.rph_radar.laser.LaserController", return_value=self.controller): + applied = apply_rph_radar_laser_control(config) self.assertTrue(applied) controller = self.controller @@ -233,13 +168,12 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase): ) # The variation handoff itself (type + params), not just call order, must be right. variation_payload = controller.start_variation.call_args.kwargs - self.assertEqual(variation_payload["variation_type"], VariationType.CHANGE_CURRENT_LD1) self.assertEqual(variation_payload["params"]["step"], 0.05) self.assertEqual(variation_payload["params"]["min_value"], 33.0) self.assertEqual(variation_payload["params"]["max_value"], 35.0) def test_apply_radar_manual_mode_sets_manual_without_variation(self) -> None: - config = self._kamil_config( + config = self._rph_config( { "enabled": True, "port": "/dev/ttyUSB0", @@ -247,27 +181,53 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase): "manual": {"temp1": 26.0, "temp2": 27.0, "current1": 30.0, "current2": 31.0}, } ) - with patch("python_app.hardware_full.laser_control.controller.LaserController", return_value=self.controller): - applied = apply_kamil_adc_laser_control(config) + with patch("python_app.hardware_full.rph_radar.laser.LaserController", return_value=self.controller): + applied = apply_rph_radar_laser_control(config) self.assertTrue(applied) controller = self.controller self.assertEqual([call[0] for call in controller.method_calls], ["connect", "reset", "set_manual_mode", "disconnect"]) self.assertEqual(controller.set_manual_mode.call_args.kwargs["current1"], 30.0) def test_apply_radar_unknown_variation_type_raises(self) -> None: - config = self._kamil_config( + config = self._rph_config( {"enabled": True, "port": "/dev/ttyUSB0", "mode": "variation", "variation": {"variation_type": "NOT_A_REAL_TYPE"}} ) - with patch("python_app.hardware_full.laser_control.controller.LaserController", return_value=self.controller): + with patch("python_app.hardware_full.rph_radar.laser.LaserController", return_value=self.controller): with self.assertRaisesRegex(ValueError, "variation_type"): - apply_kamil_adc_laser_control(config) + apply_rph_radar_laser_control(config) + + def test_state_failure_cannot_report_command_success(self) -> None: + controller = LaserController("/dev/ttyLASER") + for reply in (b"", b"\x00", b"\x04\x00", b"\x03\x00"): + with self.subTest(reply=reply), patch.object(controller, "_protocol") as protocol: + protocol.receive_raw.return_value = reply + with patch("python_app.hardware_full.laser_control.controller.time.sleep"): + with self.assertRaises(CommunicationError): + controller.reset() + + def test_invalid_settings_are_rejected_before_connect_or_reset(self) -> None: + for mode, field, value in (("manual", "temp1", 100.0), + ("variation", "static_current2", 100.0), + ("variation", "step", 0.0), + ("variation", "step", 0.002), + ("variation", "variation_type", "CHANGE_TEMPERATURE_LD1"), + ("pi", "pi_coeff1_p", 65536)): + config = self._rph_config({"enabled": True, "port": "/dev/ttyUSB0", "mode": "manual" if mode == "pi" else mode}) + target = config.radar.laser_control + setattr(target if mode == "pi" else getattr(target, mode), field, value) + with self.subTest(mode=mode, field=field), patch( + "python_app.hardware_full.rph_radar.laser.LaserController", return_value=self.controller + ) as factory: + with self.assertRaises((ValueError, ValidationError)): + apply_rph_radar_laser_control(config) + factory.assert_not_called() def test_apply_radar_skips_disabled_laser_control(self) -> None: - config = self._kamil_config({"enabled": False}) + config = self._rph_config({"enabled": False}) - with patch("python_app.hardware_full.laser_control.controller.LaserController", return_value=self.controller) as factory: - applied = apply_kamil_adc_laser_control(config) + with patch("python_app.hardware_full.rph_radar.laser.LaserController", return_value=self.controller) as factory: + applied = apply_rph_radar_laser_control(config) self.assertFalse(applied) factory.assert_not_called() diff --git a/python_app/tests/test_laser_temp_monitoring.py b/python_app/tests/test_laser_temp_monitoring.py deleted file mode 100644 index 6d6ac5e..0000000 --- a/python_app/tests/test_laser_temp_monitoring.py +++ /dev/null @@ -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() diff --git a/python_app/tests/test_live_settings_regressions.py b/python_app/tests/test_live_settings_regressions.py index a8c04f5..6947d78 100644 --- a/python_app/tests/test_live_settings_regressions.py +++ b/python_app/tests/test_live_settings_regressions.py @@ -7,7 +7,9 @@ from unittest import mock from PyQt6.QtWidgets import QApplication, QComboBox, QDoubleSpinBox, QStackedWidget from python_app.gui.antenna_geometry_editor import AntennaGeometryEditor +from python_app.gui.controllers.radar_settings_controller import RadarSettingsController from python_app.gui.controllers.sections.processing_section import build_processing_group +from python_app.gui.controllers.sections.radar_section import build_radar_group from python_app.models.gui_profile_schema import GuiStateModel from python_app.models.dataset_model import SweepCollection from python_app.models.run_config_schema import RunConfigModel @@ -33,6 +35,26 @@ class LiveSettingsRegressionTest(unittest.TestCase): editor = widget(section, f"_{mode}_{bound}_freq_mhz", QDoubleSpinBox) self.assertEqual((editor.minimum(), editor.maximum(), editor.value()), (1, 9500, expected)) + def test_rph_processing_limits_use_band_instead_of_hidden_vna_fields(self) -> None: + config, gui = RunConfigModel(), GuiStateModel() + config.radar.model = "rph_radar" + config.radar.sweep.start_hz, config.radar.sweep.stop_hz = 1e6, 2e6 + config.radar.rph_radar.band.start_hz, config.radar.rph_radar.band.stop_hz = 2.1e9, 5.5e9 + gui.processing.bscan.start_freq_mhz, gui.processing.bscan.stop_freq_mhz = 2200, 5400 + radar, processing = build_radar_group(config), build_processing_group(config, gui) + for group in (radar.group, processing.group): + self.addCleanup(group.deleteLater) + controller = RadarSettingsController(radar, mock.Mock(), processing, lambda: config, mock.Mock(), + reset_preprocess=lambda: None, live_changed=lambda: None) + for stage in ("initial", "resync"): + if stage == "resync": + controller.sync_frequency_limits() + with self.subTest(stage=stage): + for editor in processing.frequency_controls: + self.assertEqual((editor.minimum(), editor.maximum()), (2100, 5500)) + self.assertEqual(widget(processing, "_bscan_start_freq_mhz", QDoubleSpinBox).value(), 2200) + self.assertEqual(widget(processing, "_bscan_stop_freq_mhz", QDoubleSpinBox).value(), 5400) + def test_failed_live_write_restores_web_batch_and_desktop_changes(self) -> None: window = isolated_window(self.addCleanup) window._pipeline.timer.stop() diff --git a/python_app/tests/test_neutral_preprocess.py b/python_app/tests/test_neutral_preprocess.py index 415c39c..5102188 100644 --- a/python_app/tests/test_neutral_preprocess.py +++ b/python_app/tests/test_neutral_preprocess.py @@ -11,12 +11,12 @@ from python_app.models.run_config_schema import RunConfigModel from python_app.workflows.neutral_preprocess import build_neutral_s21_sets, supports_neutral_preprocess_sets -def _kamil_config(*, points: int = 5) -> RunConfigModel: +def _rph_config(*, points: int = 5) -> RunConfigModel: return RunConfigModel.from_dict( { "radar": { - "model": "kamil_adc", - "kamil_adc": { + "model": "rph_radar", + "rph_radar": { "band": {"start_hz": 2_100_000_000.0, "stop_hz": 5_500_000_000.0, "points": points}, }, }, @@ -26,9 +26,9 @@ def _kamil_config(*, points: int = 5) -> RunConfigModel: ) -class KamilAdcNeutralPreprocessTest(unittest.TestCase): +class RphRadarNeutralPreprocessTest(unittest.TestCase): def test_builds_passthrough_s21_sets_on_band_grid(self) -> None: - calibration, reference = build_neutral_s21_sets(_kamil_config(points=5)) + calibration, reference = build_neutral_s21_sets(_rph_config(points=5)) expected_frequency = np.linspace(2_100_000_000.0, 5_500_000_000.0, 5).astype(np.float32) self.assertEqual(len(calibration.traces), 2) @@ -46,22 +46,19 @@ class KamilAdcNeutralPreprocessTest(unittest.TestCase): def test_grid_matches_processor_grid(self) -> None: """The neutral axis must be byte-identical to the acquisition grid.""" - from python_app.hardware_full.kamil_adc import ( - KamilAdcProcessingParams, - KamilAdcSweepProcessor, - ) + from python_app.hardware_full.rph_radar.processing import RphRadarSweepProcessor - config = _kamil_config(points=17) - processor = KamilAdcSweepProcessor(KamilAdcProcessingParams.from_kamil_model(config.radar.kamil_adc)) + config = _rph_config(points=17) + processor = RphRadarSweepProcessor(config.radar.rph_radar) calibration, _reference = build_neutral_s21_sets(config) np.testing.assert_array_equal(calibration.traces[0].frequency_hz, processor.grid_hz) def test_rejects_degenerate_band(self) -> None: - with self.assertRaisesRegex(ValueError, "points must be >= 2"): - build_neutral_s21_sets(_kamil_config(points=1)) + with self.assertRaisesRegex(ValueError, "band.points must be"): + build_neutral_s21_sets(_rph_config(points=1)) def test_dtypes_are_float32_and_complex64(self) -> None: - calibration, _reference = build_neutral_s21_sets(_kamil_config()) + calibration, _reference = build_neutral_s21_sets(_rph_config()) trace = calibration.traces[0] self.assertEqual(trace.frequency_hz.dtype, np.float32) self.assertEqual(trace.s21.dtype, np.complex64) @@ -69,7 +66,7 @@ class KamilAdcNeutralPreprocessTest(unittest.TestCase): def test_calibration_s21_is_a_nonzero_divisor(self) -> None: # The preprocessor divides measured/calibration, so calibration S21 # must never be zero — that is the whole point of the '1+0j neutral' contract. - calibration, _reference = build_neutral_s21_sets(_kamil_config()) + calibration, _reference = build_neutral_s21_sets(_rph_config()) for trace in calibration.traces: self.assertTrue(bool(np.all(trace.s21 != 0))) diff --git a/python_app/tests/test_orchestration_recovery.py b/python_app/tests/test_orchestration_recovery.py index 4a1b428..bc1a112 100644 --- a/python_app/tests/test_orchestration_recovery.py +++ b/python_app/tests/test_orchestration_recovery.py @@ -141,9 +141,9 @@ class RadarModelCommandTest(unittest.TestCase): cmd = self.supervisor._acquisition_command(self._config_with_model("librevna_multi")) self.assertEqual(cmd[:3], [sys.executable, "-m", "python_app.scripts.matrix_raw_producer"]) - def test_kamil_producer_for_kamil_adc(self) -> None: - cmd = self.supervisor._acquisition_command(self._config_with_model("kamil_adc")) - self.assertEqual(cmd[:3], [sys.executable, "-m", "python_app.scripts.kamil_adc_raw_producer"]) + def test_rph_producer_for_rph_radar(self) -> None: + cmd = self.supervisor._acquisition_command(self._config_with_model("rph_radar")) + self.assertEqual(cmd[:3], [sys.executable, "-m", "python_app.scripts.rph_radar_raw_producer"]) def test_single_and_multi_use_python_in_both_modes(self) -> None: @@ -270,19 +270,19 @@ class SupervisorLifecycleTest(unittest.TestCase): group_kill.assert_not_called() kill.assert_called_once_with(123, signal.SIGTERM) - def test_collector_reaper_does_not_match_command_text(self) -> None: + def test_stop_does_not_kill_unowned_collector(self) -> None: entries = [Path("/proc/123"), Path("/proc/456")] cmdlines = [ - b"bash\x00-c\x00echo kamil_adc_collector\x00", - b"/project/build/bin/kamil_adc_collector\x00--config\x00config.json\x00", + b"bash\x00-c\x00echo rph_radar_collector\x00", + b"/project/build/bin/rph_radar_collector\x00--config\x00config.json\x00", ] with ( mock.patch.object(Path, "iterdir", return_value=entries), mock.patch.object(Path, "read_bytes", side_effect=cmdlines), mock.patch.object(ProcessSupervisor, "_signal_group") as signal_group, ): - self.supervisor._kill_kamil_collectors() - signal_group.assert_called_once_with(456, signal.SIGKILL) + self.supervisor.stop_all() + signal_group.assert_not_called() if __name__ == "__main__": diff --git a/python_app/tests/test_rph_radar_calibrate.py b/python_app/tests/test_rph_radar_calibrate.py new file mode 100644 index 0000000..dd7b0af --- /dev/null +++ b/python_app/tests/test_rph_radar_calibrate.py @@ -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() diff --git a/python_app/tests/test_kamil_adc_processing.py b/python_app/tests/test_rph_radar_processing.py similarity index 83% rename from python_app/tests/test_kamil_adc_processing.py rename to python_app/tests/test_rph_radar_processing.py index 7485fe8..bd3706f 100644 --- a/python_app/tests/test_kamil_adc_processing.py +++ b/python_app/tests/test_rph_radar_processing.py @@ -1,4 +1,4 @@ -"""Tests for the Kamil ADC reference-channel signal processing. +"""Tests for the RadioPhotonic radar (RPH) reference-channel signal processing. References are built from an explicit *unwrapped* phase ramp whose first sample lies in ``(-pi, pi]`` and whose steps are below ``pi``, so that @@ -9,15 +9,12 @@ accumulating forward) and lets us reason precisely about the frequency mapping. from __future__ import annotations -from dataclasses import replace import unittest import numpy as np -from python_app.hardware_full.kamil_adc.processing import ( - KamilAdcProcessingParams, - KamilAdcSweepProcessor, -) +from python_app.hardware_full.rph_radar.processing import RphRadarSweepProcessor +from python_app.models.run_config_schema import RphRadarModel, RphRadarBandModel, RphRadarPhaseCalibrationModel def _reference(phase: np.ndarray, amplitude: float = 1000.0) -> np.ndarray: @@ -25,46 +22,14 @@ def _reference(phase: np.ndarray, amplitude: float = 1000.0) -> np.ndarray: return amplitude * np.exp(1j * np.asarray(phase, dtype=np.float64)) -# Calibration shared by the processor tests: phase 0 rad -> 2 GHz, phase 100 rad -# -> 4 GHz (slope 2e7 Hz/rad). Band [2.5, 3.5] GHz corresponds to phase [25, 75]. -_CALIBRATION = KamilAdcProcessingParams( - phase0_rad=0.0, - freq0_hz=2_000_000_000.0, - phase1_rad=100.0, - freq1_hz=4_000_000_000.0, - band_start_hz=2_500_000_000.0, - band_stop_hz=3_500_000_000.0, - band_points=11, -) - - -class ProcessingParamsTest(unittest.TestCase): - def _params(self, **overrides: float) -> KamilAdcProcessingParams: - return replace(_CALIBRATION, **overrides) - - def test_slope_is_constant_from_anchors(self) -> None: - self.assertAlmostEqual(self._params().hz_per_rad, 2.0e7) - - def test_rejects_equal_phase_anchors(self) -> None: - with self.assertRaisesRegex(ValueError, "distinct phases"): - self._params(phase0_rad=5.0, phase1_rad=5.0) - - def test_rejects_equal_frequency_anchors(self) -> None: - with self.assertRaisesRegex(ValueError, "distinct frequencies"): - self._params(freq0_hz=3.0e9, freq1_hz=3.0e9) - - def test_rejects_inverted_band(self) -> None: - with self.assertRaisesRegex(ValueError, "stop_hz must be greater"): - self._params(band_start_hz=4.0e9, band_stop_hz=3.0e9) - - def test_rejects_degenerate_point_count(self) -> None: - with self.assertRaisesRegex(ValueError, "points must be >= 2"): - self._params(band_points=1) - - class SweepProcessorTest(unittest.TestCase): - def _processor(self, **overrides: float) -> KamilAdcSweepProcessor: - return KamilAdcSweepProcessor(replace(_CALIBRATION, **overrides)) + def _processor(self, **phase: float) -> RphRadarSweepProcessor: + calibration = {"phase0_rad": 0., "freq0_hz": 2e9, "phase1_rad": 100., "freq1_hz": 4e9} + calibration.update(phase) + return RphRadarSweepProcessor(RphRadarModel( + phase_calibration=RphRadarPhaseCalibrationModel(**calibration), + band=RphRadarBandModel(start_hz=2.5e9, stop_hz=3.5e9, points=11), + )) def test_grid_is_fixed_and_identical_across_calls(self) -> None: processor = self._processor() diff --git a/python_app/tests/test_rph_radar_protocol.py b/python_app/tests/test_rph_radar_protocol.py new file mode 100644 index 0000000..ad3f74f --- /dev/null +++ b/python_app/tests/test_rph_radar_protocol.py @@ -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(" 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() diff --git a/python_app/tests/test_run_config_codec.py b/python_app/tests/test_run_config_codec.py index 7aed108..478d690 100644 --- a/python_app/tests/test_run_config_codec.py +++ b/python_app/tests/test_run_config_codec.py @@ -99,6 +99,17 @@ class StrictNumericTypingTest(unittest.TestCase): class StructuralTypingTest(unittest.TestCase): """A present-but-wrong-shaped section fails loudly instead of being dropped.""" + def test_old_adc_model_is_rejected_without_alias(self) -> None: + payload = _valid_payload() + payload["radar"]["model"] = "kamil_adc" + with self.assertRaisesRegex(ValueError, "rph_radar"): + RunConfigModel.from_dict(payload) + + def test_inactive_old_adc_section_from_saved_profiles_is_ignored(self) -> None: + payload = _valid_payload() + payload["radar"]["kamil_adc"] = {"tty_path": "/tmp/old"} + self.assertEqual(RunConfigModel.from_dict(payload).radar.model, payload["radar"]["model"]) + def test_root_must_be_an_object(self) -> None: for payload in (None, [], 'bad', 42): with self.subTest(payload=payload), self.assertRaisesRegex(ValueError, 'root'): @@ -112,6 +123,19 @@ class StructuralTypingTest(unittest.TestCase): class AcquisitionValidationTest(unittest.TestCase): + def test_rph_key_normalizes_active_laser_mode(self) -> None: + keys = [] + for mode in ("variation", " Variation ", "VARIATION"): + config = self.config("rph_radar") + laser = config.radar.laser_control + laser.enabled, laser.mode = True, mode + original = config.radar_key() + keys.append(original) + laser.variation.max_value += 1 + with self.subTest(mode=mode): + self.assertNotEqual(config.radar_key(), original) + self.assertEqual(len(set(keys)), 1) + def test_default_combo_expansion_is_bounded_before_allocation(self) -> None: payload = _valid_payload() payload["switches"]["port1"]["positions"] = 65536 @@ -149,9 +173,31 @@ class AcquisitionValidationTest(unittest.TestCase): ring.slot_size_bytes = original validate_acquisition_config(config) - def test_kamil_requires_native_mode(self) -> None: + def test_radar_key_separates_switch_matrices(self) -> None: + stock, per_point = self.config("librevna_multi"), self.config("librevna_multi") + per_point.radar.multi_device.stimulus_switch_positions = 2 + self.assertTrue(stock.radar_key().endswith("_out1_in1_stim1")) + self.assertNotEqual(stock.radar_key(), per_point.radar_key()) + + def test_rph_requires_native_mode(self) -> None: with self.assertRaisesRegex(ValueError, "native"): - validate_acquisition_config(self.config("kamil_adc")) + validate_acquisition_config(self.config("rph_radar")) + + def test_adc_phase_band_and_timeouts_fail_preflight(self) -> None: + for section, field, value in ( + ("phase_calibration", "phase1_rad", 0.0), + ("phase_calibration", "freq1_hz", 2_046_000_000.0), + ("phase_calibration", "phase0_rad", float("nan")), + ("band", "stop_hz", 2_100_000_000.0), + ("band", "start_hz", -1.0), ("band", "points", 1), + ("", "sweep_timeout_s", 0.0), ("", "startup_timeout_s", float("inf")), + ): + config = self.config("rph_radar") + config.radar.driver_mode = "native" + adc = config.radar.rph_radar + setattr(getattr(adc, section) if section else adc, field, value) + with self.subTest(section=section, field=field), self.assertRaises(ValueError): + validate_acquisition_config(config) def test_matrix_validates_physical_switch_instead_of_virtual_axis(self) -> None: config = self.config("librevna_multi") diff --git a/python_app/tests/test_runtime_history.py b/python_app/tests/test_runtime_history.py index 4f2bbe0..caacbea 100644 --- a/python_app/tests/test_runtime_history.py +++ b/python_app/tests/test_runtime_history.py @@ -75,6 +75,37 @@ class RemoveLastHistoryTest(unittest.TestCase): class RuntimeSignatureTest(unittest.TestCase): + def test_reference_clock_changes_multi_device_history_only(self) -> None: + for model in ("librevna_multi", "librevna", "rph_radar"): + config = RunConfigModel() + config.radar.model = model + before = build_run_history_signature(config) + config.radar.multi_device.force_external_reference = not config.radar.multi_device.force_external_reference + with self.subTest(model=model): + self.assertEqual(before != build_run_history_signature(config), model == "librevna_multi") + + def test_adc_key_and_history_follow_measurement_not_launch_paths(self) -> None: + original = RunConfigModel() + original.radar.model, original.radar.driver_mode = "rph_radar", "native" + for section, field, value, changes_identity in ( + ("rph_radar.phase_calibration", "phase1_rad", 301.0, True), + ("rph_radar.band", "points", 1024, True), + ("rph_radar.band", "start_hz", 2.2e9, True), + ("rph_radar", "executable_path", "/new/collector", False), + ("rph_radar", "tty_path", "/new/tty", False), + ("sweep", "if_bandwidth_hz", 999.0, False), + ("", "serial", "unused ADC serial", False), + ): + changed = original.clone() + target = changed.radar + for part in section.split(".") if section else (): + target = getattr(target, part) + setattr(target, field, value) + with self.subTest(section=section, field=field): + self.assertEqual(original.radar_key() != changed.radar_key(), changes_identity) + self.assertEqual(build_run_history_signature(original) != build_run_history_signature(changed), + changes_identity) + def test_every_ring_endpoint_setting_requires_restart(self) -> None: original = RunConfigModel() signature = build_processor_run_signature(original) diff --git a/python_app/tests/test_storage_webui.py b/python_app/tests/test_storage_webui.py index 97093d2..af2e827 100644 --- a/python_app/tests/test_storage_webui.py +++ b/python_app/tests/test_storage_webui.py @@ -76,8 +76,8 @@ class RadarConfigPrefixTest(unittest.TestCase): def test_adc_model_reports_adc_point_count(self) -> None: self.assertEqual( - radar_config_filename_prefix("kamil_adc", 1e9, 6e9, 1, 0.0), - "kamil_adc_1-6GHz_adc_0dBm", + radar_config_filename_prefix("rph_radar", 1e9, 6e9, 1, 0.0), + "rph_radar_1-6GHz_adc_0dBm", ) diff --git a/python_app/workflows/neutral_preprocess.py b/python_app/workflows/neutral_preprocess.py index a29d530..8dfbbaa 100644 --- a/python_app/workflows/neutral_preprocess.py +++ b/python_app/workflows/neutral_preprocess.py @@ -2,7 +2,7 @@ Applying these sets leaves measurements unchanged and satisfies pipeline asset validation before physical calibration is available. The shortcut is enabled for -verified frequency axes: Kamil ADC, multi-device LibreVNA and single-device mock. +verified frequency axes: RadioPhotonic radar (RPH), multi-device LibreVNA and single-device mock. """ from __future__ import annotations @@ -12,10 +12,7 @@ import time import numpy as np -from python_app.hardware_full.kamil_adc import ( - KamilAdcProcessingParams, - KamilAdcSweepProcessor, -) +from python_app.hardware_full.rph_radar.processing import RphRadarSweepProcessor from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData from python_app.models.run_config_schema import ComboModel, RunConfigModel @@ -24,19 +21,16 @@ logger = logging.getLogger(__name__) def supports_neutral_preprocess_sets(config: RunConfigModel) -> bool: """Enable the shortcut only when the emitted frequency grid is verified.""" - return bool(config.is_kamil_adc or config.is_multi_device or ( + return bool(config.is_rph_radar or config.is_multi_device or ( config.radar.model == RunConfigModel.LIBREVNA_MODEL and config.radar.driver_mode == "mock" )) def neutral_frequency_grid_hz(config: RunConfigModel) -> np.ndarray: """Derive the neutral axis; the preprocessor verifies it against measured data.""" - if config.is_kamil_adc: + if config.is_rph_radar: # Single source of truth for the axis: the same grid the processor emits. - processor = KamilAdcSweepProcessor( - KamilAdcProcessingParams.from_kamil_model(config.radar.kamil_adc) - ) - return processor.grid_hz + return RphRadarSweepProcessor(config.radar.rph_radar).grid_hz points = int(config.radar.sweep.points) if points < 1: diff --git a/run_config.json b/run_config.json index e49a8d5..bd42668 100644 --- a/run_config.json +++ b/run_config.json @@ -8,33 +8,12 @@ "206930965532", "206930A15532" ], - "force_external_reference": true, + "force_external_reference": false, "recovery_attempts": 3, "output_switch_positions": 1, "input_switch_positions": 1, "stimulus_switch_positions": 2 }, - "kamil_adc": { - "project_dir": "", - "executable_path": "", - "tty_path": "", - "args": [], - "env": {}, - "startup_timeout_s": 5.0, - "sweep_timeout_s": 5.0, - "stop_timeout_s": 2.0, - "phase_calibration": { - "phase0_rad": 0.0, - "freq0_hz": 2046000000.0, - "phase1_rad": 300.0, - "freq1_hz": 5612000000.0 - }, - "band": { - "start_hz": 2100000000.0, - "stop_hz": 5500000000.0, - "points": 2048 - } - }, "laser_control": { "enabled": false, "port": "", @@ -59,8 +38,7 @@ "max_value": 35.0, "step": 0.1, "time_step": 20, - "delay_time": 3, - "temp_tolerance_c": 0.03 + "delay_time": 3 } }, "sweep": { diff --git a/run_config_examples/run_config_librevna.example.json b/run_config_examples/run_config_librevna.example.json index 2b77d0a..0429877 100644 --- a/run_config_examples/run_config_librevna.example.json +++ b/run_config_examples/run_config_librevna.example.json @@ -8,16 +8,6 @@ "force_external_reference": false, "recovery_attempts": 3 }, - "kamil_adc": { - "project_dir": "", - "executable_path": "", - "tty_path": "", - "args": [], - "env": {}, - "startup_timeout_s": 5.0, - "sweep_timeout_s": 5.0, - "stop_timeout_s": 2.0 - }, "laser_control": { "enabled": false, "port": "", diff --git a/run_config_examples/run_config_librevna_multi.example.json b/run_config_examples/run_config_librevna_multi.example.json index 2f5c898..8853898 100644 --- a/run_config_examples/run_config_librevna_multi.example.json +++ b/run_config_examples/run_config_librevna_multi.example.json @@ -8,19 +8,9 @@ "20A1307D5532", "2072306C5532" ], - "force_external_reference": true, + "force_external_reference": false, "recovery_attempts": 3 }, - "kamil_adc": { - "project_dir": "", - "executable_path": "", - "tty_path": "", - "args": [], - "env": {}, - "startup_timeout_s": 5.0, - "sweep_timeout_s": 5.0, - "stop_timeout_s": 2.0 - }, "laser_control": { "enabled": false, "port": "", diff --git a/run_config_examples/run_config_librevna_multi.example2.json b/run_config_examples/run_config_librevna_multi.example2.json index 03bb3f6..7c48253 100644 --- a/run_config_examples/run_config_librevna_multi.example2.json +++ b/run_config_examples/run_config_librevna_multi.example2.json @@ -8,19 +8,9 @@ "206930965532", "206930A15532" ], - "force_external_reference": true, + "force_external_reference": false, "recovery_attempts": 3 }, - "kamil_adc": { - "project_dir": "", - "executable_path": "", - "tty_path": "", - "args": [], - "env": {}, - "startup_timeout_s": 5.0, - "sweep_timeout_s": 5.0, - "stop_timeout_s": 2.0 - }, "laser_control": { "enabled": false, "port": "", diff --git a/run_config_examples/run_config_librevna_multi_per_point.example.json b/run_config_examples/run_config_librevna_multi_per_point.example.json index 448899c..dae99c4 100644 --- a/run_config_examples/run_config_librevna_multi_per_point.example.json +++ b/run_config_examples/run_config_librevna_multi_per_point.example.json @@ -1,40 +1,19 @@ { "radar": { "model": "librevna_multi", - "serial": "206830985532", + "serial": "209E307A5532", "driver_mode": "native", "multi_device": { "slave_serials": [ - "206930965532", - "206930A15532" + "20A1307D5532", + "2072306C5532" ], - "force_external_reference": true, + "force_external_reference": false, "recovery_attempts": 3, "output_switch_positions": 1, "input_switch_positions": 1, "stimulus_switch_positions": 2 }, - "kamil_adc": { - "project_dir": "", - "executable_path": "", - "tty_path": "", - "args": [], - "env": {}, - "startup_timeout_s": 5.0, - "sweep_timeout_s": 5.0, - "stop_timeout_s": 2.0, - "phase_calibration": { - "phase0_rad": 0.0, - "freq0_hz": 2046000000.0, - "phase1_rad": 300.0, - "freq1_hz": 5612000000.0 - }, - "band": { - "start_hz": 2100000000.0, - "stop_hz": 5500000000.0, - "points": 2048 - } - }, "laser_control": { "enabled": false, "port": "", @@ -59,17 +38,16 @@ "max_value": 35.0, "step": 0.1, "time_step": 20, - "delay_time": 3, - "temp_tolerance_c": 0.03 + "delay_time": 3 } }, "sweep": { - "start_hz": 1000000.0, + "start_hz": 500000000.0, "stop_hz": 6000000000.0, + "points": 1031, "if_bandwidth_hz": 50000.0, - "stimulus_power_dbm": -10.0, "dwell_us": 2, - "points": 201 + "stimulus_power_dbm": -3.0 } }, "switches": { @@ -190,11 +168,15 @@ "preprocess": { "s21": { "calibration": { - "set_name": "smoke_cal3", + "set_name": "20260930_low_krot", "bundle_path": "" }, "reference": { - "set_name": "smoke_cal3", + "set_name": "new_cable_nul", + "bundle_path": "" + }, + "object_calibration": { + "set_name": "", "bundle_path": "" } }, @@ -210,51 +192,51 @@ "tx_geometry": [ { "output_pos": 0, - "x_m": 0.905, + "x_m": -0.89, "y_m": 0.0, "z_m": 0.0 }, { "output_pos": 1, - "x_m": -0.905, + "x_m": 0.89, "y_m": 0.0, "z_m": 0.0 }, { "output_pos": 2, - "x_m": 0.905, - "y_m": 0.0, + "x_m": -0.32, + "y_m": 0.46, "z_m": 0.0 }, { "output_pos": 3, - "x_m": -0.905, - "y_m": 0.0, + "x_m": 0.32, + "y_m": 0.46, "z_m": 0.0 } ], "rx_geometry": [ { "input_pos": 0, - "x_m": -0.18, + "x_m": 0.23, "y_m": 0.0, "z_m": 0.0 }, { "input_pos": 1, - "x_m": 0.485, - "y_m": 0.0, + "x_m": 0.53, + "y_m": 0.3, "z_m": 0.0 }, { "input_pos": 2, - "x_m": -0.49, - "y_m": 0.0, + "x_m": -0.53, + "y_m": 0.145, "z_m": 0.0 }, { "input_pos": 3, - "x_m": 0.185, + "x_m": -0.23, "y_m": 0.0, "z_m": 0.0 } @@ -286,5 +268,117 @@ "capacity": 50, "slot_size_bytes": 2097152 } + }, + "gui": { + "version": 1, + "switches": { + "combo_mode": "text", + "combos_text": "0:0,1:0,2:0,3:0,0:1,1:1,2:1,3:1,0:2,1:2,2:2,3:2,0:3,1:3,2:3,3:3", + "single_input": "0", + "single_output": "0" + }, + "processing": { + "selected_mode": "sar3d", + "pass_through": { + "show_magnitude": true, + "show_phase": true, + "unwrap_phase": false, + "combo_filter": "", + "fixed_y_enabled": false, + "y_min_db": -100.0, + "y_max_db": 0.0 + }, + "bscan": { + "axis": "abs", + "cut_m": 0.824, + "max_depth_m": 1.0, + "gain": 1.0, + "start_freq_mhz": 2000.0, + "stop_freq_mhz": 6000.0, + "subtract_mean_ascan": false, + "history_window_scans": 50 + }, + "gpr": { + "input_positions": "0,1,2,3", + "output_positions": "0,1,2,3", + "min_depth_m": 3.0, + "max_depth_m": 8.0, + "range_comp_power": 0.1, + "object_min_frac": 0.7, + "score_mode": "combined", + "motion_mode": "int_minus", + "look_angle_deg": 10.0, + "direction_sign": 1.0, + "speed_m_s": 0.0, + "ignore_socket_speed_enabled": true, + "max_detected_objects_to_draw": 7, + "draw_top_m_objects": 2, + "object_approach_min_frames": 3, + "start_freq_mhz": 2000.0, + "stop_freq_mhz": 6000.0, + "background_subtract_enabled": false, + "background_mean_count": 10, + "remove_sidelobe_objects_enabled": true, + "imaging_plane_y_m": 0.0, + "render_mode": "heatmap", + "visible_x_min_m": -2.91, + "visible_x_max_m": 2.91, + "visible_z_min_m": 0.0, + "visible_z_max_m": 8.0 + }, + "legacy_gpr": { + "mode": "point", + "input_positions": "0,1,2,3", + "output_positions": "0,1,2,3", + "min_depth_m": 2.0, + "max_depth_m": 14.0, + "comp_power": 0.2, + "start_freq_mhz": 3000.0, + "stop_freq_mhz": 6000.0, + "speed_m_s": 0.0, + "ignore_socket_speed_enabled": false, + "look_angle_deg": 0.0, + "apply_freq_phase_correction": true, + "reference_mode": "frame_center", + "snr_thresh": 4.5, + "snr_comp_max": 25.0, + "background_subtract_enabled": true, + "background_mean_count": 10, + "render_mode": "heatmap", + "min_visible_pair_count": 1, + "visible_x_min_m": -2.91, + "visible_x_max_m": 2.91, + "visible_z_min_m": 0.0, + "visible_z_max_m": 14.0 + }, + "sar3d": { + "input_positions": "", + "output_positions": "", + "min_depth_m": 2.5, + "max_depth_m": 8.0, + "start_freq_mhz": 2000.0, + "stop_freq_mhz": 6000.0, + "speed_m_s": 0.0, + "ignore_socket_speed_enabled": false, + "look_angle_deg": 10.0, + "frame_radius": 0, + "voxel_m": 0.02, + "bp_ground_depth_m": 1.25, + "comp_range_power": 0.3, + "use_compensation": true, + "normalize_each_frame": true + } + }, + "data_actions": { + "save_path": "", + "save_name": "snapshot_manual", + "record_count": 100 + }, + "preprocess_dialog": { + "set_name": "new_cable_nul", + "radar_config_dir": "", + "use_all_radar_configs": false, + "median_sweep_count": 5 + } } } diff --git a/run_config_examples/run_config_librevna_multi_per_point_lowband.example.json b/run_config_examples/run_config_librevna_multi_per_point_lowband.example.json index e3d3291..992ef24 100644 --- a/run_config_examples/run_config_librevna_multi_per_point_lowband.example.json +++ b/run_config_examples/run_config_librevna_multi_per_point_lowband.example.json @@ -8,33 +8,12 @@ "206930965532", "206930A15532" ], - "force_external_reference": true, + "force_external_reference": false, "recovery_attempts": 3, "output_switch_positions": 1, "input_switch_positions": 1, "stimulus_switch_positions": 2 }, - "kamil_adc": { - "project_dir": "", - "executable_path": "", - "tty_path": "", - "args": [], - "env": {}, - "startup_timeout_s": 5.0, - "sweep_timeout_s": 5.0, - "stop_timeout_s": 2.0, - "phase_calibration": { - "phase0_rad": 0.0, - "freq0_hz": 2046000000.0, - "phase1_rad": 300.0, - "freq1_hz": 5612000000.0 - }, - "band": { - "start_hz": 2100000000.0, - "stop_hz": 5500000000.0, - "points": 2048 - } - }, "laser_control": { "enabled": false, "port": "", @@ -59,8 +38,7 @@ "max_value": 35.0, "step": 0.1, "time_step": 20, - "delay_time": 3, - "temp_tolerance_c": 0.03 + "delay_time": 3 } }, "sweep": { diff --git a/run_config_examples/run_config_kamil_adc.example.json b/run_config_examples/run_config_rph_radar.example.json similarity index 98% rename from run_config_examples/run_config_kamil_adc.example.json rename to run_config_examples/run_config_rph_radar.example.json index 9f35c68..5be3752 100644 --- a/run_config_examples/run_config_kamil_adc.example.json +++ b/run_config_examples/run_config_rph_radar.example.json @@ -1,6 +1,6 @@ { "radar": { - "model": "kamil_adc", + "model": "rph_radar", "serial": "kamil_adc", "driver_mode": "native", "multi_device": { @@ -8,9 +8,9 @@ "force_external_reference": false, "recovery_attempts": 3 }, - "kamil_adc": { + "rph_radar": { "project_dir": "", - "executable_path": "build/bin/kamil_adc_collector", + "executable_path": "build/bin/rph_radar_collector", "tty_path": "/tmp/ttyADC_data", "args": [ "profile:phase", diff --git a/run_config_examples/run_config_simulator.example.json b/run_config_examples/run_config_simulator.example.json index ab1a481..8c51651 100644 --- a/run_config_examples/run_config_simulator.example.json +++ b/run_config_examples/run_config_simulator.example.json @@ -8,16 +8,6 @@ "force_external_reference": false, "recovery_attempts": 3 }, - "kamil_adc": { - "project_dir": "", - "executable_path": "", - "tty_path": "", - "args": [], - "env": {}, - "startup_timeout_s": 5.0, - "sweep_timeout_s": 5.0, - "stop_timeout_s": 2.0 - }, "laser_control": { "enabled": false, "port": "", diff --git a/run_configs/run_config.json b/run_configs/run_config.json index 9fd9947..f0f8cb2 100644 --- a/run_configs/run_config.json +++ b/run_configs/run_config.json @@ -8,16 +8,6 @@ "force_external_reference": false, "recovery_attempts": 3 }, - "kamil_adc": { - "project_dir": "", - "executable_path": "", - "tty_path": "", - "args": [], - "env": {}, - "startup_timeout_s": 5.0, - "sweep_timeout_s": 5.0, - "stop_timeout_s": 2.0 - }, "laser_control": { "enabled": false, "port": "", diff --git a/start.sh b/start.sh index 1b77bcf..2a94d89 100755 --- a/start.sh +++ b/start.sh @@ -286,11 +286,11 @@ EOF build_cpp_binaries() { local jobs jobs="${BUILD_JOBS:-$(nproc)}" - # The Kamil ADC collector is an extra, device-specific binary built only for + # The RadioPhotonic radar (RPH) collector is an extra, device-specific binary built only for # that model; all models share the core pipeline binaries (`all`). local targets="all" - if [[ "${RADAR_MODEL}" == "kamil_adc" ]]; then - targets="all kamil_adc_collector" + if [[ "${RADAR_MODEL}" == "rph_radar" ]]; then + targets="all rph_radar_collector" fi if make -C "${PROJECT_ROOT}" -q ${targets} >/dev/null 2>&1; then echo "[start.sh] C++ binaries are up to date; skipping build." @@ -308,22 +308,14 @@ cleanup_known_shm() { /dev/shm/radar_preprocessed \ /dev/shm/radar_preprocessed_tap \ /dev/shm/radar_results \ - /dev/shm/radar_raw_kamil_adc \ - /dev/shm/radar_raw_tap_kamil_adc \ - /dev/shm/radar_preprocessed_kamil_adc \ - /dev/shm/radar_preprocessed_tap_kamil_adc \ - /dev/shm/radar_results_kamil_adc \ + /dev/shm/radar_raw_rph_radar \ + /dev/shm/radar_raw_tap_rph_radar \ + /dev/shm/radar_preprocessed_rph_radar \ + /dev/shm/radar_preprocessed_tap_rph_radar \ + /dev/shm/radar_results_rph_radar \ || true } -kill_stale_adc_collector() { - # Exact executable matching: an argument mentioning the collector is not a process to kill. - PYTHONPATH="${PROJECT_ROOT}${PYTHONPATH:+:${PYTHONPATH}}" "${PYTHON_CMD}" -c ' -from python_app.orchestration.process_supervisor import ProcessSupervisor -ProcessSupervisor._kill_kamil_collectors() -' -} - run_gui() { if [[ -n "${PROFILE_PATH}" ]]; then export RADAR_SYSTEM_PROFILE="${PROFILE_PATH}" @@ -393,7 +385,7 @@ import sys from pathlib import Path from python_app.orchestration.producer_selection import acquisition_command command = acquisition_command(Path(sys.argv[1])) -print("kamil_adc_collector" if "python_app.scripts.kamil_adc_raw_producer" in command else "") +print("rph_radar_collector" if "python_app.scripts.rph_radar_raw_producer" in command else "") ' "${PROFILE_PATH:-${PROJECT_ROOT}/run_config.json}")" required_bins="${required_bins} ${acquisition_binary}" for bin in ${required_bins}; do @@ -431,7 +423,7 @@ main() { # LibreVNA uses Python/libusb; Python/C++ SHM needs libatomic. if ((HEADLESS == 0)); then ensure_system_dependencies - if [[ "${RADAR_MODEL}" != "kamil_adc" ]]; then ensure_usb_access_rules; fi + if [[ "${RADAR_MODEL}" != "rph_radar" ]]; then ensure_usb_access_rules; fi fi ensure_python_dependencies @@ -456,9 +448,6 @@ main() { if ((CLEAN_SHM == 1)); then cleanup_known_shm fi - # Clear any ADC collector wedged by a previous run before we launch a new one. - kill_stale_adc_collector - if ((PRODUCER_ONLY == 1)); then run_producer_only fi