new kamil adc

This commit is contained in:
Ayzen
2026-06-11 13:02:02 +03:00
parent 21f76d7cd2
commit 9661504e51
46 changed files with 12097 additions and 1063 deletions
+27 -2
View File
@@ -60,20 +60,35 @@ 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
# 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
KAMIL_COLLECTOR_INCLUDES := -I$(KAMIL_COLLECTOR_DIR)/include -I$(KAMIL_COLLECTOR_DIR)/vendor/lcard
KAMIL_COLLECTOR_LDFLAGS := -pthread -ldl -lutil
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
SWEEP_ORCH_OBJS := $(addprefix $(BUILD_DIR)/,$(COMMON_SOURCES:.cpp=.o) $(ORCH_SOURCES:.cpp=.o))
PREPROCESSOR_OBJS := $(addprefix $(BUILD_DIR)/,$(COMMON_SOURCES:.cpp=.o) $(PREPROC_SOURCES:.cpp=.o))
DATA_PROCESSOR_OBJS := $(addprefix $(BUILD_DIR)/,$(COMMON_SOURCES:.cpp=.o) $(PROCESSOR_SOURCES:.cpp=.o))
DEPFILES := $(sort $(SWEEP_ORCH_OBJS:.o=.d) $(PREPROCESSOR_OBJS:.o=.d) $(DATA_PROCESSOR_OBJS:.o=.d))
KAMIL_COLLECTOR_OBJS := $(addprefix $(BUILD_DIR)/,$(KAMIL_COLLECTOR_SOURCES:.cpp=.o))
DEPFILES := $(sort $(SWEEP_ORCH_OBJS:.o=.d) $(PREPROCESSOR_OBJS:.o=.d) $(DATA_PROCESSOR_OBJS:.o=.d) $(KAMIL_COLLECTOR_OBJS:.o=.d))
TARGETS := \
$(BIN_DIR)/sweep_orchestrator \
$(BIN_DIR)/data_preprocessor \
$(BIN_DIR)/data_processor
.PHONY: all clean
.PHONY: all clean kamil_adc_collector
all: $(TARGETS)
kamil_adc_collector: $(BIN_DIR)/kamil_adc_collector
$(BIN_DIR)/sweep_orchestrator: $(SWEEP_ORCH_OBJS)
@mkdir -p $(BIN_DIR)
$(CXX) $(SWEEP_ORCH_OBJS) -o $@ $(ORCH_LDFLAGS)
@@ -86,6 +101,16 @@ $(BIN_DIR)/data_processor: $(DATA_PROCESSOR_OBJS)
@mkdir -p $(BIN_DIR)
$(CXX) $(DATA_PROCESSOR_OBJS) -o $@ $(LDFLAGS)
# The collector is self-contained: compile its objects with only the vendored
# L-Card headers (no project/VISA includes) by overriding the generic rule's
# variables for these objects, then link with dlopen/openpty support.
$(KAMIL_COLLECTOR_OBJS): INCLUDES := $(KAMIL_COLLECTOR_INCLUDES)
$(KAMIL_COLLECTOR_OBJS): VISA_CXXFLAGS :=
$(BIN_DIR)/kamil_adc_collector: $(KAMIL_COLLECTOR_OBJS)
@mkdir -p $(BIN_DIR)
$(CXX) $(KAMIL_COLLECTOR_OBJS) -o $@ $(KAMIL_COLLECTOR_LDFLAGS)
$(BUILD_DIR)/%.o: %.cpp
@mkdir -p $(dir $@)
$(CXX) $(CXXFLAGS) $(VISA_CXXFLAGS) $(INCLUDES) -c $< -o $@
+3 -2
View File
@@ -93,8 +93,9 @@ Run the desktop GUI:
```
`start.sh` supports `--headless` (offscreen, auto-start — for unattended Pi use),
`--kamil-adc`, `--profile PATH`, `--producer-only`, `--skip-build`, and
`--clean-shm`; see `./start.sh --help`.
`--profile PATH`, `--producer-only`, `--skip-build`, and `--clean-shm`; see
`./start.sh --help`. The acquisition device (LibreVNA, Kamil ADC, …) is detected
automatically from the active config's `radar.model` — there is no per-device flag.
The web interface is served in-process at `http://<host>:8080`. It mirrors the live
plot and offers Start / Stop / Capture controls and the active processor settings.
@@ -0,0 +1,42 @@
# 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, which the Python `kamil_adc` producer
(`python_app/hardware_full/kamil_adc`) reads and processes.
This is the data *source* for `radar.model == "kamil_adc"`; it is the Kamil ADC
counterpart to the C++ `sweep_orchestrator` used by LibreVNA devices.
## Layout
| Path | Contents |
|------|----------|
| `src/` | Collector implementation (`main.cpp`) and the PTY / capture-file writers. |
| `include/` | Headers for the writers. |
| `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. |
The sources under `src/` and `include/` are imported verbatim from the original
standalone tool and are kept byte-for-byte identical — the wire format and
acquisition behaviour are unchanged; only their home moved into this repo.
## 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"`. The build is self-contained:
it needs only the vendored headers and links `-ldl -lutil -lpthread`.
## Runtime
The collector loads the proprietary `libx502api.so` / `libe502api.so` at run time
via `dlopen`, so those libraries must be on `LD_LIBRARY_PATH`. 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 the generated `tty:<path>` argument.
@@ -0,0 +1,55 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
struct CapturePacket {
std::size_t packet_index = 0;
std::size_t channel_count = 2;
bool has_di1_trace = false;
std::vector<double> ch1;
std::vector<double> ch2;
std::vector<uint8_t> di1;
};
class CaptureFileWriter {
public:
CaptureFileWriter(std::string csv_path,
std::string svg_path,
std::string live_html_path,
std::string live_json_path);
void write(const std::vector<CapturePacket>& packets,
double frame_freq_hz,
double nominal_range_v) const;
void initialize_live_plot() const;
void initialize_csv(std::size_t channel_count, bool has_di1_trace) const;
void append_csv_packet(const CapturePacket& packet,
double frame_freq_hz,
std::size_t& global_frame_index) const;
void update_live_plot(const CapturePacket& packet,
std::size_t packets_seen,
double packets_per_second,
double frame_freq_hz,
const std::string& close_reason,
std::size_t zeroed_samples,
std::size_t stored_samples) const;
const std::string& live_html_path() const;
const std::string& live_json_path() const;
private:
void finalize_csv_from_spool(double frame_freq_hz) const;
void write_svg(const std::vector<CapturePacket>& packets,
double frame_freq_hz,
double nominal_range_v) const;
std::string csv_path_;
std::string svg_path_;
std::string live_html_path_;
std::string live_json_path_;
};
@@ -0,0 +1,41 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <memory>
#include <string>
class TtyProtocolWriter {
public:
struct StatsSnapshot {
std::uint64_t frames_written = 0;
std::uint64_t frames_dropped = 0;
std::uint64_t ring_overflows = 0;
};
TtyProtocolWriter(std::string path, std::size_t ring_capacity_bytes);
~TtyProtocolWriter();
TtyProtocolWriter(const TtyProtocolWriter&) = delete;
TtyProtocolWriter& operator=(const TtyProtocolWriter&) = delete;
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);
StatsSnapshot stats() const;
const std::string& path() const;
void throw_if_failed() const;
void shutdown();
private:
void enqueue_frame(uint16_t word0, uint16_t word1, uint16_t word2, uint16_t word3);
void worker_loop();
std::string path_;
struct Impl;
std::unique_ptr<Impl> impl_;
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,450 @@
#include "tty_protocol_writer.h"
#include <algorithm>
#include <array>
#include <chrono>
#include <cstring>
#include <exception>
#include <stdexcept>
#include <thread>
#include <utility>
#include <vector>
#ifdef _WIN32
struct TtyProtocolWriter::Impl {};
TtyProtocolWriter::TtyProtocolWriter(std::string path, std::size_t ring_capacity_bytes)
: path_(std::move(path)) {
(void) ring_capacity_bytes;
throw std::runtime_error("tty output is supported only on Linux/POSIX");
}
TtyProtocolWriter::~TtyProtocolWriter() = default;
void TtyProtocolWriter::emit_packet_start(uint16_t marker) {
(void) marker;
}
void TtyProtocolWriter::emit_step(uint16_t index, int16_t ch1_avg, int16_t ch2_avg) {
(void) index;
(void) ch1_avg;
(void) ch2_avg;
}
void TtyProtocolWriter::enqueue_encoded_frames(const uint16_t* words, std::size_t frame_count) {
(void) words;
(void) frame_count;
}
TtyProtocolWriter::StatsSnapshot TtyProtocolWriter::stats() const {
return {};
}
const std::string& TtyProtocolWriter::path() const {
return path_;
}
void TtyProtocolWriter::throw_if_failed() const {}
void TtyProtocolWriter::shutdown() {}
void TtyProtocolWriter::enqueue_frame(uint16_t word0, uint16_t word1, uint16_t word2, uint16_t word3) {
(void) word0;
(void) word1;
(void) word2;
(void) word3;
}
void TtyProtocolWriter::worker_loop() {}
#else
#include <cerrno>
#include <condition_variable>
#include <fcntl.h>
#include <limits.h>
#include <mutex>
#include <optional>
#include <pty.h>
#include <sstream>
#include <sys/stat.h>
#include <sys/types.h>
#include <termios.h>
#include <unistd.h>
namespace {
constexpr std::size_t kFrameWordCount = 4U;
constexpr std::size_t kFrameByteCount = kFrameWordCount * sizeof(uint16_t);
constexpr std::size_t kWriteBatchFrames = 256U;
using EncodedFrame = std::array<std::uint8_t, kFrameByteCount>;
std::string io_error(const std::string& action, const std::string& path) {
std::ostringstream out;
out << action << " '" << path << "': " << std::strerror(errno);
return out.str();
}
void close_fd_if_open(int& fd) noexcept {
if (fd >= 0) {
::close(fd);
fd = -1;
}
}
void set_fd_raw(int fd) {
struct termios tio {};
if (::tcgetattr(fd, &tio) != 0) {
throw std::runtime_error(io_error("Cannot read tty attributes for", std::to_string(fd)));
}
::cfmakeraw(&tio);
tio.c_cc[VINTR] = _POSIX_VDISABLE;
tio.c_cc[VQUIT] = _POSIX_VDISABLE;
tio.c_cc[VERASE] = _POSIX_VDISABLE;
tio.c_cc[VKILL] = _POSIX_VDISABLE;
tio.c_cc[VEOF] = _POSIX_VDISABLE;
tio.c_cc[VTIME] = 0;
tio.c_cc[VMIN] = 1;
#ifdef VSWTC
tio.c_cc[VSWTC] = _POSIX_VDISABLE;
#endif
tio.c_cc[VSTART] = _POSIX_VDISABLE;
tio.c_cc[VSTOP] = _POSIX_VDISABLE;
tio.c_cc[VSUSP] = _POSIX_VDISABLE;
#ifdef VEOL
tio.c_cc[VEOL] = _POSIX_VDISABLE;
#endif
#ifdef VREPRINT
tio.c_cc[VREPRINT] = _POSIX_VDISABLE;
#endif
#ifdef VDISCARD
tio.c_cc[VDISCARD] = _POSIX_VDISABLE;
#endif
#ifdef VWERASE
tio.c_cc[VWERASE] = _POSIX_VDISABLE;
#endif
#ifdef VLNEXT
tio.c_cc[VLNEXT] = _POSIX_VDISABLE;
#endif
#ifdef VEOL2
tio.c_cc[VEOL2] = _POSIX_VDISABLE;
#endif
if (::tcsetattr(fd, TCSANOW, &tio) != 0) {
throw std::runtime_error(io_error("Cannot apply raw tty attributes to", std::to_string(fd)));
}
}
bool is_character_device_path(const std::string& path) {
struct stat st {};
if (::stat(path.c_str(), &st) != 0) {
if (errno == ENOENT) {
return false;
}
throw std::runtime_error(io_error("Cannot stat tty output", path));
}
return S_ISCHR(st.st_mode);
}
std::optional<std::string> read_link_target(const std::string& path) {
std::array<char, PATH_MAX> buf {};
const ssize_t len = ::readlink(path.c_str(), buf.data(), buf.size() - 1U);
if (len < 0) {
if (errno == EINVAL || errno == ENOENT) {
return std::nullopt;
}
throw std::runtime_error(io_error("Cannot read symlink", path));
}
buf[static_cast<std::size_t>(len)] = '\0';
return std::string(buf.data());
}
EncodedFrame encode_frame(uint16_t word0, uint16_t word1, uint16_t word2, uint16_t word3) {
const uint16_t words[kFrameWordCount] = {word0, word1, word2, word3};
EncodedFrame frame {};
std::memcpy(frame.data(), words, sizeof(words));
return frame;
}
} // namespace
struct TtyProtocolWriter::Impl {
explicit Impl(std::size_t ring_capacity_bytes)
: capacity_frames(std::max<std::size_t>(1U, ring_capacity_bytes / kFrameByteCount)),
ring(capacity_frames) {}
int fd = -1;
int slave_fd = -1;
std::string slave_path;
bool owns_link = false;
const std::size_t capacity_frames;
std::vector<EncodedFrame> ring;
std::size_t head = 0;
std::size_t size = 0;
mutable std::mutex mutex;
std::condition_variable data_ready_cv;
std::thread worker;
bool stop_requested = false;
std::exception_ptr failure;
StatsSnapshot stats;
};
TtyProtocolWriter::TtyProtocolWriter(std::string path, std::size_t ring_capacity_bytes)
: path_(std::move(path)),
impl_(std::make_unique<Impl>(ring_capacity_bytes)) {
if (is_character_device_path(path_)) {
impl_->fd = ::open(path_.c_str(), O_WRONLY | O_NOCTTY);
if (impl_->fd < 0) {
throw std::runtime_error(io_error("Cannot open tty output", path_));
}
} else {
std::array<char, PATH_MAX> slave_name {};
if (::openpty(&impl_->fd, &impl_->slave_fd, slave_name.data(), nullptr, nullptr) != 0) {
throw std::runtime_error(io_error("Cannot create PTY bridge for", path_));
}
try {
impl_->slave_path = slave_name.data();
set_fd_raw(impl_->slave_fd);
struct stat st {};
if (::lstat(path_.c_str(), &st) == 0) {
if (!S_ISLNK(st.st_mode) && !S_ISREG(st.st_mode)) {
throw std::runtime_error("Refusing to replace non-link path '" + path_ + "'");
}
if (::unlink(path_.c_str()) != 0) {
throw std::runtime_error(io_error("Cannot remove existing tty link", path_));
}
} else if (errno != ENOENT) {
throw std::runtime_error(io_error("Cannot inspect tty link path", path_));
}
if (::symlink(impl_->slave_path.c_str(), path_.c_str()) != 0) {
throw std::runtime_error(io_error("Cannot create tty symlink", path_));
}
impl_->owns_link = true;
} catch (...) {
close_fd_if_open(impl_->slave_fd);
close_fd_if_open(impl_->fd);
throw;
}
}
impl_->worker = std::thread([this]() { worker_loop(); });
}
TtyProtocolWriter::~TtyProtocolWriter() {
try {
shutdown();
} catch (...) {
}
if (!impl_) {
return;
}
if (impl_->owns_link && !path_.empty()) {
try {
const auto target = read_link_target(path_);
if (target && (*target == impl_->slave_path)) {
::unlink(path_.c_str());
}
} catch (...) {
}
impl_->owns_link = false;
}
close_fd_if_open(impl_->slave_fd);
close_fd_if_open(impl_->fd);
}
void TtyProtocolWriter::emit_packet_start(uint16_t marker) {
enqueue_frame(marker, 0xFFFF, 0xFFFF, 0xFFFF);
}
void TtyProtocolWriter::emit_step(uint16_t index, int16_t ch1_avg, int16_t ch2_avg) {
enqueue_frame(0x000A,
index,
static_cast<uint16_t>(ch1_avg),
static_cast<uint16_t>(ch2_avg));
}
void TtyProtocolWriter::enqueue_encoded_frames(const uint16_t* words, std::size_t frame_count) {
if ((frame_count == 0U) || (words == nullptr)) {
return;
}
throw_if_failed();
std::lock_guard<std::mutex> lock(impl_->mutex);
if (impl_->failure) {
std::rethrow_exception(impl_->failure);
}
if (impl_->stop_requested) {
throw std::runtime_error("tty writer is already shut down");
}
std::size_t start_frame = 0;
std::size_t frames_to_copy = frame_count;
std::size_t dropped_frames = 0;
if (frame_count >= impl_->capacity_frames) {
start_frame = frame_count - impl_->capacity_frames;
frames_to_copy = impl_->capacity_frames;
dropped_frames = impl_->size + start_frame;
impl_->head = 0;
impl_->size = 0;
} else {
const std::size_t available_frames = impl_->capacity_frames - impl_->size;
if (frame_count > available_frames) {
dropped_frames = frame_count - available_frames;
impl_->head = (impl_->head + dropped_frames) % impl_->capacity_frames;
impl_->size -= dropped_frames;
}
}
if (dropped_frames != 0U) {
impl_->stats.frames_dropped += static_cast<std::uint64_t>(dropped_frames);
++impl_->stats.ring_overflows;
}
for (std::size_t i = 0; i < frames_to_copy; ++i) {
const std::size_t src_index = (start_frame + i) * kFrameWordCount;
const std::size_t dst_index = (impl_->head + impl_->size) % impl_->capacity_frames;
impl_->ring[dst_index] = encode_frame(words[src_index + 0U],
words[src_index + 1U],
words[src_index + 2U],
words[src_index + 3U]);
++impl_->size;
}
impl_->data_ready_cv.notify_one();
}
TtyProtocolWriter::StatsSnapshot TtyProtocolWriter::stats() const {
if (!impl_) {
return {};
}
std::lock_guard<std::mutex> lock(impl_->mutex);
return impl_->stats;
}
const std::string& TtyProtocolWriter::path() const {
return path_;
}
void TtyProtocolWriter::throw_if_failed() const {
if (!impl_) {
return;
}
std::exception_ptr failure;
{
std::lock_guard<std::mutex> lock(impl_->mutex);
failure = impl_->failure;
}
if (failure) {
std::rethrow_exception(failure);
}
}
void TtyProtocolWriter::shutdown() {
if (!impl_) {
return;
}
{
std::lock_guard<std::mutex> lock(impl_->mutex);
impl_->stop_requested = true;
}
close_fd_if_open(impl_->fd);
impl_->data_ready_cv.notify_all();
if (impl_->worker.joinable()) {
impl_->worker.join();
}
}
void TtyProtocolWriter::enqueue_frame(uint16_t word0, uint16_t word1, uint16_t word2, uint16_t word3) {
const uint16_t words[kFrameWordCount] = {word0, word1, word2, word3};
enqueue_encoded_frames(words, 1U);
}
void TtyProtocolWriter::worker_loop() {
for (;;) {
std::array<std::uint8_t, kWriteBatchFrames * kFrameByteCount> write_batch;
std::size_t batch_count = 0;
{
std::unique_lock<std::mutex> lock(impl_->mutex);
impl_->data_ready_cv.wait(lock, [this]() {
return impl_->stop_requested || impl_->failure || (impl_->size != 0U);
});
if (impl_->failure || impl_->stop_requested) {
return;
}
batch_count = std::min<std::size_t>(impl_->size, kWriteBatchFrames);
for (std::size_t i = 0; i < batch_count; ++i) {
const EncodedFrame& frame = impl_->ring[impl_->head];
std::memcpy(write_batch.data() + (i * kFrameByteCount), frame.data(), kFrameByteCount);
impl_->head = (impl_->head + 1U) % impl_->capacity_frames;
--impl_->size;
}
}
const std::uint8_t* bytes = write_batch.data();
std::size_t remaining = batch_count * kFrameByteCount;
while (remaining != 0U) {
const ssize_t written = ::write(impl_->fd, bytes, remaining);
if (written < 0) {
if (errno == EINTR) {
continue;
}
if ((errno == EAGAIN) || (errno == EWOULDBLOCK) || (errno == EIO)) {
{
std::lock_guard<std::mutex> lock(impl_->mutex);
if (impl_->stop_requested) {
return;
}
}
std::this_thread::sleep_for(std::chrono::milliseconds(5));
continue;
}
std::lock_guard<std::mutex> lock(impl_->mutex);
if (!impl_->stop_requested) {
impl_->failure = std::make_exception_ptr(
std::runtime_error(io_error("Cannot write tty frame to", path_)));
}
impl_->data_ready_cv.notify_all();
return;
}
if (written == 0) {
std::lock_guard<std::mutex> lock(impl_->mutex);
if (!impl_->stop_requested) {
impl_->failure = std::make_exception_ptr(
std::runtime_error("tty write returned 0 bytes for '" + path_ + "'"));
}
impl_->data_ready_cv.notify_all();
return;
}
bytes += static_cast<std::size_t>(written);
remaining -= static_cast<std::size_t>(written);
}
{
std::lock_guard<std::mutex> lock(impl_->mutex);
impl_->stats.frames_written += static_cast<std::uint64_t>(batch_count);
}
}
}
#endif
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,810 @@
/* A portable stdint.h
****************************************************************************
* BSD License:
****************************************************************************
*
* Copyright (c) 2005-2011 Paul Hsieh
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
*
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
* 3. The name of the author may not be used to endorse or promote products
* derived from this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
* IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
* IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
* INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
* NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
* THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
****************************************************************************
*
* Version 0.1.12
*
* The ANSI C standard committee, for the C99 standard, specified the
* inclusion of a new standard include file called stdint.h. This is
* a very useful and long desired include file which contains several
* very precise definitions for integer scalar types that is
* critically important for making portable several classes of
* applications including cryptography, hashing, variable length
* integer libraries and so on. But for most developers its likely
* useful just for programming sanity.
*
* The problem is that most compiler vendors have decided not to
* implement the C99 standard, and the next C++ language standard
* (which has a lot more mindshare these days) will be a long time in
* coming and its unknown whether or not it will include stdint.h or
* how much adoption it will have. Either way, it will be a long time
* before all compilers come with a stdint.h and it also does nothing
* for the extremely large number of compilers available today which
* do not include this file, or anything comparable to it.
*
* So that's what this file is all about. Its an attempt to build a
* single universal include file that works on as many platforms as
* possible to deliver what stdint.h is supposed to. A few things
* that should be noted about this file:
*
* 1) It is not guaranteed to be portable and/or present an identical
* interface on all platforms. The extreme variability of the
* ANSI C standard makes this an impossibility right from the
* very get go. Its really only meant to be useful for the vast
* majority of platforms that possess the capability of
* implementing usefully and precisely defined, standard sized
* integer scalars. Systems which are not intrinsically 2s
* complement may produce invalid constants.
*
* 2) There is an unavoidable use of non-reserved symbols.
*
* 3) Other standard include files are invoked.
*
* 4) This file may come in conflict with future platforms that do
* include stdint.h. The hope is that one or the other can be
* used with no real difference.
*
* 5) In the current verison, if your platform can't represent
* int32_t, int16_t and int8_t, it just dumps out with a compiler
* error.
*
* 6) 64 bit integers may or may not be defined. Test for their
* presence with the test: #ifdef INT64_MAX or #ifdef UINT64_MAX.
* Note that this is different from the C99 specification which
* requires the existence of 64 bit support in the compiler. If
* this is not defined for your platform, yet it is capable of
* dealing with 64 bits then it is because this file has not yet
* been extended to cover all of your system's capabilities.
*
* 7) (u)intptr_t may or may not be defined. Test for its presence
* with the test: #ifdef PTRDIFF_MAX. If this is not defined
* for your platform, then it is because this file has not yet
* been extended to cover all of your system's capabilities, not
* because its optional.
*
* 8) The following might not been defined even if your platform is
* capable of defining it:
*
* WCHAR_MIN
* WCHAR_MAX
* (u)int64_t
* PTRDIFF_MIN
* PTRDIFF_MAX
* (u)intptr_t
*
* 9) The following have not been defined:
*
* WINT_MIN
* WINT_MAX
*
* 10) The criteria for defining (u)int_least(*)_t isn't clear,
* except for systems which don't have a type that precisely
* defined 8, 16, or 32 bit types (which this include file does
* not support anyways). Default definitions have been given.
*
* 11) The criteria for defining (u)int_fast(*)_t isn't something I
* would trust to any particular compiler vendor or the ANSI C
* committee. It is well known that "compatible systems" are
* commonly created that have very different performance
* characteristics from the systems they are compatible with,
* especially those whose vendors make both the compiler and the
* system. Default definitions have been given, but its strongly
* recommended that users never use these definitions for any
* reason (they do *NOT* deliver any serious guarantee of
* improved performance -- not in this file, nor any vendor's
* stdint.h).
*
* 12) The following macros:
*
* PRINTF_INTMAX_MODIFIER
* PRINTF_INT64_MODIFIER
* PRINTF_INT32_MODIFIER
* PRINTF_INT16_MODIFIER
* PRINTF_LEAST64_MODIFIER
* PRINTF_LEAST32_MODIFIER
* PRINTF_LEAST16_MODIFIER
* PRINTF_INTPTR_MODIFIER
*
* are strings which have been defined as the modifiers required
* for the "d", "u" and "x" printf formats to correctly output
* (u)intmax_t, (u)int64_t, (u)int32_t, (u)int16_t, (u)least64_t,
* (u)least32_t, (u)least16_t and (u)intptr_t types respectively.
* PRINTF_INTPTR_MODIFIER is not defined for some systems which
* provide their own stdint.h. PRINTF_INT64_MODIFIER is not
* defined if INT64_MAX is not defined. These are an extension
* beyond what C99 specifies must be in stdint.h.
*
* In addition, the following macros are defined:
*
* PRINTF_INTMAX_HEX_WIDTH
* PRINTF_INT64_HEX_WIDTH
* PRINTF_INT32_HEX_WIDTH
* PRINTF_INT16_HEX_WIDTH
* PRINTF_INT8_HEX_WIDTH
* PRINTF_INTMAX_DEC_WIDTH
* PRINTF_INT64_DEC_WIDTH
* PRINTF_INT32_DEC_WIDTH
* PRINTF_INT16_DEC_WIDTH
* PRINTF_INT8_DEC_WIDTH
*
* Which specifies the maximum number of characters required to
* print the number of that type in either hexadecimal or decimal.
* These are an extension beyond what C99 specifies must be in
* stdint.h.
*
* Compilers tested (all with 0 warnings at their highest respective
* settings): Borland Turbo C 2.0, WATCOM C/C++ 11.0 (16 bits and 32
* bits), Microsoft Visual C++ 6.0 (32 bit), Microsoft Visual Studio
* .net (VC7), Intel C++ 4.0, GNU gcc v3.3.3
*
* This file should be considered a work in progress. Suggestions for
* improvements, especially those which increase coverage are strongly
* encouraged.
*
* Acknowledgements
*
* The following people have made significant contributions to the
* development and testing of this file:
*
* Chris Howie
* John Steele Scott
* Dave Thorup
* John Dill
*
*/
#ifndef LCARD_PSTDINT
#define LCARD_PSTDINT
#include <stddef.h>
#include <limits.h>
/*
* For gcc with _STDINT_H, fill in the PRINTF_INT*_MODIFIER macros, and
* do nothing else. On the Mac OS X version of gcc this is _STDINT_H_.
*/
#if ((defined(__STDC__) && __STDC__ && defined(__STDC_VERSION__) && (__STDC_VERSION__ >= 199901L)) \
|| (defined (__WATCOMC__) && (defined (_STDINT_H_INCLUDED) || (__WATCOMC__ >= 1250))) \
|| (defined(__GNUC__)) \
|| (defined (_MSC_VER) && (_MSC_VER >= 1600)) \
|| (defined (__BORLANDC__) && (__BORLANDC__ >= 0x560))) && !defined (_PSTDINT_H_INCLUDED)
#include <stdint.h>
#define _PSTDINT_H_INCLUDED
# ifndef PRINTF_INT64_MODIFIER
# define PRINTF_INT64_MODIFIER "ll"
# endif
# ifndef PRINTF_INT32_MODIFIER
# define PRINTF_INT32_MODIFIER "l"
# endif
# ifndef PRINTF_INT16_MODIFIER
# define PRINTF_INT16_MODIFIER "h"
# endif
# ifndef PRINTF_INTMAX_MODIFIER
# define PRINTF_INTMAX_MODIFIER PRINTF_INT64_MODIFIER
# endif
# ifndef PRINTF_INT64_HEX_WIDTH
# define PRINTF_INT64_HEX_WIDTH "16"
# endif
# ifndef PRINTF_INT32_HEX_WIDTH
# define PRINTF_INT32_HEX_WIDTH "8"
# endif
# ifndef PRINTF_INT16_HEX_WIDTH
# define PRINTF_INT16_HEX_WIDTH "4"
# endif
# ifndef PRINTF_INT8_HEX_WIDTH
# define PRINTF_INT8_HEX_WIDTH "2"
# endif
# ifndef PRINTF_INT64_DEC_WIDTH
# define PRINTF_INT64_DEC_WIDTH "20"
# endif
# ifndef PRINTF_INT32_DEC_WIDTH
# define PRINTF_INT32_DEC_WIDTH "10"
# endif
# ifndef PRINTF_INT16_DEC_WIDTH
# define PRINTF_INT16_DEC_WIDTH "5"
# endif
# ifndef PRINTF_INT8_DEC_WIDTH
# define PRINTF_INT8_DEC_WIDTH "3"
# endif
# ifndef PRINTF_INTMAX_HEX_WIDTH
# define PRINTF_INTMAX_HEX_WIDTH PRINTF_INT64_HEX_WIDTH
# endif
# ifndef PRINTF_INTMAX_DEC_WIDTH
# define PRINTF_INTMAX_DEC_WIDTH PRINTF_INT64_DEC_WIDTH
# endif
/*
* Something really weird is going on with Open Watcom. Just pull some of
* these duplicated definitions from Open Watcom's stdint.h file for now.
*/
# if defined (__WATCOMC__) && __WATCOMC__ >= 1250
# if !defined (INT64_C)
# define INT64_C(x) (x + (INT64_MAX - INT64_MAX))
# endif
# if !defined (UINT64_C)
# define UINT64_C(x) (x + (UINT64_MAX - UINT64_MAX))
# endif
# if !defined (INT32_C)
# define INT32_C(x) (x + (INT32_MAX - INT32_MAX))
# endif
# if !defined (UINT32_C)
# define UINT32_C(x) (x + (UINT32_MAX - UINT32_MAX))
# endif
# if !defined (INT16_C)
# define INT16_C(x) (x)
# endif
# if !defined (UINT16_C)
# define UINT16_C(x) (x)
# endif
# if !defined (INT8_C)
# define INT8_C(x) (x)
# endif
# if !defined (UINT8_C)
# define UINT8_C(x) (x)
# endif
# if !defined (UINT64_MAX)
# define UINT64_MAX 18446744073709551615ULL
# endif
# if !defined (INT64_MAX)
# define INT64_MAX 9223372036854775807LL
# endif
# if !defined (UINT32_MAX)
# define UINT32_MAX 4294967295UL
# endif
# if !defined (INT32_MAX)
# define INT32_MAX 2147483647L
# endif
# if !defined (INTMAX_MAX)
# define INTMAX_MAX INT64_MAX
# endif
# if !defined (INTMAX_MIN)
# define INTMAX_MIN INT64_MIN
# endif
# endif
#endif
#ifndef _PSTDINT_H_INCLUDED
#define _PSTDINT_H_INCLUDED
#ifndef SIZE_MAX
# define SIZE_MAX (~(size_t)0)
#endif
/*
* Deduce the type assignments from limits.h under the assumption that
* integer sizes in bits are powers of 2, and follow the ANSI
* definitions.
*/
#ifndef UINT8_MAX
# define UINT8_MAX 0xff
#endif
#ifndef uint8_t
# if (UCHAR_MAX == UINT8_MAX) || defined (S_SPLINT_S)
typedef unsigned char uint8_t;
# define UINT8_C(v) ((uint8_t) v)
# else
# error "Platform not supported"
# endif
#endif
#ifndef INT8_MAX
# define INT8_MAX 0x7f
#endif
#ifndef INT8_MIN
# define INT8_MIN INT8_C(0x80)
#endif
#ifndef int8_t
# if (SCHAR_MAX == INT8_MAX) || defined (S_SPLINT_S)
typedef signed char int8_t;
# define INT8_C(v) ((int8_t) v)
# else
# error "Platform not supported"
# endif
#endif
#ifndef UINT16_MAX
# define UINT16_MAX 0xffff
#endif
#ifndef uint16_t
#if (UINT_MAX == UINT16_MAX) || defined (S_SPLINT_S)
typedef unsigned int uint16_t;
# ifndef PRINTF_INT16_MODIFIER
# define PRINTF_INT16_MODIFIER ""
# endif
# define UINT16_C(v) ((uint16_t) (v))
#elif (USHRT_MAX == UINT16_MAX)
typedef unsigned short uint16_t;
# define UINT16_C(v) ((uint16_t) (v))
# ifndef PRINTF_INT16_MODIFIER
# define PRINTF_INT16_MODIFIER "h"
# endif
#else
#error "Platform not supported"
#endif
#endif
#ifndef INT16_MAX
# define INT16_MAX 0x7fff
#endif
#ifndef INT16_MIN
# define INT16_MIN INT16_C(0x8000)
#endif
#ifndef int16_t
#if (INT_MAX == INT16_MAX) || defined (S_SPLINT_S)
typedef signed int int16_t;
# define INT16_C(v) ((int16_t) (v))
# ifndef PRINTF_INT16_MODIFIER
# define PRINTF_INT16_MODIFIER ""
# endif
#elif (SHRT_MAX == INT16_MAX)
typedef signed short int16_t;
# define INT16_C(v) ((int16_t) (v))
# ifndef PRINTF_INT16_MODIFIER
# define PRINTF_INT16_MODIFIER "h"
# endif
#else
#error "Platform not supported"
#endif
#endif
#ifndef UINT32_MAX
# define UINT32_MAX (0xffffffffUL)
#endif
#ifndef uint32_t
#if (ULONG_MAX == UINT32_MAX) || defined (S_SPLINT_S)
typedef unsigned long uint32_t;
# define UINT32_C(v) v ## UL
# ifndef PRINTF_INT32_MODIFIER
# define PRINTF_INT32_MODIFIER "l"
# endif
#elif (UINT_MAX == UINT32_MAX)
typedef unsigned int uint32_t;
# ifndef PRINTF_INT32_MODIFIER
# define PRINTF_INT32_MODIFIER ""
# endif
# define UINT32_C(v) v ## U
#elif (USHRT_MAX == UINT32_MAX)
typedef unsigned short uint32_t;
# define UINT32_C(v) ((unsigned short) (v))
# ifndef PRINTF_INT32_MODIFIER
# define PRINTF_INT32_MODIFIER ""
# endif
#else
#error "Platform not supported"
#endif
#endif
#ifndef INT32_MAX
# define INT32_MAX (0x7fffffffL)
#endif
#ifndef INT32_MIN
# define INT32_MIN INT32_C(0x80000000)
#endif
#ifndef int32_t
#if (LONG_MAX == INT32_MAX) || defined (S_SPLINT_S)
typedef signed long int32_t;
# define INT32_C(v) v ## L
# ifndef PRINTF_INT32_MODIFIER
# define PRINTF_INT32_MODIFIER "l"
# endif
#elif (INT_MAX == INT32_MAX)
typedef signed int int32_t;
# define INT32_C(v) v
# ifndef PRINTF_INT32_MODIFIER
# define PRINTF_INT32_MODIFIER ""
# endif
#elif (SHRT_MAX == INT32_MAX)
typedef signed short int32_t;
# define INT32_C(v) ((short) (v))
# ifndef PRINTF_INT32_MODIFIER
# define PRINTF_INT32_MODIFIER ""
# endif
#else
#error "Platform not supported"
#endif
#endif
/*
* The macro stdint_int64_defined is temporarily used to record
* whether or not 64 integer support is available. It must be
* defined for any 64 integer extensions for new platforms that are
* added.
*/
#undef stdint_int64_defined
#if (defined(__STDC__) && defined(__STDC_VERSION__)) || defined (S_SPLINT_S)
# if (__STDC__ && __STDC_VERSION__ >= 199901L) || defined (S_SPLINT_S)
# define stdint_int64_defined
typedef long long int64_t;
typedef unsigned long long uint64_t;
# define UINT64_C(v) v ## ULL
# define INT64_C(v) v ## LL
# ifndef PRINTF_INT64_MODIFIER
# define PRINTF_INT64_MODIFIER "ll"
# endif
# endif
#endif
#if !defined (stdint_int64_defined)
# if defined(__GNUC__)
# define stdint_int64_defined
__extension__ typedef long long int64_t;
__extension__ typedef unsigned long long uint64_t;
# define UINT64_C(v) v ## ULL
# define INT64_C(v) v ## LL
# ifndef PRINTF_INT64_MODIFIER
# define PRINTF_INT64_MODIFIER "ll"
# endif
# elif defined(__MWERKS__) || defined (__SUNPRO_C) || defined (__SUNPRO_CC) || defined (__APPLE_CC__) || defined (_LONG_LONG) || defined (_CRAYC) || defined (S_SPLINT_S)
# define stdint_int64_defined
typedef long long int64_t;
typedef unsigned long long uint64_t;
# define UINT64_C(v) v ## ULL
# define INT64_C(v) v ## LL
# ifndef PRINTF_INT64_MODIFIER
# define PRINTF_INT64_MODIFIER "ll"
# endif
# elif (defined(__WATCOMC__) && defined(__WATCOM_INT64__)) || (defined(_MSC_VER) && _INTEGRAL_MAX_BITS >= 64) || (defined (__BORLANDC__) && __BORLANDC__ > 0x460) || defined (__alpha) || defined (__DECC)
# define stdint_int64_defined
typedef __int64 int64_t;
typedef unsigned __int64 uint64_t;
# define UINT64_C(v) v ## UI64
# define INT64_C(v) v ## I64
# ifndef PRINTF_INT64_MODIFIER
# define PRINTF_INT64_MODIFIER "I64"
# endif
# endif
#endif
#if !defined (LONG_LONG_MAX) && defined (INT64_C)
# define LONG_LONG_MAX INT64_C (9223372036854775807)
#endif
#ifndef ULONG_LONG_MAX
# define ULONG_LONG_MAX UINT64_C (18446744073709551615)
#endif
#if !defined (INT64_MAX) && defined (INT64_C)
# define INT64_MAX INT64_C (9223372036854775807)
#endif
#if !defined (INT64_MIN) && defined (INT64_C)
# define INT64_MIN INT64_C (-9223372036854775808)
#endif
#if !defined (UINT64_MAX) && defined (INT64_C)
# define UINT64_MAX UINT64_C (18446744073709551615)
#endif
/*
* Width of hexadecimal for number field.
*/
#ifndef PRINTF_INT64_HEX_WIDTH
# define PRINTF_INT64_HEX_WIDTH "16"
#endif
#ifndef PRINTF_INT32_HEX_WIDTH
# define PRINTF_INT32_HEX_WIDTH "8"
#endif
#ifndef PRINTF_INT16_HEX_WIDTH
# define PRINTF_INT16_HEX_WIDTH "4"
#endif
#ifndef PRINTF_INT8_HEX_WIDTH
# define PRINTF_INT8_HEX_WIDTH "2"
#endif
#ifndef PRINTF_INT64_DEC_WIDTH
# define PRINTF_INT64_DEC_WIDTH "20"
#endif
#ifndef PRINTF_INT32_DEC_WIDTH
# define PRINTF_INT32_DEC_WIDTH "10"
#endif
#ifndef PRINTF_INT16_DEC_WIDTH
# define PRINTF_INT16_DEC_WIDTH "5"
#endif
#ifndef PRINTF_INT8_DEC_WIDTH
# define PRINTF_INT8_DEC_WIDTH "3"
#endif
/*
* Ok, lets not worry about 128 bit integers for now. Moore's law says
* we don't need to worry about that until about 2040 at which point
* we'll have bigger things to worry about.
*/
#ifdef stdint_int64_defined
typedef int64_t intmax_t;
typedef uint64_t uintmax_t;
# define INTMAX_MAX INT64_MAX
# define INTMAX_MIN INT64_MIN
# define UINTMAX_MAX UINT64_MAX
# define UINTMAX_C(v) UINT64_C(v)
# define INTMAX_C(v) INT64_C(v)
# ifndef PRINTF_INTMAX_MODIFIER
# define PRINTF_INTMAX_MODIFIER PRINTF_INT64_MODIFIER
# endif
# ifndef PRINTF_INTMAX_HEX_WIDTH
# define PRINTF_INTMAX_HEX_WIDTH PRINTF_INT64_HEX_WIDTH
# endif
# ifndef PRINTF_INTMAX_DEC_WIDTH
# define PRINTF_INTMAX_DEC_WIDTH PRINTF_INT64_DEC_WIDTH
# endif
#else
typedef int32_t intmax_t;
typedef uint32_t uintmax_t;
# define INTMAX_MAX INT32_MAX
# define UINTMAX_MAX UINT32_MAX
# define UINTMAX_C(v) UINT32_C(v)
# define INTMAX_C(v) INT32_C(v)
# ifndef PRINTF_INTMAX_MODIFIER
# define PRINTF_INTMAX_MODIFIER PRINTF_INT32_MODIFIER
# endif
# ifndef PRINTF_INTMAX_HEX_WIDTH
# define PRINTF_INTMAX_HEX_WIDTH PRINTF_INT32_HEX_WIDTH
# endif
# ifndef PRINTF_INTMAX_DEC_WIDTH
# define PRINTF_INTMAX_DEC_WIDTH PRINTF_INT32_DEC_WIDTH
# endif
#endif
/*
* Because this file currently only supports platforms which have
* precise powers of 2 as bit sizes for the default integers, the
* least definitions are all trivial. Its possible that a future
* version of this file could have different definitions.
*/
#ifndef stdint_least_defined
typedef int8_t int_least8_t;
typedef uint8_t uint_least8_t;
typedef int16_t int_least16_t;
typedef uint16_t uint_least16_t;
typedef int32_t int_least32_t;
typedef uint32_t uint_least32_t;
# define PRINTF_LEAST32_MODIFIER PRINTF_INT32_MODIFIER
# define PRINTF_LEAST16_MODIFIER PRINTF_INT16_MODIFIER
# define UINT_LEAST8_MAX UINT8_MAX
# define INT_LEAST8_MAX INT8_MAX
# define UINT_LEAST16_MAX UINT16_MAX
# define INT_LEAST16_MAX INT16_MAX
# define UINT_LEAST32_MAX UINT32_MAX
# define INT_LEAST32_MAX INT32_MAX
# define INT_LEAST8_MIN INT8_MIN
# define INT_LEAST16_MIN INT16_MIN
# define INT_LEAST32_MIN INT32_MIN
# ifdef stdint_int64_defined
typedef int64_t int_least64_t;
typedef uint64_t uint_least64_t;
# define PRINTF_LEAST64_MODIFIER PRINTF_INT64_MODIFIER
# define UINT_LEAST64_MAX UINT64_MAX
# define INT_LEAST64_MAX INT64_MAX
# define INT_LEAST64_MIN INT64_MIN
# endif
#endif
#undef stdint_least_defined
/*
* The ANSI C committee pretending to know or specify anything about
* performance is the epitome of misguided arrogance. The mandate of
* this file is to *ONLY* ever support that absolute minimum
* definition of the fast integer types, for compatibility purposes.
* No extensions, and no attempt to suggest what may or may not be a
* faster integer type will ever be made in this file. Developers are
* warned to stay away from these types when using this or any other
* stdint.h.
*/
typedef int_least8_t int_fast8_t;
typedef uint_least8_t uint_fast8_t;
typedef int_least16_t int_fast16_t;
typedef uint_least16_t uint_fast16_t;
typedef int_least32_t int_fast32_t;
typedef uint_least32_t uint_fast32_t;
#define UINT_FAST8_MAX UINT_LEAST8_MAX
#define INT_FAST8_MAX INT_LEAST8_MAX
#define UINT_FAST16_MAX UINT_LEAST16_MAX
#define INT_FAST16_MAX INT_LEAST16_MAX
#define UINT_FAST32_MAX UINT_LEAST32_MAX
#define INT_FAST32_MAX INT_LEAST32_MAX
#define INT_FAST8_MIN INT_LEAST8_MIN
#define INT_FAST16_MIN INT_LEAST16_MIN
#define INT_FAST32_MIN INT_LEAST32_MIN
#ifdef stdint_int64_defined
typedef int_least64_t int_fast64_t;
typedef uint_least64_t uint_fast64_t;
# define UINT_FAST64_MAX UINT_LEAST64_MAX
# define INT_FAST64_MAX INT_LEAST64_MAX
# define INT_FAST64_MIN INT_LEAST64_MIN
#endif
#undef stdint_int64_defined
/*
* Whatever piecemeal, per compiler thing we can do about the wchar_t
* type limits.
*/
#if defined(__WATCOMC__) || defined(_MSC_VER) || defined (__GNUC__)
# include <wchar.h>
# ifndef WCHAR_MIN
# define WCHAR_MIN 0
# endif
# ifndef WCHAR_MAX
# define WCHAR_MAX ((wchar_t)-1)
# endif
#endif
/*
* Whatever piecemeal, per compiler/platform thing we can do about the
* (u)intptr_t types and limits.
*/
#if defined (_MSC_VER) && defined (_UINTPTR_T_DEFINED)
# define STDINT_H_UINTPTR_T_DEFINED
#elif defined (_CVI_)
# define STDINT_H_UINTPTR_T_DEFINED
#endif
#ifndef STDINT_H_UINTPTR_T_DEFINED
# if defined (__alpha__) || defined (__ia64__) || defined (__x86_64__) || defined (_WIN64)
# define stdint_intptr_bits 64
# elif defined (__WATCOMC__) || defined (__TURBOC__)
# if defined(__TINY__) || defined(__SMALL__) || defined(__MEDIUM__)
# define stdint_intptr_bits 16
# else
# define stdint_intptr_bits 32
# endif
# elif defined (__i386__) || defined (_WIN32) || defined (WIN32)
# define stdint_intptr_bits 32
# elif defined (__INTEL_COMPILER)
/* TODO -- what did Intel do about x86-64? */
# endif
# ifdef stdint_intptr_bits
# define stdint_intptr_glue3_i(a,b,c) a##b##c
# define stdint_intptr_glue3(a,b,c) stdint_intptr_glue3_i(a,b,c)
# ifndef PRINTF_INTPTR_MODIFIER
# define PRINTF_INTPTR_MODIFIER stdint_intptr_glue3(PRINTF_INT,stdint_intptr_bits,_MODIFIER)
# endif
# ifndef PTRDIFF_MAX
# define PTRDIFF_MAX stdint_intptr_glue3(INT,stdint_intptr_bits,_MAX)
# endif
# ifndef PTRDIFF_MIN
# define PTRDIFF_MIN stdint_intptr_glue3(INT,stdint_intptr_bits,_MIN)
# endif
# ifndef UINTPTR_MAX
# define UINTPTR_MAX stdint_intptr_glue3(UINT,stdint_intptr_bits,_MAX)
# endif
# ifndef INTPTR_MAX
# define INTPTR_MAX stdint_intptr_glue3(INT,stdint_intptr_bits,_MAX)
# endif
# ifndef INTPTR_MIN
# define INTPTR_MIN stdint_intptr_glue3(INT,stdint_intptr_bits,_MIN)
# endif
# ifndef INTPTR_C
# define INTPTR_C(x) stdint_intptr_glue3(INT,stdint_intptr_bits,_C)(x)
# endif
# ifndef UINTPTR_C
# define UINTPTR_C(x) stdint_intptr_glue3(UINT,stdint_intptr_bits,_C)(x)
# endif
typedef stdint_intptr_glue3(uint,stdint_intptr_bits,_t) uintptr_t;
typedef stdint_intptr_glue3( int,stdint_intptr_bits,_t) intptr_t;
# else
/* TODO -- This following is likely wrong for some platforms, and does
nothing for the definition of uintptr_t. */
typedef ptrdiff_t intptr_t;
# endif
# define STDINT_H_UINTPTR_T_DEFINED
#endif
/*
* Assumes sig_atomic_t is signed and we have a 2s complement machine.
*/
#ifndef SIG_ATOMIC_MAX
# define SIG_ATOMIC_MAX ((((sig_atomic_t) 1) << (sizeof (sig_atomic_t)*CHAR_BIT-1)) - 1)
#endif
#endif
#if defined (__TEST_PSTDINT_FOR_CORRECTNESS)
/*
* Please compile with the maximum warning settings to make sure macros are not
* defined more than once.
*/
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#define glue3_aux(x,y,z) x ## y ## z
#define glue3(x,y,z) glue3_aux(x,y,z)
#define DECLU(bits) glue3(uint,bits,_t) glue3(u,bits,=) glue3(UINT,bits,_C) (0);
#define DECLI(bits) glue3(int,bits,_t) glue3(i,bits,=) glue3(INT,bits,_C) (0);
#define DECL(us,bits) glue3(DECL,us,) (bits)
#define TESTUMAX(bits) glue3(u,bits,=) glue3(~,u,bits); if (glue3(UINT,bits,_MAX) glue3(!=,u,bits)) printf ("Something wrong with UINT%d_MAX\n", bits)
int main () {
DECL(I,8)
DECL(U,8)
DECL(I,16)
DECL(U,16)
DECL(I,32)
DECL(U,32)
#ifdef INT64_MAX
DECL(I,64)
DECL(U,64)
#endif
intmax_t imax = INTMAX_C(0);
uintmax_t umax = UINTMAX_C(0);
char str0[256], str1[256];
sprintf (str0, "%d %x\n", 0, ~0);
sprintf (str1, "%d %x\n", i8, ~0);
if (0 != strcmp (str0, str1)) printf ("Something wrong with i8 : %s\n", str1);
sprintf (str1, "%u %x\n", u8, ~0);
if (0 != strcmp (str0, str1)) printf ("Something wrong with u8 : %s\n", str1);
sprintf (str1, "%d %x\n", i16, ~0);
if (0 != strcmp (str0, str1)) printf ("Something wrong with i16 : %s\n", str1);
sprintf (str1, "%u %x\n", u16, ~0);
if (0 != strcmp (str0, str1)) printf ("Something wrong with u16 : %s\n", str1);
sprintf (str1, "%" PRINTF_INT32_MODIFIER "d %x\n", i32, ~0);
if (0 != strcmp (str0, str1)) printf ("Something wrong with i32 : %s\n", str1);
sprintf (str1, "%" PRINTF_INT32_MODIFIER "u %x\n", u32, ~0);
if (0 != strcmp (str0, str1)) printf ("Something wrong with u32 : %s\n", str1);
#ifdef INT64_MAX
sprintf (str1, "%" PRINTF_INT64_MODIFIER "d %x\n", i64, ~0);
if (0 != strcmp (str0, str1)) printf ("Something wrong with i64 : %s\n", str1);
#endif
sprintf (str1, "%" PRINTF_INTMAX_MODIFIER "d %x\n", imax, ~0);
if (0 != strcmp (str0, str1)) printf ("Something wrong with imax : %s\n", str1);
sprintf (str1, "%" PRINTF_INTMAX_MODIFIER "u %x\n", umax, ~0);
if (0 != strcmp (str0, str1)) printf ("Something wrong with umax : %s\n", str1);
TESTUMAX(8);
TESTUMAX(16);
TESTUMAX(32);
#ifdef INT64_MAX
TESTUMAX(64);
#endif
return EXIT_SUCCESS;
}
#endif
#endif
File diff suppressed because it is too large Load Diff
@@ -419,6 +419,7 @@ class AppWindowConfigProfileIOMixin:
self._set_combo_current_text(self._processing_mode, gui_state.processing.selected_mode)
self._show_magnitude_checkbox.setChecked(bool(gui_state.processing.pass_through.show_magnitude))
self._show_phase_checkbox.setChecked(bool(gui_state.processing.pass_through.show_phase))
self._unwrap_phase_checkbox.setChecked(bool(gui_state.processing.pass_through.unwrap_phase))
self._pass_through_combo_filter_input.setText(str(gui_state.processing.pass_through.combo_filter))
self._pass_through_fixed_y_enabled.setChecked(bool(gui_state.processing.pass_through.fixed_y_enabled))
self._pass_through_y_min_db.setValue(float(gui_state.processing.pass_through.y_min_db))
@@ -203,6 +203,7 @@ class AppWindowConfigStateBuildersMixin:
pass_through=GuiPassThroughStateModel(
show_magnitude=True,
show_phase=True,
unwrap_phase=False,
combo_filter="",
fixed_y_enabled=False,
y_min_db=-100.0,
@@ -323,6 +324,7 @@ class AppWindowConfigStateBuildersMixin:
pass_through=GuiPassThroughStateModel(
show_magnitude=bool(self._show_magnitude_checkbox.isChecked()),
show_phase=bool(self._show_phase_checkbox.isChecked()),
unwrap_phase=bool(self._unwrap_phase_checkbox.isChecked()),
combo_filter=self._pass_through_combo_filter_input.text().strip(),
fixed_y_enabled=bool(self._pass_through_fixed_y_enabled.isChecked()),
y_min_db=float(self._pass_through_y_min_db.value()),
@@ -12,7 +12,7 @@ from python_app.gui.runtime.history import (
build_run_history_signature,
record_result_history,
)
from python_app.hardware_full.kamil_adc_service import apply_kamil_adc_laser_control
from python_app.hardware_full.kamil_adc import apply_kamil_adc_laser_control
from python_app.hardware_full.single_radar_service import create_single_radar_service
from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection
from python_app.models.run_config_model import RunConfigModel
@@ -21,6 +21,10 @@ class AppWindowTracePlotMixin:
"""Return whether phase curves should be rendered."""
return self._show_phase_checkbox.isChecked()
def _unwrap_phase_enabled(self) -> bool:
"""Return whether the phase trace should be unwrapped (cumulative)."""
return self._unwrap_phase_checkbox.isChecked()
def _pass_through_fixed_y_range(self) -> tuple[bool, float, float]:
"""Return normalized magnitude Y-range override for pass-through mode."""
y_min = float(self._pass_through_y_min_db.value())
@@ -48,13 +52,29 @@ class AppWindowTracePlotMixin:
return None
def _configure_pass_through_magnitude_axis(self, plot: pg.PlotWidget) -> None:
"""Apply pass-through magnitude-axis autorange or fixed Y window."""
"""Apply the magnitude Y-axis: a fixed window or autorange.
The X axis is set explicitly from the data band by the caller, so this
never re-enables X autorange (which would scan all curves every frame).
"""
fixed_y_enabled, y_min, y_max = self._pass_through_fixed_y_range()
view_box = plot.getViewBox()
view_box.invertY(False)
view_box.enableAutoRange(x=True, y=not fixed_y_enabled)
if fixed_y_enabled:
view_box.enableAutoRange(y=False)
plot.setYRange(y_min, y_max, padding=0.0)
else:
view_box.enableAutoRange(y=True)
def _configure_pass_through_phase_axis(self, plot: pg.PlotWidget) -> None:
"""Apply the phase Y-axis: fixed ±180° when wrapped, autorange when unwrapped."""
view_box = plot.getViewBox()
view_box.invertY(False)
if self._unwrap_phase_enabled():
view_box.enableAutoRange(y=True)
else:
view_box.enableAutoRange(y=False)
plot.setYRange(-180.0, 180.0, padding=0.02)
def _on_trace_visibility_changed(self, *_args) -> None:
"""Redraw pass-through traces when magnitude/phase toggles changed."""
@@ -116,7 +136,6 @@ class AppWindowTracePlotMixin:
if show_magnitude:
mag_item = magnitude_plot.getPlotItem()
self._configure_pass_through_magnitude_axis(magnitude_plot)
mag_item.showAxis("left", show=True)
mag_item.showAxis("bottom", show=not show_phase)
magnitude_plot.setLabel("left", "Magnitude", units="dB")
@@ -126,8 +145,6 @@ class AppWindowTracePlotMixin:
if show_phase:
phase_item = phase_plot.getPlotItem()
phase_plot.getViewBox().invertY(False)
phase_plot.getViewBox().enableAutoRange(x=True, y=False)
phase_item.showAxis("left", show=True)
phase_item.showAxis("bottom", show=True)
phase_plot.setLabel("left", "Phase", units="deg")
@@ -179,16 +196,18 @@ class AppWindowTracePlotMixin:
x_max = max(x_max, local_x_max)
if show_magnitude:
magnitude_values = 20.0 * np.log10(np.maximum(np.abs(payload.trace), 1e-12))
mag_x, magnitude_values = self._magnitude_display_arrays(
payload.frequency_hz, payload.trace
)
active_magnitude_keys.add(curve_key)
magnitude_curve = self._trace_magnitude_curves.get(curve_key)
if magnitude_curve is None:
magnitude_curve = pg.PlotCurveItem(pen=pg.mkPen(color, width=1.4))
magnitude_curve = pg.PlotCurveItem(pen=pg.mkPen(color, width=1.4), antialias=False)
self._trace_magnitude_curves[curve_key] = magnitude_curve
magnitude_plot.addItem(magnitude_curve)
else:
magnitude_curve.setPen(pg.mkPen(color, width=1.4))
magnitude_curve.setData(payload.frequency_hz, magnitude_values)
magnitude_curve.setData(mag_x, magnitude_values)
legend_source_magnitude.setdefault(combo_key, magnitude_curve)
has_data = True
@@ -196,7 +215,7 @@ class AppWindowTracePlotMixin:
active_phase_keys.add(curve_key)
phase_curve = self._trace_phase_curves.get(curve_key)
if phase_curve is None:
phase_curve = pg.PlotCurveItem(pen=pg.mkPen(color, width=1.2))
phase_curve = pg.PlotCurveItem(pen=pg.mkPen(color, width=1.2), antialias=False)
self._trace_phase_curves[curve_key] = phase_curve
phase_plot.addItem(phase_curve)
else:
@@ -231,16 +250,17 @@ class AppWindowTracePlotMixin:
phase_sources=legend_source_phase,
)
if has_data:
if np.isfinite(x_min) and np.isfinite(x_max):
if show_magnitude:
magnitude_plot.setXRange(x_min, x_max, padding=0.02)
if show_phase:
phase_plot.setXRange(x_min, x_max, padding=0.02)
if show_phase:
phase_plot.setYRange(-180.0, 180.0, padding=0.02)
# X is the (constant) frequency band: set it explicitly rather than
# autoranging every frame. Y is configured once per draw.
if has_data and np.isfinite(x_min) and np.isfinite(x_max):
if show_magnitude:
self._configure_pass_through_magnitude_axis(magnitude_plot)
magnitude_plot.setXRange(x_min, x_max, padding=0.02)
if show_phase:
phase_plot.setXRange(x_min, x_max, padding=0.02)
if show_magnitude:
self._configure_pass_through_magnitude_axis(magnitude_plot)
if show_phase:
self._configure_pass_through_phase_axis(phase_plot)
return has_data
@staticmethod
@@ -268,15 +288,40 @@ class AppWindowTracePlotMixin:
plot.removeItem(curve)
cache.clear()
def _phase_display_arrays(self, frequency_hz: np.ndarray, trace: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Return phase display arrays with decimation for faster rendering."""
max_points = int(getattr(self, "_trace_phase_render_max_points", 1200))
if max_points > 0 and trace.size > max_points:
step = max(1, int(np.ceil(trace.size / max_points)))
frequency_hz = frequency_hz[::step]
trace = trace[::step]
phase_values = np.arctan2(trace.imag, trace.real) * (180.0 / np.pi)
return frequency_hz, phase_values
def _render_decimation_step(self, size: int) -> int:
"""Stride that caps a rendered trace at the configured maximum point count.
Beyond ~1 point per screen pixel there is no visual gain, so decimating
keeps the line plots responsive even with many combos on screen.
"""
max_points = int(getattr(self, "_trace_render_max_points", 800))
if max_points > 0 and size > max_points:
return max(1, int(np.ceil(size / max_points)))
return 1
def _magnitude_display_arrays(
self, frequency_hz: np.ndarray, trace: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
"""Return decimated (frequency, magnitude-dB) arrays for fast rendering."""
step = self._render_decimation_step(int(trace.size))
frequency_hz = frequency_hz[::step]
magnitude_db = 20.0 * np.log10(np.maximum(np.abs(trace[::step]), 1e-12))
return frequency_hz, magnitude_db
def _phase_display_arrays(
self, frequency_hz: np.ndarray, trace: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
"""Return decimated (frequency, phase-deg) arrays, unwrapped when enabled.
Unwrapping runs at full resolution (before decimation) so the cumulative
phase is correct, then both arrays are decimated together for rendering.
"""
phase = np.angle(trace)
if self._unwrap_phase_enabled():
phase = np.unwrap(phase)
phase_deg = np.degrees(phase)
step = self._render_decimation_step(int(trace.size))
return frequency_hz[::step], phase_deg[::step]
def _sync_trace_legends(
self,
@@ -363,26 +408,24 @@ class AppWindowTracePlotMixin:
return
if show_magnitude:
self._configure_pass_through_magnitude_axis(magnitude_plot)
magnitude_plot.getPlotItem().showAxis("bottom", show=not show_phase)
magnitude_plot.setLabel("left", "Magnitude", units="dB")
magnitude_plot.setTitle(title)
if not show_phase:
magnitude_plot.setLabel("bottom", "Frequency", units="Hz")
if show_phase:
phase_plot.getViewBox().invertY(False)
phase_plot.getViewBox().enableAutoRange(x=True, y=False)
phase_plot.getPlotItem().showAxis("bottom", show=True)
phase_plot.setLabel("left", "Phase", units="deg")
phase_plot.setLabel("bottom", "Frequency", units="Hz")
phase_plot.setTitle(title)
if show_magnitude:
magnitude_db = 20.0 * np.log10(np.maximum(np.abs(samples), 1e-12))
mag_x, magnitude_db = self._magnitude_display_arrays(trace.frequency_hz, samples)
magnitude_curve = pg.PlotCurveItem(
trace.frequency_hz,
mag_x,
magnitude_db,
pen=pg.mkPen("#ffd166", width=1.8),
antialias=False,
)
magnitude_plot.addItem(magnitude_curve)
self._trace_magnitude_curves[
@@ -390,23 +433,26 @@ class AppWindowTracePlotMixin:
] = magnitude_curve
if show_phase:
phase_values = np.degrees(np.angle(samples))
phase_x, phase_values = self._phase_display_arrays(trace.frequency_hz, samples)
phase_curve = pg.PlotCurveItem(
trace.frequency_hz,
phase_x,
phase_values,
pen=pg.mkPen("#80ed99", width=1.4, style=Qt.PenStyle.DashLine),
antialias=False,
)
phase_plot.addItem(phase_curve)
self._trace_phase_curves[
(int(trace.combo.input), int(trace.combo.output), 0, "__single_trace__")
] = phase_curve
phase_plot.setYRange(-180.0, 180.0, padding=0.02)
if np.size(trace.frequency_hz) > 1:
x_min = float(np.min(trace.frequency_hz))
x_max = float(np.max(trace.frequency_hz))
if show_magnitude:
magnitude_plot.setXRange(x_min, x_max, padding=0.02)
self._configure_pass_through_magnitude_axis(magnitude_plot)
if show_phase:
phase_plot.setXRange(x_min, x_max, padding=0.02)
if show_magnitude:
self._configure_pass_through_magnitude_axis(magnitude_plot)
if show_phase:
self._configure_pass_through_phase_axis(phase_plot)
@@ -3,7 +3,6 @@
from __future__ import annotations
from python_app.gui.preprocess_dialog import PreprocessDialog
from python_app.hardware_full.kamil_adc_service import KamilAdcService
from python_app.orchestration.preprocess_assets import (
PREPROCESS_ASSET_SPECS,
VISIBLE_PREPROCESS_ASSET_KEYS,
@@ -429,8 +428,8 @@ class AppWindowPreprocessMixin:
self._stop_run()
pipeline_was_paused = True
point_count = self._read_kamil_adc_point_count(config)
calibration, reference = build_kamil_adc_neutral_s21_sets(config, point_count)
calibration, reference = build_kamil_adc_neutral_s21_sets(config)
point_count = config.radar.kamil_adc.band.points
self._store.save_set("s21_calibration", radar_key, set_name, calibration)
self._store.save_set("s21_reference", radar_key, set_name, reference)
@@ -454,25 +453,6 @@ class AppWindowPreprocessMixin:
if pipeline_was_paused:
self._start_run()
def _read_kamil_adc_point_count(self, config) -> int:
"""Read one Kamil ADC sweep and return its actual point count."""
dialog = self._ensure_preprocess_dialog()
dialog.set_status("Reading one Kamil ADC sweep to detect point count...")
self._log("Reading one Kamil ADC sweep to detect neutral-set point count")
radar = KamilAdcService(config)
try:
radar.open()
radar.configure(config.radar.sweep)
sweep = radar.acquire()
finally:
radar.close()
point_count = int(sweep.x.size)
if point_count <= 0:
raise RuntimeError("Kamil ADC returned an empty sweep while detecting point count")
return point_count
def _build_single_radar_capture_session(
self,
*,
@@ -125,7 +125,10 @@ class AppWindowUiMixin:
self._trace_phase_legend_combo_keys = set()
self._trace_magnitude_curves = {}
self._trace_phase_curves = {}
self._trace_phase_render_max_points = 400
# Cap rendered points per trace (magnitude and phase alike): beyond ~1
# point/pixel there is no visual gain, and decimation keeps pass-through
# responsive with many combos on screen.
self._trace_render_max_points = 800
self._plot_stack.addWidget(self._trace_plots_container)
@@ -80,6 +80,13 @@ def build_processing_group(owner) -> QGroupBox:
owner._show_phase_checkbox = QCheckBox("Show phase")
owner._show_phase_checkbox.setChecked(bool(pass_defaults.show_phase))
owner._unwrap_phase_checkbox = QCheckBox("Unwrap phase")
owner._unwrap_phase_checkbox.setToolTip(
"Plot the cumulative (unwrapped) phase instead of wrapping to ±180°; "
"the phase axis auto-scales to the unwrapped range."
)
owner._unwrap_phase_checkbox.setChecked(bool(pass_defaults.unwrap_phase))
owner._pass_through_combo_filter_input = QLineEdit(str(pass_defaults.combo_filter))
owner._pass_through_combo_filter_input.setPlaceholderText("empty = all, e.g. 0:0,1:0")
@@ -105,12 +112,13 @@ def build_processing_group(owner) -> QGroupBox:
[
owner._show_magnitude_checkbox,
owner._show_phase_checkbox,
owner._unwrap_phase_checkbox,
("Switch combos", owner._pass_through_combo_filter_input),
owner._pass_through_fixed_y_enabled,
("Y min dB", owner._pass_through_y_min_db),
("Y max dB", owner._pass_through_y_max_db),
],
split_index=4,
split_index=5,
)
owner._processing_mode_pages.addWidget(pass_through_page)
@@ -492,6 +500,7 @@ def build_processing_group(owner) -> QGroupBox:
owner._processing_mode.currentTextChanged.connect(owner._on_processing_mode_changed)
owner._show_magnitude_checkbox.toggled.connect(owner._on_trace_visibility_changed)
owner._show_phase_checkbox.toggled.connect(owner._on_trace_visibility_changed)
owner._unwrap_phase_checkbox.toggled.connect(owner._on_trace_visibility_changed)
owner._pass_through_combo_filter_input.editingFinished.connect(owner._on_trace_visibility_changed)
owner._pass_through_fixed_y_enabled.toggled.connect(owner._sync_pass_through_y_controls)
owner._pass_through_fixed_y_enabled.toggled.connect(owner._on_processing_live_settings_changed)
@@ -0,0 +1,36 @@
"""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",
]
+103
View File
@@ -0,0 +1,103 @@
"""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_model 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,
},
)
return True
raise RuntimeError(f"Unsupported laser_control mode: {laser.mode}")
finally:
controller.disconnect()
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")
@@ -0,0 +1,185 @@
"""Signal processing for the Kamil ADC 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,
comparable S21 trace is a fixed three-stage pipeline:
1. **Frequency axis from the reference phase.** The reference arm has a constant
electrical delay, so its unwrapped phase is an affine function of frequency.
Two fixed calibration anchors ``(phase0, freq0)`` and ``(phase1, freq1)``
supplied by config, *never* derived from the live sweep define that law::
f(phase) = freq0 + (phase - phase0) * (freq1 - freq0) / (phase1 - phase0)
Trigger jitter shifts every sample's absolute phase together, so the measured
band floats from sweep to sweep around the fixed calibration.
2. **Amplitude normalization.** ``S = main / |reference|`` divides out the
stimulus amplitude. Only the magnitude is removed; the reference phase is used
solely for the axis above.
3. **Crop + resample onto a fixed grid.** The floating per-sweep axis is resampled
(linear, on real and imaginary parts) onto a single hardcoded grid
``linspace(band_start, band_stop, band_points)``. Every sweep then shares a
byte-identical frequency axis, so traces can be averaged and subtracted. A
sweep whose floated range does not fully span the band is *rejected* rather
than edge-extrapolated.
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
import numpy as np
from python_app.models.run_config_schema import KamilAdcModel
# A reference sample whose magnitude falls to (effectively) zero carries no usable
# amplitude or phase; such points are dropped before normalization rather than
# producing a division blow-up. The threshold only guards genuine zeros — real
# reference frames are emitted from settled measurements and sit far above it.
_REFERENCE_AMPLITUDE_FLOOR = 1e-9
# Linear interpolation onto the band needs at least two distinct source samples;
# a sweep yielding fewer usable points is malformed and rejected.
_MIN_USABLE_POINTS = 2
@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:
"""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")
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
)
@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).
Returns frequencies in *step order* (not sorted); see the module docstring
for the calibration law.
"""
phase = np.unwrap(np.angle(np.asarray(reference)))
return self._params.freq0_hz + (phase - self._params.phase0_rad) * self._params.hz_per_rad
def process(self, main: np.ndarray, reference: np.ndarray) -> np.ndarray | None:
"""Return the S21 trace resampled onto the fixed grid, or ``None`` to reject.
``main`` and ``reference`` are equal-length complex arrays ordered by
ascending step index. ``None`` is returned when the sweep is malformed
(too few usable points) or does not fully cover the configured band.
"""
main = np.asarray(main, dtype=np.complex128)
reference = np.asarray(reference, dtype=np.complex128)
if main.size < _MIN_USABLE_POINTS or main.size != reference.size:
return None
# Frequency axis from the absolute unwrapped reference phase (step order).
freqs = self.reference_frequency_axis(reference)
# Amplitude-only normalization; drop points where the reference vanished.
reference_amplitude = np.abs(reference)
with np.errstate(divide="ignore", invalid="ignore"):
s21 = main / reference_amplitude
usable = (
(reference_amplitude > self._params.reference_amplitude_floor)
& np.isfinite(freqs)
& np.isfinite(s21.real)
& np.isfinite(s21.imag)
)
if int(np.count_nonzero(usable)) < _MIN_USABLE_POINTS:
return None
freqs = freqs[usable]
s21 = s21[usable]
# Sort onto a monotonically increasing axis (handles either sweep
# direction) so interpolation and the coverage check are well defined.
order = np.argsort(freqs, kind="stable")
freqs = freqs[order]
s21 = s21[order]
# 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:
return None
# Linear interpolation on real/imaginary parts. Because the band lies
# within [freqs[0], freqs[-1]], np.interp never extrapolates here.
real = np.interp(self._grid_hz, freqs, s21.real)
imag = np.interp(self._grid_hz, freqs, s21.imag)
return (real + 1j * imag).astype(np.complex64)
@@ -0,0 +1,141 @@
"""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
# Wire-format constants.
FRAME_BYTES = 8
MAIN_MARKER = 0x000A
REFERENCE_MARKER = 0x00A8
_BOUNDARY_STEP = 0xFFFF
# marker (u16), step (u16), ch1 (i16), ch2 (i16) — point frames carry signed I/Q.
_FRAME = struct.Struct("<HHhh")
# The last three words of a boundary frame are all 0xFFFF; a real step is
# 1..0xFFFE, so this tail unambiguously marks a sweep boundary regardless of the
# (profile-dependent) start marker.
_BOUNDARY_TAIL = b"\xff\xff\xff\xff\xff\xff"
# Frame-alignment anchor: the phase-profile sweep-boundary frame.
_START_FRAME = struct.pack("<HHHH", MAIN_MARKER, 0xFFFF, 0xFFFF, 0xFFFF)
@dataclass(frozen=True, slots=True)
class RawSweep:
"""One sweep's main and reference samples, aligned and ordered by step.
``steps`` is strictly ascending; ``main`` and ``reference`` are the complex
samples at those steps. All three arrays share the same length.
"""
steps: np.ndarray
main: np.ndarray
reference: np.ndarray
@property
def size(self) -> int:
return int(self.steps.size)
class KamilAdcStreamParser:
"""Incremental, frame-aligning parser turning the TTY byte stream into sweeps.
Usage: call :meth:`feed` with each chunk of bytes; it returns the list of
sweeps completed by that chunk (usually zero or one). The parser is stateful
but holds no I/O and is cheap to unit-test.
"""
__slots__ = ("_buffer", "_aligned", "_main", "_reference")
def __init__(self) -> None:
self._buffer = bytearray()
self._aligned = False
self._main: dict[int, complex] = {}
self._reference: dict[int, complex] = {}
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)
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()
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._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
self._main = {}
self._reference = {}
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),
)
@@ -0,0 +1,358 @@
"""Service that launches the Kamil ADC 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`.
"""
from __future__ import annotations
from contextlib import suppress
from dataclasses import dataclass, field
import logging
import os
from pathlib import Path
import signal
import stat
import subprocess
import threading
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,
raise_if_process_exited,
)
from python_app.hardware_full.librevna_driver.models import SweepResult
from python_app.models.run_config_model import RadarSweepModel, RunConfigModel
logger = logging.getLogger(__name__)
# Project root, used to resolve relative collector paths (e.g.
# ``build/bin/kamil_adc_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.
_DEFAULT_LCARD_LIB_DIR = "~/.local/lib"
# Rejected-sweep logging is throttled so a persistently mis-set band/calibration
# does not flood the log: log the first rejection, then every Nth.
_REJECT_LOG_EVERY = 50
# The collector's graceful X502 teardown can block, so a stop gives it only this
# 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
@dataclass(slots=True)
class KamilAdcService:
"""Launch the external Kamil ADC collector and serve its processed sweeps."""
config: RunConfigModel
_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)
_rejected_count: int = field(init=False, default=0, repr=False)
def __post_init__(self) -> None:
self._validate_config()
@property
def command(self) -> list[str]:
"""External collector command, including the generated ``tty:`` argument."""
adc = self.config.radar.kamil_adc
return [str(self._resolve_executable()), *adc.args, f"tty:{adc.tty_path}"]
def open(self, *, stop_event: threading.Event | None = None) -> None:
"""Launch the collector and start the TTY reader thread.
An optional ``stop_event`` lets a caller abort the TTY-wait loop promptly
(e.g. on shutdown) instead of blocking for the full startup timeout.
"""
if self._reader is not None:
return
previous_tty_identity = _prepare_tty_path_for_collector(self.config.radar.kamil_adc.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.open()
self._reader = reader
logger.info("Kamil ADC service opened")
except Exception:
logger.exception("Kamil ADC 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")
if self._reader is not None:
with suppress(Exception):
self._reader.close()
self._reader = None
self._stop_process()
def configure(self, sweep: RadarSweepModel) -> None:
"""Build the sweep processor from the ``radar.kamil_adc`` 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
from the reference-phase calibration, and the output grid from
``radar.kamil_adc.band`` never from the nominal sweep bounds.
"""
self._processor = KamilAdcSweepProcessor(
KamilAdcProcessingParams.from_kamil_model(self.config.radar.kamil_adc)
)
self._rejected_count = 0
params = self._processor.params
logger.debug(
"Kamil ADC 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,
)
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")
def acquire(self) -> SweepResult:
"""Return the next sweep that covers the band, as S21 on the fixed grid.
Sweeps whose floated frequency range does not span the configured band are
rejected and the next sweep is read, until one passes or the sweep timeout
elapses (which then surfaces as a :class:`TimeoutError`).
"""
if self._processor is None:
raise RuntimeError("Kamil ADC service is not configured")
if self._reader is None:
raise RuntimeError("Kamil ADC 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})")
grid = self._processor.grid_hz
points = int(grid.size)
deadline = time.monotonic() + self.config.radar.kamil_adc.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"
)
raw = self._reader.read_sweep(timeout_s=remaining_s, process=process)
s21 = self._processor.process(raw.main, raw.reference)
if s21 is not None:
return SweepResult(
x=grid.copy(),
traces={
"s11": np.zeros(points, dtype=np.complex64),
"s21": s21,
},
)
self._log_rejected_sweep(raw)
def read_raw_sweep(self) -> RawSweep:
"""Return the next raw (main, reference) sweep without any processing.
Bypasses the frequency mapping, normalization, crop and resample of
:meth:`acquire` intended for calibration tooling that needs the
unprocessed reference samples. Raises if the service is not open.
"""
if self._reader is None:
raise RuntimeError("Kamil ADC service is not open")
return self._reader.read_sweep(
timeout_s=self.config.radar.kamil_adc.sweep_timeout_s,
process=self._process,
)
# ------------------------------------------------------------------
# Process / TTY lifecycle
# ------------------------------------------------------------------
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))
self._process = subprocess.Popen(
self.command,
cwd=str(self._resolve_project_dir()),
env=self._build_env(),
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.STDOUT,
start_new_session=True,
)
def _stop_process(self) -> None:
process = self._process
self._process = None
if process is None or process.poll() is not None:
return
logger.info("Stopping Kamil ADC 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)
with suppress(ProcessLookupError):
os.killpg(process.pid, signal.SIGTERM)
try:
process.wait(timeout=grace_s)
return
except subprocess.TimeoutExpired:
pass
logger.warning("Kamil ADC 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
# (USB D-state in the L-Card driver) may not be reaped within the grace
# window even after SIGKILL. Best-effort wait; the OS reaps it eventually.
with suppress(subprocess.TimeoutExpired):
process.wait(timeout=1.0)
def _wait_for_tty(
self,
previous_identity: tuple[object, ...] | None,
*,
stop_event: threading.Event | None = None,
) -> None:
adc = self.config.radar.kamil_adc
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_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")
else:
time.sleep(0.05)
raise TimeoutError(
f"Timed out waiting for Kamil ADC TTY `{adc.tty_path}` to be created by the collector"
)
# ------------------------------------------------------------------
# Path / environment resolution
# ------------------------------------------------------------------
def _resolve_executable(self) -> Path:
return self._resolve_path(self.config.radar.kamil_adc.executable_path)
def _resolve_project_dir(self) -> Path:
project_dir = self.config.radar.kamil_adc.project_dir
return self._resolve_path(project_dir) if project_dir else _REPO_ROOT
@staticmethod
def _resolve_path(path_str: str) -> Path:
"""Expand ``~`` and resolve a relative path against the project root."""
path = Path(path_str).expanduser()
return path if path.is_absolute() else (_REPO_ROOT / path)
def _build_env(self) -> dict[str, str]:
"""Child environment: inherited env + config env, with the L-Card lib path.
Config ``env`` values are ``~``/``$VAR`` expanded. Unless the config pins
``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
env = os.environ.copy()
for key, value in adc.env.items():
env[key] = os.path.expandvars(os.path.expanduser(value))
if "LD_LIBRARY_PATH" not in adc.env:
lcard_dir = os.path.expanduser(_DEFAULT_LCARD_LIB_DIR)
existing = env.get("LD_LIBRARY_PATH", "")
env["LD_LIBRARY_PATH"] = f"{lcard_dir}{os.pathsep}{existing}" if existing else lcard_dir
return env
# ------------------------------------------------------------------
# Validation / diagnostics
# ------------------------------------------------------------------
def _validate_config(self) -> None:
if not self.config.is_kamil_adc:
raise RuntimeError("KamilAdcService requires radar.model='kamil_adc'")
if self.config.radar.driver_mode != "native":
raise RuntimeError("Kamil ADC requires radar.driver_mode='native'")
adc = self.config.radar.kamil_adc
if not adc.executable_path:
raise ValueError("radar.kamil_adc.executable_path is required")
if not adc.tty_path:
raise ValueError("radar.kamil_adc.tty_path is required")
if any(arg.startswith("tty:") for arg in adc.args):
raise ValueError("radar.kamil_adc.args must not contain tty:<path>; use tty_path instead")
for name in ("startup_timeout_s", "sweep_timeout_s", "stop_timeout_s"):
if getattr(adc, name) <= 0.0:
raise ValueError(f"radar.kamil_adc.{name} must be > 0")
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}")
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}")
if not os.access(executable_path, os.X_OK):
raise RuntimeError(f"radar.kamil_adc.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."""
self._rejected_count += 1
if self._rejected_count != 1 and self._rejected_count % _REJECT_LOG_EVERY != 0:
return
params = self._processor.params # type: ignore[union-attr]
try:
freqs = self._processor.reference_frequency_axis(raw.reference) # type: ignore[union-attr]
covered = f"[{float(np.min(freqs)):.6g}, {float(np.max(freqs)):.6g}]"
except Exception: # noqa: BLE001 — diagnostics must never raise
covered = "<unavailable>"
logger.warning(
"Kamil ADC sweep rejected (count=%d): covered %s Hz does not span band "
"[%.6g, %.6g] Hz (usable points=%d). Check phase_calibration/band.",
self._rejected_count, covered, params.band_start_hz, params.band_stop_hz, raw.size,
)
def _tty_identity(path: str) -> tuple[object, ...] | None:
try:
if os.path.islink(path):
stat_result = os.lstat(path)
return (
"link",
os.readlink(path),
int(stat_result.st_dev),
int(stat_result.st_ino),
int(stat_result.st_mtime_ns),
)
stat_result = os.stat(path)
except FileNotFoundError:
return None
return (
"node",
int(stat_result.st_dev),
int(stat_result.st_ino),
int(stat_result.st_mtime_ns),
)
def _prepare_tty_path_for_collector(path: str) -> tuple[object, ...] | None:
"""Remove a stale generated TTY symlink/file before starting the collector."""
try:
stat_result = os.lstat(path)
except FileNotFoundError:
return None
if stat.S_ISLNK(stat_result.st_mode) or stat.S_ISREG(stat_result.st_mode):
os.unlink(path)
return None
@@ -0,0 +1,190 @@
"""Background TTY reader publishing the latest completed Kamil ADC 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
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
latest data. Parser/stream errors are captured and re-raised on the consumer
thread (fail-fast; the supervisor relaunches a clean collector).
"""
from __future__ import annotations
from dataclasses import dataclass, field
import errno
import logging
import os
import select
import subprocess
import threading
import time
from python_app.hardware_full.kamil_adc.protocol import KamilAdcStreamParser, RawSweep
logger = logging.getLogger(__name__)
# Large reads keep up with bursty CDC-ACM/PTY writers without raising the syscall
# rate; 64 KiB matches the typical Linux PTY buffer size.
_READ_CHUNK_BYTES = 65536
# select() poll interval — short enough to react to close() promptly, long enough
# that idle CPU stays near zero.
_READ_POLL_INTERVAL_S = 0.1
def raise_if_process_exited(process: subprocess.Popen[bytes] | None) -> None:
"""Raise if the external collector process has exited."""
if process is None:
return
return_code = process.poll()
if return_code is not None:
raise RuntimeError(f"Kamil ADC collector exited with code {return_code}")
@dataclass(slots=True)
class KamilAdcTtyReader:
"""Daemon-thread TTY reader publishing the latest completed sweep."""
tty_path: str
_fd: int | None = field(init=False, default=None, repr=False)
_thread: threading.Thread | None = field(init=False, default=None, repr=False)
_stop_event: threading.Event = field(init=False, default_factory=threading.Event, repr=False)
_mailbox_cv: threading.Condition = field(init=False, default_factory=threading.Condition, repr=False)
_latest_sweep: RawSweep | None = field(init=False, default=None, repr=False)
_reader_error: Exception | None = field(init=False, default=None, repr=False)
_published_count: int = field(init=False, default=0, repr=False)
def open(self) -> None:
"""Open the TTY and start the background reader thread."""
if self._fd is not None:
return
self._fd = os.open(self.tty_path, os.O_RDONLY | os.O_NOCTTY | os.O_NONBLOCK)
self._stop_event.clear()
self._latest_sweep = None
self._reader_error = None
self._published_count = 0
self._thread = threading.Thread(
target=self._reader_loop,
name=f"kamil-adc-tty-reader[{self.tty_path}]",
daemon=True,
)
self._thread.start()
logger.info("Kamil ADC 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)
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")
self._thread = None
if self._fd is not None:
try:
os.close(self._fd)
finally:
self._fd = None
self._latest_sweep = None
self._reader_error = None
@property
def published_count(self) -> int:
"""Total number of sweeps the reader thread has produced."""
with self._mailbox_cv:
return self._published_count
def read_sweep(
self,
*,
timeout_s: float,
process: subprocess.Popen[bytes] | None = None,
) -> RawSweep:
"""Wait for and return the next published sweep.
Raises :class:`TimeoutError` if none arrives within ``timeout_s``,
:class:`RuntimeError` if the collector process exited, and re-raises any
error caught by the reader thread.
"""
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:
# 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))
# ------------------------------------------------------------------
# Reader-thread internals
# ------------------------------------------------------------------
def _reader_loop(self) -> None:
"""Drain the TTY, parse sweeps, and publish each completed one until stop."""
parser = KamilAdcStreamParser()
try:
while not self._stop_event.is_set():
chunk = self._read_available()
if not chunk:
continue
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)
self._publish_error(exc)
def _read_available(self) -> bytes:
"""Block on ``select`` up to the poll interval; return new bytes (maybe empty).
Returns ``b""`` when no data is ready yet or a stop was requested; raises
:class:`RuntimeError` on stream close or an unrecoverable read error.
"""
fd = self._fd
if fd is None or self._stop_event.is_set():
return b""
try:
readable, _, _ = select.select([fd], [], [], _READ_POLL_INTERVAL_S)
except InterruptedError:
return b""
if not readable:
return b""
try:
chunk = os.read(fd, _READ_CHUNK_BYTES)
except BlockingIOError:
return b""
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
if not chunk:
raise RuntimeError(f"Kamil ADC TTY `{self.tty_path}` closed while reading")
return chunk
def _publish_sweep(self, sweep: RawSweep) -> None:
"""Store ``sweep`` as the latest mailbox value, overwriting any unread one."""
with self._mailbox_cv:
self._latest_sweep = sweep
self._published_count += 1
self._mailbox_cv.notify()
def _publish_error(self, exc: Exception) -> None:
"""Record ``exc`` as the reader fault and wake any waiter."""
with self._mailbox_cv:
self._reader_error = exc
self._mailbox_cv.notify_all()
@@ -1,639 +0,0 @@
"""Service for acquiring sweeps from the external Kamil ADC collector.
The external `kamil_adc` binary publishes its samples on a PTY/TTY device as a
stream of 8-byte frames:
* **Start marker**: `0x000A 0xFFFF 0xFFFF 0xFFFF` delimits sweep boundaries.
* **Point frame**: `0x000A step real_i16 imag_i16` one complex sample per
frame, with `step` running 1, 2, , N for an N-point sweep.
The hardware emits sweeps continuously, faster than callers tend to invoke
:meth:`KamilAdcService.acquire`. To avoid TTY-buffer overruns and stale data,
a daemon thread drains the device end of the TTY non-stop, parses complete
sweeps as they arrive, and publishes the **latest** one to a one-slot mailbox.
:meth:`acquire` simply waits for the next sweep to appear in that mailbox.
Sweep length is determined by the first sweep observed at runtime and stays
constant for the life of the service; any later mismatch is treated as a
protocol violation rather than something to silently discard.
"""
from __future__ import annotations
from contextlib import suppress
from dataclasses import dataclass, field
import errno
import logging
import os
from pathlib import Path
import select
import signal
import stat
import struct
import subprocess
import threading
import time
import numpy as np
from python_app.hardware_full.librevna_driver.models import SweepResult
from python_app.models.run_config_model import RadarSweepModel, RunConfigModel
logger = logging.getLogger(__name__)
# Wire-format constants for the Kamil ADC TTY protocol.
KAMIL_ADC_MARKER = 0x000A
KAMIL_ADC_START_STEP = 0xFFFF
KAMIL_ADC_FRAME_BYTES = 8
_START_FRAME: bytes = struct.pack(
"<HHHH", KAMIL_ADC_MARKER, KAMIL_ADC_START_STEP, KAMIL_ADC_START_STEP, KAMIL_ADC_START_STEP
)
# Point frames carry signed 16-bit real/imag components; start markers reuse
# the same 8-byte slot but with all four words unsigned. Comparing the raw
# bytes against :data:`_START_FRAME` is therefore the correct boundary check.
_POINT_STRUCT = struct.Struct("<HHhh")
# Larger TTY reads keep up with bursty USB CDC-ACM writers without raising the
# syscall rate. 64 KiB matches the typical Linux PTY buffer size.
_READ_CHUNK_BYTES = 65536
# select() poll interval inside the reader thread — short enough to react to
# `close()` requests, long enough that idle CPU stays near zero.
_READ_POLL_INTERVAL_S = 0.1
def _parse_point_frame(frame: bytes, expected_step: int) -> complex:
"""Parse one 8-byte point frame; validate marker and step ordering."""
marker, step, real, imag = _POINT_STRUCT.unpack(frame)
if marker != KAMIL_ADC_MARKER:
raise ValueError(f"Kamil ADC marker mismatch: got 0x{marker:04x}, expected 0x000a")
if step != expected_step:
raise ValueError(f"Kamil ADC step mismatch: got {step}, expected {expected_step}")
return complex(real, imag)
@dataclass(slots=True)
class KamilAdcTtyReader:
"""Background-thread TTY reader publishing the latest completed sweep.
The reader spawns a daemon thread on :meth:`open` which continuously
drains the TTY, parses frames into complete sweeps, and stores the most
recent one in a single-slot mailbox. Consumers call :meth:`read_sweep` to
take that sweep; if a newer one arrives before the consumer reads, it
overwrites the previous unread value by design, since consumers always
want the freshest data.
"""
tty_path: str
_fd: int | None = field(init=False, default=None, repr=False)
_thread: threading.Thread | None = field(init=False, default=None, repr=False)
_stop_event: threading.Event = field(init=False, default_factory=threading.Event, repr=False)
_mailbox_cv: threading.Condition = field(init=False, default_factory=threading.Condition, repr=False)
_latest_sweep: np.ndarray | None = field(init=False, default=None, repr=False)
_reader_error: Exception | None = field(init=False, default=None, repr=False)
_locked_points: int | None = field(init=False, default=None, repr=False)
_published_count: int = field(init=False, default=0, repr=False)
def open(self) -> None:
"""Open the TTY and start the background reader thread."""
if self._fd is not None:
return
self._fd = os.open(self.tty_path, os.O_RDONLY | os.O_NOCTTY | os.O_NONBLOCK)
self._stop_event.clear()
self._latest_sweep = None
self._reader_error = None
self._locked_points = None
self._published_count = 0
self._thread = threading.Thread(
target=self._reader_loop,
name=f"kamil-adc-tty-reader[{self.tty_path}]",
daemon=True,
)
self._thread.start()
logger.info("Kamil ADC 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)
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")
self._thread = None
if self._fd is not None:
try:
os.close(self._fd)
finally:
self._fd = None
self._latest_sweep = None
self._reader_error = None
self._locked_points = None
@property
def locked_points(self) -> int | None:
"""Return the sweep point count established by the first sweep, or `None`."""
return self._locked_points
@property
def published_count(self) -> int:
"""Return the total number of sweeps the reader thread has produced."""
with self._mailbox_cv:
return self._published_count
def read_sweep(
self,
*,
timeout_s: float,
process: subprocess.Popen[bytes] | None = None,
) -> np.ndarray:
"""Wait for and return the next published sweep.
Raises :class:`TimeoutError` if no sweep arrives within `timeout_s`,
:class:`RuntimeError` if the external collector process exited, and
propagates any exception caught by the reader thread.
"""
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:
# Always deliver a pending sweep first: if the reader thread
# both published a sweep and then died, the consumer should
# still see the good data and only meet 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
self._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"
)
# Wake periodically so we can re-check process liveness.
self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s))
# ------------------------------------------------------------------
# Reader-thread internals
# ------------------------------------------------------------------
def _reader_loop(self) -> None:
"""Drain the TTY, parse frames, and publish completed sweeps until stop.
Runs on the background reader thread. Any exception is logged and stored
so the next :meth:`read_sweep` re-raises it on the consumer thread.
"""
buffer = bytearray()
try:
if not self._skip_to_first_start_marker(buffer):
return
while not self._stop_event.is_set():
sweep = self._read_one_sweep(buffer)
if sweep is None:
return
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)
self._publish_error(exc)
def _skip_to_first_start_marker(self, buffer: bytearray) -> bool:
"""Discard pre-roll bytes until a start marker is consumed from `buffer`."""
while not self._stop_event.is_set():
start_index = buffer.find(_START_FRAME)
if start_index >= 0:
del buffer[: start_index + KAMIL_ADC_FRAME_BYTES]
return True
# Keep just enough trailing bytes that a marker split across read
# boundaries can still be reassembled on the next chunk.
if len(buffer) >= KAMIL_ADC_FRAME_BYTES:
del buffer[: -(KAMIL_ADC_FRAME_BYTES - 1)]
if not self._read_more(buffer):
return False
return False
def _read_one_sweep(self, buffer: bytearray) -> np.ndarray | None:
"""Parse frames from `buffer` until the next start marker; return the sweep."""
values: list[complex] = []
expected_step = 1
while not self._stop_event.is_set():
while len(buffer) < KAMIL_ADC_FRAME_BYTES:
if not self._read_more(buffer):
return None
frame = bytes(buffer[:KAMIL_ADC_FRAME_BYTES])
del buffer[:KAMIL_ADC_FRAME_BYTES]
if frame == _START_FRAME:
if not values:
# Two consecutive markers — ignore the empty sweep and keep parsing.
continue
self._validate_and_lock_point_count(len(values))
return np.asarray(values, dtype=np.complex64)
if self._locked_points is not None and expected_step > self._locked_points:
raise RuntimeError(
f"Kamil ADC sweep exceeded locked point count {self._locked_points} "
"without a start marker"
)
values.append(_parse_point_frame(frame, expected_step))
expected_step += 1
return None
def _validate_and_lock_point_count(self, points: int) -> None:
"""Lock the point count on the first sweep; reject mismatches thereafter."""
if self._locked_points is None:
self._locked_points = points
logger.info("Kamil ADC sweep point count locked to %d", points)
return
if points != self._locked_points:
raise RuntimeError(
f"Kamil ADC sweep length changed: locked={self._locked_points}, got={points}"
)
def _read_more(self, buffer: bytearray) -> bool:
"""Block on `select` until bytes arrive, then append them to `buffer`.
Returns `False` if the reader was asked to stop, `True` if at least one
byte was appended. Raises on stream-level errors.
"""
fd = self._fd
if fd is None:
return False
while not self._stop_event.is_set():
try:
readable, _, _ = select.select([fd], [], [], _READ_POLL_INTERVAL_S)
except InterruptedError:
continue
if not readable:
continue
try:
chunk = os.read(fd, _READ_CHUNK_BYTES)
except BlockingIOError:
continue
except OSError as exc:
if exc.errno in {errno.EAGAIN, errno.EWOULDBLOCK}:
continue
raise RuntimeError(
f"Failed to read Kamil ADC TTY `{self.tty_path}`: {exc}"
) from exc
if not chunk:
raise RuntimeError(f"Kamil ADC TTY `{self.tty_path}` closed while reading")
buffer.extend(chunk)
return True
return False
def _publish_sweep(self, sweep: np.ndarray) -> None:
"""Store `sweep` as the latest mailbox value, overwriting any prior unread one."""
with self._mailbox_cv:
self._latest_sweep = sweep
self._published_count += 1
self._mailbox_cv.notify()
def _publish_error(self, exc: Exception) -> None:
"""Record `exc` as the reader fault and wake any waiter."""
with self._mailbox_cv:
self._reader_error = exc
self._mailbox_cv.notify_all()
@staticmethod
def _raise_if_process_exited(process: subprocess.Popen[bytes] | None) -> None:
if process is None:
return
return_code = process.poll()
if return_code is not None:
raise RuntimeError(f"Kamil ADC process exited with code {return_code}")
@dataclass(slots=True)
class KamilAdcService:
"""Launch the external `kamil_adc` collector and serve its sweeps."""
config: RunConfigModel
_process: subprocess.Popen[bytes] | None = field(init=False, default=None, repr=False)
_reader: KamilAdcTtyReader | None = field(init=False, default=None, repr=False)
_settings: RadarSweepModel | None = field(init=False, default=None, repr=False)
_frequency_hz: np.ndarray | None = field(init=False, default=None, repr=False)
def __post_init__(self) -> None:
self._validate_config()
@property
def command(self) -> list[str]:
"""Return external collector command including the generated TTY argument."""
adc = self.config.radar.kamil_adc
executable_path = str(Path(adc.executable_path).expanduser())
return [executable_path, *adc.args, f"tty:{adc.tty_path}"]
def open(self, *, stop_event: threading.Event | None = None) -> None:
"""Launch the collector and start the TTY reader thread.
An optional `stop_event` lets a caller abort the TTY-wait loop promptly
(e.g. on shutdown) instead of blocking for the full startup timeout.
"""
if self._reader is not None:
return
previous_tty_identity = _prepare_tty_path_for_collector(self.config.radar.kamil_adc.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.open()
self._reader = reader
logger.info("Kamil ADC service opened")
except Exception:
logger.exception("Kamil ADC 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")
if self._reader is not None:
with suppress(Exception):
self._reader.close()
self._reader = None
self._stop_process()
def configure(self, sweep: RadarSweepModel) -> None:
"""Store sweep settings used to build the synthetic frequency axis."""
self._validate_sweep(sweep)
self._settings = sweep
self._frequency_hz = None
logger.debug(
"Kamil ADC configured: frequency axis %s-%s Hz", sweep.start_hz, sweep.stop_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")
def acquire(self) -> SweepResult:
"""Return the most recent completed sweep as S21 (S11 filled with zeros)."""
if self._settings is None:
raise RuntimeError("Kamil ADC service is not configured")
if self._reader is None:
raise RuntimeError("Kamil ADC 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 process is not running (code={return_code})")
s21 = self._reader.read_sweep(
timeout_s=self.config.radar.kamil_adc.sweep_timeout_s,
process=process,
)
points = int(s21.size)
if self._frequency_hz is None or self._frequency_hz.size != points:
logger.debug("Building Kamil ADC frequency axis for %d points", points)
self._frequency_hz = self._build_frequency_axis(points)
return SweepResult(
x=self._frequency_hz.copy(),
traces={
"s11": np.zeros(points, dtype=np.complex64),
"s21": s21,
},
)
# ------------------------------------------------------------------
# Process / TTY lifecycle
# ------------------------------------------------------------------
def _start_process(self) -> None:
if self._process is not None and self._process.poll() is None:
return
adc = self.config.radar.kamil_adc
env = os.environ.copy()
env.update(adc.env)
logger.info("Starting Kamil ADC collector: %s", " ".join(self.command))
self._process = subprocess.Popen(
self.command,
cwd=str(Path(adc.project_dir).expanduser()),
env=env,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.STDOUT,
start_new_session=True,
)
def _stop_process(self) -> None:
process = self._process
self._process = None
if process is None or process.poll() is not None:
return
logger.info("Stopping Kamil ADC collector (pid=%d)", process.pid)
with suppress(ProcessLookupError):
os.killpg(process.pid, signal.SIGTERM)
try:
process.wait(timeout=self.config.radar.kamil_adc.stop_timeout_s)
return
except subprocess.TimeoutExpired:
pass
logger.warning(
"Kamil ADC collector (pid=%d) ignored SIGTERM; sending SIGKILL", process.pid
)
with suppress(ProcessLookupError):
os.killpg(process.pid, signal.SIGKILL)
process.wait(timeout=1.0)
def _wait_for_tty(
self,
previous_identity: tuple[object, ...] | None,
*,
stop_event: threading.Event | None = None,
) -> None:
adc = self.config.radar.kamil_adc
deadline = time.monotonic() + adc.startup_timeout_s
while time.monotonic() < deadline:
# Abort promptly if a stop was requested mid-wait.
if stop_event is not None and stop_event.is_set():
raise RuntimeError("Kamil ADC startup aborted before TTY became available")
KamilAdcTtyReader._raise_if_process_exited(self._process)
identity = _tty_identity(adc.tty_path)
if identity is not None and identity != previous_identity:
return
# Use the stop event's wait() so a set() breaks the poll immediately.
if stop_event is not None:
if stop_event.wait(0.05):
raise RuntimeError("Kamil ADC 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"
)
# ------------------------------------------------------------------
# Validation helpers
# ------------------------------------------------------------------
def _validate_config(self) -> None:
if not self.config.is_kamil_adc:
raise RuntimeError("KamilAdcService requires radar.model='kamil_adc'")
if self.config.radar.driver_mode != "native":
raise RuntimeError("Kamil ADC requires radar.driver_mode='native'")
adc = self.config.radar.kamil_adc
if not adc.project_dir:
raise ValueError("radar.kamil_adc.project_dir is required")
if not adc.executable_path:
raise ValueError("radar.kamil_adc.executable_path is required")
if not adc.tty_path:
raise ValueError("radar.kamil_adc.tty_path is required")
if any(arg.startswith("tty:") for arg in adc.args):
raise ValueError("radar.kamil_adc.args must not contain tty:<path>; use tty_path instead")
if adc.startup_timeout_s <= 0.0:
raise ValueError("radar.kamil_adc.startup_timeout_s must be > 0")
if adc.sweep_timeout_s <= 0.0:
raise ValueError("radar.kamil_adc.sweep_timeout_s must be > 0")
if adc.stop_timeout_s <= 0.0:
raise ValueError("radar.kamil_adc.stop_timeout_s must be > 0")
project_dir = Path(adc.project_dir).expanduser()
if not project_dir.is_dir():
raise RuntimeError(f"radar.kamil_adc.project_dir is not a directory: {project_dir}")
executable_path = Path(adc.executable_path).expanduser()
if not executable_path.is_file():
raise RuntimeError(f"radar.kamil_adc.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}")
@staticmethod
def _validate_sweep(sweep: RadarSweepModel) -> None:
if float(sweep.stop_hz) < float(sweep.start_hz):
raise ValueError("Kamil ADC sweep stop_hz must be >= start_hz")
def _build_frequency_axis(self, points: int) -> np.ndarray:
if self._settings is None:
raise RuntimeError("Kamil ADC service is not configured")
return np.linspace(
float(self._settings.start_hz),
float(self._settings.stop_hz),
int(points),
dtype=np.float32,
)
def _tty_identity(path: str) -> tuple[object, ...] | None:
try:
if os.path.islink(path):
stat_result = os.lstat(path)
return (
"link",
os.readlink(path),
int(stat_result.st_dev),
int(stat_result.st_ino),
int(stat_result.st_mtime_ns),
)
stat_result = os.stat(path)
except FileNotFoundError:
return None
return (
"node",
int(stat_result.st_dev),
int(stat_result.st_ino),
int(stat_result.st_mtime_ns),
)
def _prepare_tty_path_for_collector(path: str) -> tuple[object, ...] | None:
"""Remove stale generated TTY links before starting the external collector."""
try:
stat_result = os.lstat(path)
except FileNotFoundError:
return None
if stat.S_ISLNK(stat_result.st_mode) or stat.S_ISREG(stat_result.st_mode):
os.unlink(path)
return None
return None
def apply_kamil_adc_laser_control(config: RunConfigModel) -> bool:
"""Apply the configured laser settings through the legacy device_main command sequence.
Connects to the laser controller, resets it, and applies either manual or
variation mode per ``radar.laser_control``. 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,
},
)
return True
raise RuntimeError(f"Unsupported laser_control mode: {laser.mode}")
finally:
controller.disconnect()
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")
@@ -5,7 +5,7 @@ from __future__ import annotations
import logging
from typing import Protocol
from python_app.hardware_full.kamil_adc_service import KamilAdcService
from python_app.hardware_full.kamil_adc import KamilAdcService
from python_app.hardware_full.librevna_driver.models import SweepResult
from python_app.hardware_full.librevna_service import LibreVnaService
from python_app.hardware_full.remote_compact_m_k209_service import RemoteCompactMK209Service
@@ -3,6 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from python_app.hardware_full.switch_drivers import (
H7992Driver,
@@ -11,6 +12,9 @@ from python_app.hardware_full.switch_drivers import (
SwitchDriverProtocol,
)
if TYPE_CHECKING:
from python_app.models.run_config_schema import SwitchModel
@dataclass(slots=True)
class SwitchService:
@@ -31,6 +35,25 @@ class SwitchService:
"""Create underlying driver based on configured mode and type."""
self._driver = self._build_driver()
@classmethod
def from_model(cls, model: SwitchModel) -> SwitchService:
"""Build a switch service from a ``SwitchModel`` config section.
The single construction path shared by every acquisition producer and
capture workflow, so all devices drive switches identically.
"""
return cls(
name=model.name,
positions=model.positions,
mode=model.driver_mode,
driver=model.driver,
gpio_chip=model.gpio_chip,
pin_a=model.pin_a,
pin_b=model.pin_b,
invert_logic=model.invert_logic,
default_position=model.default_position,
)
def open(self) -> None:
"""Open underlying switch driver resources."""
self._driver.open()
+7
View File
@@ -185,6 +185,12 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
gui.processing.pass_through.show_phase,
"gui.processing.pass_through",
),
unwrap_phase=_optional_bool(
pass_through_object,
"unwrap_phase",
gui.processing.pass_through.unwrap_phase,
"gui.processing.pass_through",
),
combo_filter=_optional_string(
pass_through_object,
"combo_filter",
@@ -521,6 +527,7 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]:
"pass_through": {
"show_magnitude": gui.processing.pass_through.show_magnitude,
"show_phase": gui.processing.pass_through.show_phase,
"unwrap_phase": gui.processing.pass_through.unwrap_phase,
"combo_filter": gui.processing.pass_through.combo_filter,
"fixed_y_enabled": gui.processing.pass_through.fixed_y_enabled,
"y_min_db": gui.processing.pass_through.y_min_db,
+1
View File
@@ -30,6 +30,7 @@ class GuiPassThroughStateModel:
show_magnitude: bool = True
show_phase: bool = True
unwrap_phase: bool = False
combo_filter: str = ""
fixed_y_enabled: bool = False
y_min_db: float = -100.0
+24
View File
@@ -239,6 +239,19 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
model.radar.kamil_adc.stop_timeout_s = _read_float(
kamil_adc_payload, "stop_timeout_s", model.radar.kamil_adc.stop_timeout_s
)
phase_calibration_payload = _as_dict(
kamil_adc_payload.get("phase_calibration"), "radar.kamil_adc.phase_calibration"
)
calibration = model.radar.kamil_adc.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.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)
model.radar.laser_control.enabled = _read_bool(
laser_control_payload, "enabled", model.radar.laser_control.enabled
@@ -490,6 +503,17 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
"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,
+4
View File
@@ -6,7 +6,9 @@ from python_app.models.run_config_schema import (
GprModel,
GprRxGeometryModel,
GprTxGeometryModel,
KamilAdcBandModel,
KamilAdcModel,
KamilAdcPhaseCalibrationModel,
LaserControlModel,
LaserManualModeModel,
LaserVariationModeModel,
@@ -37,7 +39,9 @@ __all__ = [
"GprModel",
"GprRxGeometryModel",
"GprTxGeometryModel",
"KamilAdcBandModel",
"KamilAdcModel",
"KamilAdcPhaseCalibrationModel",
"LaserControlModel",
"LaserManualModeModel",
"LaserVariationModeModel",
+36
View File
@@ -42,6 +42,38 @@ class RadarMultiDeviceModel:
recovery_attempts: int = 3
@dataclass(slots=True)
class KamilAdcPhaseCalibrationModel:
"""Affine law mapping the reference signal's unwrapped phase to frequency.
Two fixed anchor points ``(phase0_rad, freq0_hz)`` and ``(phase1_rad, freq1_hz)``
define ``f(phase) = freq0_hz + (phase - phase0_rad) * (freq1_hz - freq0_hz)
/ (phase1_rad - phase0_rad)``, applied to the *absolute* unwrapped phase of
every sweep. These are physical constants of the reference arm and must be
supplied by config never derived from a live sweep.
"""
phase0_rad: float = 0.0
freq0_hz: float = 2_046_000_000.0
phase1_rad: float = 300.0
freq1_hz: float = 5_612_000_000.0
@dataclass(slots=True)
class KamilAdcBandModel:
"""Fixed frequency window every sweep is cropped to and resampled onto.
Each sweep is resampled onto ``linspace(start_hz, stop_hz, points)`` so all
sweeps share one identical axis and can be averaged/subtracted. The window
must lie inside the (floating) range each sweep actually covers; sweeps that
fail to cover it are rejected rather than edge-extrapolated.
"""
start_hz: float = 2_100_000_000.0
stop_hz: float = 5_500_000_000.0
points: int = 2048
@dataclass(slots=True)
class KamilAdcModel:
"""External Kamil ADC acquisition process settings."""
@@ -54,6 +86,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
)
band: KamilAdcBandModel = field(default_factory=KamilAdcBandModel)
@dataclass(slots=True)
@@ -23,6 +23,10 @@ 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)
@@ -135,6 +139,9 @@ 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_orchestrator_exit=%s)",
@@ -186,6 +193,7 @@ class ProcessSupervisor:
def stop(self) -> None:
"""Stop acquisition-side processes, keep processor process intact."""
self._stop_processes(["sweep_orchestrator", "data_preprocessor"])
self._kill_kamil_collectors()
def stop_orchestrator(self) -> None:
"""Stop orchestrator process only."""
@@ -198,6 +206,7 @@ class ProcessSupervisor:
def stop_all(self) -> None:
"""Stop all managed processes."""
self._stop_processes(["sweep_orchestrator", "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.
@@ -353,6 +362,32 @@ class ProcessSupervisor:
except (ProcessLookupError, PermissionError):
pass
def _kill_kamil_collectors(self) -> 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
if _KAMIL_COLLECTOR_MARKER in cmdline:
self._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.
@@ -523,6 +558,9 @@ class ProcessSupervisor:
self._pidfile_path.write_text("", encoding="utf-8")
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.
+265
View File
@@ -0,0 +1,265 @@
"""Calibrate the Kamil ADC reference-phase frequency law from live sweeps.
Captures many raw sweeps from the collector and reports and optionally writes
back the calibration the runtime uses:
* ``phase0_rad`` / ``phase1_rad`` the unwrapped reference phase at the sweep
start and stop, taken as the MEDIAN over all captured sweeps. ``freq0_hz`` /
``freq1_hz`` (the known sweep endpoints) come from config and are kept as-is.
* ``band.points`` recommended as the median number of usable points landing
inside ``[band.start_hz, band.stop_hz]``, so the fixed output grid matches the
native density rather than inflating it.
It also reports how reliably the configured band is covered (sweeps that do not
span it are rejected at runtime) and how many points the crop discards.
The collector is launched with the ``do8_freq_ref`` arguments regardless of what
the on-disk config says, so the reference channel is always present. With
``--apply`` the config is migrated to that collector/args and the calibrated
anchors + recommended point count are written back.
Run on the Pi, e.g.::
.venv/bin/python -m python_app.scripts.kamil_adc_calibrate \
--config run_config_kamil_adc.pi.json --sweeps 200 --apply
"""
from __future__ import annotations
import argparse
from contextlib import suppress
import json
import logging
from pathlib import Path
import statistics
import time
import numpy as np
from python_app.hardware_full.kamil_adc import KamilAdcService, apply_kamil_adc_laser_control
from python_app.models.run_config_model import RunConfigModel
logger = logging.getLogger("kamil_adc_calibrate")
# The collector arguments that enable the DI8 reference overlay (mirrors the
# kamil example config / run_do8_freq_ref.sh). Forced on so calibration always
# sees the reference channel even if the on-disk config predates it.
DO8_FREQ_REF_ARGS = [
"profile:phase",
"clock:internal",
"internal_ref_hz:2000000",
"start:di_syn2_rise",
"stop:di_syn2_fall",
"sample_clock_hz:max",
"range:2",
"duration_ms:100",
"packet_limit:0",
"do1_toggle_per_frame",
"do1_pair_subtract_avg",
"do8_freq_ref",
"do8_cycle_period:8",
]
COLLECTOR_PATH = "build/bin/kamil_adc_collector"
def _open_with_retry(service: KamilAdcService, *, attempts: int = 4, delay_s: float = 8.0) -> None:
"""Open the collector, retrying the transient E-502 device-busy after a close.
The L-Card device is not always reacquirable immediately after a previous
collector released it; a short backoff lets it settle before the next try.
"""
for attempt in range(1, attempts + 1):
try:
service.open()
return
except Exception as exc: # noqa: BLE001 — device-busy is expected and retried
logger.warning("Collector open failed (attempt %d/%d): %s", attempt, attempts, exc)
with suppress(Exception):
service.close()
if attempt < attempts:
time.sleep(delay_s)
raise RuntimeError(f"Collector did not open after {attempts} attempts")
def _capture_reference_phases(service: KamilAdcService, *, warmup: int, sweeps: int) -> list[np.ndarray]:
"""Capture `sweeps` reference-phase arrays after discarding `warmup` sweeps."""
for index in range(warmup):
service.read_raw_sweep()
if index == 0:
logger.info("Warming up (%d sweeps) while the sweep settles...", warmup)
phases: list[np.ndarray] = []
for index in range(sweeps):
raw = service.read_raw_sweep()
if raw.reference.size >= 2:
phases.append(np.unwrap(np.angle(raw.reference.astype(np.complex128))))
if (index + 1) % 50 == 0:
logger.info("Captured %d/%d sweeps", index + 1, sweeps)
return phases
def _summarize(values: np.ndarray) -> str:
"""Compact min / median / max summary for a 1-D array."""
return f"min={np.min(values):.6g} median={np.median(values):.6g} max={np.max(values):.6g}"
def _analyze(phases: list[np.ndarray], config: RunConfigModel) -> dict:
"""Derive the calibration and band diagnostics from captured phase arrays."""
kamil = config.radar.kamil_adc
freq0, freq1 = kamil.phase_calibration.freq0_hz, kamil.phase_calibration.freq1_hz
band_start, band_stop = kamil.band.start_hz, kamil.band.stop_hz
phase0 = float(statistics.median(float(phase[0]) for phase in phases))
phase1 = float(statistics.median(float(phase[-1]) for phase in phases))
if phase1 == phase0:
raise RuntimeError("Degenerate calibration: median start and stop phases are equal")
slope = (freq1 - freq0) / (phase1 - phase0)
total_points = np.array([phase.size for phase in phases], dtype=np.float64)
f_starts, f_stops, in_band_counts, covers = [], [], [], []
for phase in phases:
freqs = freq0 + (phase - phase0) * slope
lo, hi = float(np.min(freqs)), float(np.max(freqs))
f_starts.append(lo)
f_stops.append(hi)
in_band_counts.append(int(np.count_nonzero((freqs >= band_start) & (freqs <= band_stop))))
covers.append(lo <= band_start and hi >= band_stop)
in_band = np.array(in_band_counts, dtype=np.float64)
f_start = np.array(f_starts)
f_stop = np.array(f_stops)
recommended_points = int(round(float(np.median(in_band))))
# A band that ~95% of sweeps satisfy on each edge: start at the 95th percentile
# of per-sweep start frequencies, stop at the 5th percentile of stop frequencies.
rec_band_start = float(np.quantile(f_start, 0.95))
rec_band_stop = float(np.quantile(f_stop, 0.05))
rec_coverage = float(np.mean((f_start <= rec_band_start) & (f_stop >= rec_band_stop)))
return {
"sweeps": len(phases),
"phase0_rad": phase0,
"phase1_rad": phase1,
"phase_span_rad": phase1 - phase0,
"phase0_mad_rad": float(np.median(np.abs([float(p[0]) - phase0 for p in phases]))),
"phase1_mad_rad": float(np.median(np.abs([float(p[-1]) - phase1 for p in phases]))),
"freq0_hz": freq0,
"freq1_hz": freq1,
"band_start_hz": band_start,
"band_stop_hz": band_stop,
"total_points_median": float(np.median(total_points)),
"in_band_points_median": float(np.median(in_band)),
"recommended_points": recommended_points,
"cropped_fraction": 1.0 - float(np.median(in_band)) / float(np.median(total_points)),
"coverage_fraction": float(np.mean(covers)),
"lower_ok_fraction": float(np.mean(f_start <= band_start)),
"upper_ok_fraction": float(np.mean(f_stop >= band_stop)),
"rec_band_start_hz": rec_band_start,
"rec_band_stop_hz": rec_band_stop,
"rec_coverage_fraction": rec_coverage,
"f_start": f_start,
"f_stop": f_stop,
}
def _report(result: dict) -> None:
"""Print a human-readable calibration report."""
print("\n" + "=" * 72)
print(f"Kamil ADC calibration over {result['sweeps']} sweeps")
print("=" * 72)
print(
f"phase0_rad = {result['phase0_rad']:.6f} (median start phase, MAD "
f"{result['phase0_mad_rad']:.4f}) -> {result['freq0_hz'] / 1e9:.4f} GHz"
)
print(
f"phase1_rad = {result['phase1_rad']:.6f} (median stop phase, MAD "
f"{result['phase1_mad_rad']:.4f}) -> {result['freq1_hz'] / 1e9:.4f} GHz"
)
print(f"phase span = {result['phase_span_rad']:.4f} rad")
print(
f"points/sweep: total median={result['total_points_median']:.0f}, "
f"in-band median={result['in_band_points_median']:.0f}"
)
print(f"recommended band.points = {result['recommended_points']}")
print(
f"band [{result['band_start_hz'] / 1e9:.3f}, {result['band_stop_hz'] / 1e9:.3f}] GHz: "
f"covered by {result['coverage_fraction'] * 100:.1f}% of sweeps "
f"(start<=lo: {result['lower_ok_fraction'] * 100:.1f}%, stop>=hi: {result['upper_ok_fraction'] * 100:.1f}%), "
f"{result['cropped_fraction'] * 100:.1f}% of points cropped"
)
print(f"per-sweep start freq (GHz): {_summarize(result['f_start'] / 1e9)}")
print(f"per-sweep stop freq (GHz): {_summarize(result['f_stop'] / 1e9)}")
print(
f"suggested band for ~95%/edge: [{result['rec_band_start_hz'] / 1e9:.3f}, "
f"{result['rec_band_stop_hz'] / 1e9:.3f}] GHz -> covers "
f"{result['rec_coverage_fraction'] * 100:.1f}% of sweeps"
)
if result["coverage_fraction"] < 0.95:
print(
"WARNING: many sweeps do not cover the configured band and would be rejected; "
"consider the suggested band above (or longer laser settling if sweeps are partial)."
)
print("=" * 72 + "\n")
def _apply(config_path: Path, result: dict) -> None:
"""Write the migrated collector args + calibrated anchors + points to the config."""
payload = json.loads(config_path.read_text(encoding="utf-8"))
kamil = payload.setdefault("radar", {}).setdefault("kamil_adc", {})
kamil["executable_path"] = COLLECTOR_PATH
kamil["args"] = list(DO8_FREQ_REF_ARGS)
kamil["phase_calibration"] = {
"phase0_rad": result["phase0_rad"],
"freq0_hz": result["freq0_hz"],
"phase1_rad": result["phase1_rad"],
"freq1_hz": result["freq1_hz"],
}
kamil["band"] = {
"start_hz": result["band_start_hz"],
"stop_hz": result["band_stop_hz"],
"points": result["recommended_points"],
}
config_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
print(f"Applied calibration to {config_path}")
def main() -> int:
parser = argparse.ArgumentParser(description="Calibrate Kamil ADC reference phase -> frequency")
parser.add_argument("--config", required=True, type=Path, help="Path to the kamil_adc run config")
parser.add_argument("--sweeps", type=int, default=200, help="Sweeps to average (default 200)")
parser.add_argument("--warmup", type=int, default=10, help="Sweeps to discard first (default 10)")
parser.add_argument("--apply", action="store_true", help="Write the calibration back to --config")
parser.add_argument("--no-laser", action="store_true", help="Skip laser setup (already running)")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
config = RunConfigModel.load_from_path(args.config)
if not config.is_kamil_adc:
raise SystemExit("Config is not a kamil_adc profile")
# Force the reference-producing collector regardless of the on-disk config.
config.radar.kamil_adc.executable_path = COLLECTOR_PATH
config.radar.kamil_adc.args = list(DO8_FREQ_REF_ARGS)
if not args.no_laser:
logger.info("Applying laser control...")
apply_kamil_adc_laser_control(config)
service = KamilAdcService(config)
logger.info("Opening collector: %s", " ".join(service.command))
_open_with_retry(service)
try:
phases = _capture_reference_phases(service, warmup=args.warmup, sweeps=args.sweeps)
finally:
service.close()
if len(phases) < max(2, args.sweeps // 2):
raise SystemExit(f"Only {len(phases)} usable sweeps captured; check the reference signal")
result = _analyze(phases, config)
_report(result)
if args.apply:
_apply(args.config, result)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+6 -19
View File
@@ -12,10 +12,10 @@ import time
import numpy as np
from python_app.hardware_full.kamil_adc_service import KamilAdcService
from python_app.hardware_full.kamil_adc import KamilAdcService
from python_app.hardware_full.switch_service import SwitchService
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.models.run_config_model import RunConfigModel, SwitchModel
from python_app.models.run_config_model import RunConfigModel
from python_app.orchestration.shm import ShmRingWriter
from python_app.storage.npz.serialize import RAW_MAGIC, serialize_trace_collection
@@ -137,8 +137,8 @@ def main() -> int:
config.rings.raw.name, config.rings.raw_tap.name,
)
radar = KamilAdcService(config)
input_switch = _switch_from_model(config.input_switch)
output_switch = _switch_from_model(config.output_switch)
input_switch = SwitchService.from_model(config.input_switch)
output_switch = SwitchService.from_model(config.output_switch)
try:
if not _open_radar_with_retry(config, radar, input_switch, output_switch, stop_requested):
@@ -226,7 +226,8 @@ def main() -> int:
output_switch.close()
with suppress(Exception):
input_switch.close()
radar.close()
with suppress(Exception):
radar.close()
raw_tap_writer.close()
raw_writer.close()
@@ -234,19 +235,5 @@ def main() -> int:
return 0
def _switch_from_model(model: SwitchModel) -> SwitchService:
return SwitchService(
name=model.name,
positions=model.positions,
mode=model.driver_mode,
driver=model.driver,
gpio_chip=model.gpio_chip,
pin_a=model.pin_a,
pin_b=model.pin_b,
invert_logic=model.invert_logic,
default_position=model.default_position,
)
if __name__ == "__main__":
raise SystemExit(main())
@@ -30,6 +30,22 @@ class GuiProfileCodecTest(unittest.TestCase):
self.assertEqual(decoded.gui.processing.pass_through.combo_filter, "0:0,1:0")
self.assertEqual(encoded["gui"]["processing"]["pass_through"]["combo_filter"], "0:0,1:0")
def test_pass_through_unwrap_phase_round_trips(self) -> None:
profile = GuiProfileModel(
gui=GuiStateModel(
processing=GuiProcessingStateModel(
pass_through=GuiPassThroughStateModel(unwrap_phase=True)
)
)
)
encoded = profile.to_dict()
decoded = GuiProfileModel.from_dict(encoded)
assert decoded.gui is not None
self.assertTrue(decoded.gui.processing.pass_through.unwrap_phase)
self.assertTrue(encoded["gui"]["processing"]["pass_through"]["unwrap_phase"])
def test_default_profile_round_trips_idempotently(self) -> None:
# Full-subtree idempotence catches field-drop/mis-map regressions across every
# sub-model, which the single combo_filter round-trip above cannot.
@@ -10,35 +10,26 @@ from python_app.models.run_config_model import RunConfigModel
from python_app.workflows.kamil_adc_neutral_preprocess import build_kamil_adc_neutral_s21_sets
def _kamil_config(*, points: int = 5) -> RunConfigModel:
return RunConfigModel.from_dict(
{
"radar": {
"model": "kamil_adc",
"kamil_adc": {
"band": {"start_hz": 2_100_000_000.0, "stop_hz": 5_500_000_000.0, "points": points},
},
},
"switches": {"port1": {"positions": 1}, "port2": {"positions": 2}},
"run": {"combos": [{"input": 0, "output": 0}, {"input": 1, "output": 0}]},
}
)
class KamilAdcNeutralPreprocessTest(unittest.TestCase):
def test_builds_passthrough_s21_sets_for_current_sweep(self) -> None:
config = RunConfigModel.from_dict(
{
"radar": {
"model": "kamil_adc",
"sweep": {
"start_hz": 1_000_000.0,
"stop_hz": 4_000_000.0,
"if_bandwidth_hz": 1.0,
"stimulus_power_dbm": -10.0,
},
},
"switches": {
"port1": {"positions": 1},
"port2": {"positions": 2},
},
"run": {
"combos": [
{"input": 0, "output": 0},
{"input": 1, "output": 0},
],
},
}
)
def test_builds_passthrough_s21_sets_on_band_grid(self) -> None:
calibration, reference = build_kamil_adc_neutral_s21_sets(_kamil_config(points=5))
calibration, reference = build_kamil_adc_neutral_s21_sets(config, point_count=4)
expected_frequency = np.linspace(1_000_000.0, 4_000_000.0, 4, dtype=np.float32)
expected_frequency = np.linspace(2_100_000_000.0, 5_500_000_000.0, 5).astype(np.float32)
self.assertEqual(len(calibration.traces), 2)
self.assertEqual(len(reference.traces), 2)
self.assertEqual(
@@ -47,47 +38,35 @@ class KamilAdcNeutralPreprocessTest(unittest.TestCase):
)
for trace in calibration.traces:
np.testing.assert_array_equal(trace.frequency_hz, expected_frequency)
np.testing.assert_array_equal(trace.s11, np.zeros(4, dtype=np.complex64))
np.testing.assert_array_equal(trace.s21, np.ones(4, dtype=np.complex64))
np.testing.assert_array_equal(trace.s11, np.zeros(5, dtype=np.complex64))
np.testing.assert_array_equal(trace.s21, np.ones(5, dtype=np.complex64))
for trace in reference.traces:
np.testing.assert_array_equal(trace.frequency_hz, expected_frequency)
np.testing.assert_array_equal(trace.s11, np.zeros(4, dtype=np.complex64))
np.testing.assert_array_equal(trace.s21, np.zeros(4, dtype=np.complex64))
np.testing.assert_array_equal(trace.s21, np.zeros(5, dtype=np.complex64))
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,
)
config = _kamil_config(points=17)
processor = KamilAdcSweepProcessor(KamilAdcProcessingParams.from_kamil_model(config.radar.kamil_adc))
calibration, _reference = build_kamil_adc_neutral_s21_sets(config)
np.testing.assert_array_equal(calibration.traces[0].frequency_hz, processor.grid_hz)
def test_rejects_non_kamil_config(self) -> None:
config = RunConfigModel.from_dict({"radar": {"model": "librevna"}})
with self.assertRaisesRegex(ValueError, "kamil_adc"):
build_kamil_adc_neutral_s21_sets(config, point_count=4)
build_kamil_adc_neutral_s21_sets(config)
@staticmethod
def _kamil_config() -> RunConfigModel:
return RunConfigModel.from_dict(
{
"radar": {
"model": "kamil_adc",
"sweep": {"start_hz": 1_000_000.0, "stop_hz": 4_000_000.0,
"if_bandwidth_hz": 1.0, "stimulus_power_dbm": -10.0},
},
"switches": {"port1": {"positions": 1}, "port2": {"positions": 2}},
"run": {"combos": [{"input": 0, "output": 0}, {"input": 1, "output": 0}]},
}
)
def test_point_count_zero_or_negative_raises(self) -> None:
config = self._kamil_config()
for bad in (0, -1):
with self.subTest(point_count=bad), self.assertRaisesRegex(ValueError, "point count"):
build_kamil_adc_neutral_s21_sets(config, point_count=bad)
def test_single_point_sweep(self) -> None:
calibration, _reference = build_kamil_adc_neutral_s21_sets(self._kamil_config(), point_count=1)
for trace in calibration.traces:
self.assertEqual(trace.frequency_hz.tolist(), [1_000_000.0])
self.assertEqual(trace.s21.shape, (1,))
def test_rejects_degenerate_band(self) -> None:
with self.assertRaisesRegex(ValueError, "points must be >= 2"):
build_kamil_adc_neutral_s21_sets(_kamil_config(points=1))
def test_dtypes_are_float32_and_complex64(self) -> None:
calibration, _reference = build_kamil_adc_neutral_s21_sets(self._kamil_config(), point_count=4)
calibration, _reference = build_kamil_adc_neutral_s21_sets(_kamil_config())
trace = calibration.traces[0]
self.assertEqual(trace.frequency_hz.dtype, np.float32)
self.assertEqual(trace.s21.dtype, np.complex64)
@@ -96,7 +75,7 @@ class KamilAdcNeutralPreprocessTest(unittest.TestCase):
def test_calibration_s21_is_a_nonzero_divisor(self) -> None:
# The C++ through-calibrator divides measured/calibration, so calibration S21
# must never be zero — that is the whole point of the '1+0j neutral' contract.
calibration, _reference = build_kamil_adc_neutral_s21_sets(self._kamil_config(), point_count=4)
calibration, _reference = build_kamil_adc_neutral_s21_sets(_kamil_config())
for trace in calibration.traces:
self.assertTrue(bool(np.all(trace.s21 != 0)))
@@ -0,0 +1,172 @@
"""Tests for the Kamil ADC 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
``np.unwrap(np.angle(ref))`` recovers exactly the phase we specify. This mirrors
how a real swept reference behaves (absolute phase anchored at sample 0,
accumulating forward) and lets us reason precisely about the frequency mapping.
"""
from __future__ import annotations
import unittest
import numpy as np
from python_app.hardware_full.kamil_adc.processing import (
KamilAdcProcessingParams,
KamilAdcSweepProcessor,
)
def _reference(phase: np.ndarray, amplitude: float = 1000.0) -> np.ndarray:
"""A reference signal whose unwrapped phase equals ``phase`` (radians)."""
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 = dict(
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 KamilAdcProcessingParams(**{**_CALIBRATION, **overrides}) # type: ignore[arg-type]
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(KamilAdcProcessingParams(**{**_CALIBRATION, **overrides})) # type: ignore[arg-type]
def test_grid_is_fixed_and_identical_across_calls(self) -> None:
processor = self._processor()
grid = processor.grid_hz
self.assertEqual(grid.shape, (11,))
self.assertAlmostEqual(float(grid[0]), 2.5e9)
self.assertAlmostEqual(float(grid[-1]), 3.5e9)
np.testing.assert_array_equal(grid, processor.grid_hz)
def test_frequency_axis_follows_calibration_law(self) -> None:
processor = self._processor()
# A dense ramp 0 -> 50 rad: endpoints map to 2 GHz and 3 GHz.
ref = _reference(np.linspace(0.0, 50.0, 201))
freqs = processor.reference_frequency_axis(ref)
self.assertAlmostEqual(float(freqs[0]), 2.0e9, delta=1.0)
self.assertAlmostEqual(float(freqs[-1]), 3.0e9, delta=1.0)
def test_uses_config_constants_not_sweep_endpoints(self) -> None:
"""Two sweeps with different phase spans map a given absolute phase to the
SAME frequency proving fixed config anchors, not endpoint normalization."""
processor = self._processor()
ref_short = _reference(np.linspace(0.0, 100.0, 401)) # spans phase [0, 100]
ref_long = _reference(np.linspace(0.0, 130.0, 521)) # spans phase [0, 130]
# Encode S(f) = (f - 3 GHz) / 1 GHz, a line in TRUE frequency.
line = lambda ref: ((processor.reference_frequency_axis(ref) - 3.0e9) / 1.0e9) * np.abs(ref)
s_short = processor.process(line(ref_short), ref_short)
s_long = processor.process(line(ref_long), ref_long)
self.assertIsNotNone(s_short)
self.assertIsNotNone(s_long)
expected = (processor.grid_hz.astype(np.float64) - 3.0e9) / 1.0e9
# Endpoint normalization would compress the longer sweep's axis and break this.
np.testing.assert_allclose(s_short.real, expected, atol=1e-3)
np.testing.assert_allclose(s_long.real, expected, atol=1e-3)
np.testing.assert_allclose(s_short.imag, 0.0, atol=1e-3)
def test_amplitude_normalization_divides_by_reference_magnitude(self) -> None:
processor = self._processor()
ref = _reference(np.linspace(0.0, 100.0, 401), amplitude=4.0)
# |main| = 8 everywhere -> |S| = 8 / 4 = 2.
main = 8.0 * np.exp(1j * np.angle(ref))
result = processor.process(main, ref)
self.assertIsNotNone(result)
np.testing.assert_allclose(np.abs(result), 2.0, atol=1e-3)
def test_rejects_sweep_that_does_not_cover_band(self) -> None:
processor = self._processor()
# Phase [0, 40] -> freq [2.0, 2.8] GHz, short of the 3.5 GHz band stop.
ref = _reference(np.linspace(0.0, 40.0, 201))
self.assertIsNone(processor.process(np.ones(201, dtype=np.complex128), ref))
def test_accepts_sweep_that_covers_band(self) -> None:
processor = self._processor()
ref = _reference(np.linspace(0.0, 100.0, 401)) # freq [2.0, 4.0] GHz
result = processor.process(np.abs(ref).astype(np.complex128), ref)
self.assertIsNotNone(result)
self.assertEqual(result.shape, (11,))
self.assertEqual(result.dtype, np.complex64)
np.testing.assert_allclose(np.abs(result), 1.0, atol=1e-3) # main=|ref| -> |S|=1
def test_interpolates_linear_trace_onto_grid(self) -> None:
processor = self._processor()
ref = _reference(np.linspace(0.0, 100.0, 401))
freqs = processor.reference_frequency_axis(ref)
# S(f) = (f - 2.5 GHz) / 1 GHz -> must resample to that same line on the grid.
main = ((freqs - 2.5e9) / 1.0e9) * np.abs(ref)
result = processor.process(main, ref)
self.assertIsNotNone(result)
expected = (processor.grid_hz.astype(np.float64) - 2.5e9) / 1.0e9
np.testing.assert_allclose(result.real, expected, atol=1e-3)
np.testing.assert_allclose(result.imag, 0.0, atol=1e-3)
def test_handles_descending_phase_direction(self) -> None:
# phase 0 -> 2 GHz, phase -100 -> 4 GHz (negative slope). Phase ramp
# 0 -> -100 therefore sweeps frequency UP across the band.
processor = self._processor(phase1_rad=-100.0)
ref = _reference(np.linspace(0.0, -100.0, 401))
result = processor.process(np.abs(ref).astype(np.complex128), ref)
self.assertIsNotNone(result)
self.assertEqual(result.shape, (11,))
np.testing.assert_allclose(np.abs(result), 1.0, atol=1e-3)
def test_rejects_too_few_points(self) -> None:
processor = self._processor()
one = np.ones(1, dtype=np.complex128)
self.assertIsNone(processor.process(one, one))
def test_rejects_length_mismatch(self) -> None:
processor = self._processor()
self.assertIsNone(
processor.process(np.ones(10, dtype=np.complex128), np.ones(9, dtype=np.complex128))
)
def test_drops_zero_amplitude_reference_points(self) -> None:
processor = self._processor()
ref = _reference(np.linspace(0.0, 100.0, 401))
main = np.abs(ref).astype(np.complex128)
ref[10] = 0.0 # vanished reference samples must be ignored, not crash
ref[200] = 0.0
result = processor.process(main, ref)
self.assertIsNotNone(result)
self.assertTrue(np.all(np.isfinite(result.real)))
self.assertTrue(np.all(np.isfinite(result.imag)))
if __name__ == "__main__":
unittest.main()
+152
View File
@@ -0,0 +1,152 @@
"""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 (
MAIN_MARKER,
REFERENCE_MARKER,
KamilAdcStreamParser,
)
def _boundary() -> bytes:
return struct.pack("<HHHH", MAIN_MARKER, 0xFFFF, 0xFFFF, 0xFFFF)
def _main(step: int, real: int, imag: int) -> bytes:
return struct.pack("<HHhh", MAIN_MARKER, step, real, imag)
def _reference(step: int, real: int, imag: int) -> bytes:
return struct.pack("<HHhh", REFERENCE_MARKER, step, real, imag)
class KamilAdcStreamParserTest(unittest.TestCase):
def test_pairs_main_and_reference_by_step(self) -> None:
parser = KamilAdcStreamParser()
stream = (
_boundary()
+ _main(1, 10, -1) + _reference(1, 100, 5)
+ _main(2, 20, -2) + _reference(2, 200, 6)
+ _boundary()
)
sweeps = parser.feed(stream)
self.assertEqual(len(sweeps), 1)
sweep = sweeps[0]
self.assertEqual(sweep.steps.tolist(), [1, 2])
self.assertEqual(sweep.main.tolist(), [complex(10, -1), complex(20, -2)])
self.assertEqual(sweep.reference.tolist(), [complex(100, 5), complex(200, 6)])
def test_keeps_only_steps_present_in_both_channels(self) -> None:
parser = KamilAdcStreamParser()
stream = (
_boundary()
+ _main(1, 10, 0) # main only -> dropped
+ _main(2, 20, 0) + _reference(2, 200, 0) # both -> kept
+ _reference(3, 300, 0) # reference only -> dropped
+ _boundary()
)
(sweep,) = parser.feed(stream)
self.assertEqual(sweep.steps.tolist(), [2])
self.assertEqual(sweep.main.tolist(), [complex(20, 0)])
self.assertEqual(sweep.reference.tolist(), [complex(200, 0)])
def test_orders_by_ascending_step_regardless_of_arrival(self) -> None:
parser = KamilAdcStreamParser()
stream = (
_boundary()
+ _main(3, 3, 0) + _reference(3, 30, 0)
+ _main(1, 1, 0) + _reference(1, 10, 0)
+ _main(2, 2, 0) + _reference(2, 20, 0)
+ _boundary()
)
(sweep,) = parser.feed(stream)
self.assertEqual(sweep.steps.tolist(), [1, 2, 3])
self.assertEqual(sweep.main.real.tolist(), [1, 2, 3])
def test_discards_preroll_before_first_boundary(self) -> None:
parser = KamilAdcStreamParser()
# Garbage + a partial point before the first real boundary must be skipped.
stream = (
_main(7, 7, 7) # pre-roll point (no preceding boundary) -> ignored
+ _boundary()
+ _main(1, 11, 0) + _reference(1, 1, 0)
+ _boundary()
)
(sweep,) = parser.feed(stream)
self.assertEqual(sweep.steps.tolist(), [1])
def test_handles_chunk_splits_across_frames(self) -> None:
parser = KamilAdcStreamParser()
stream = (
_boundary()
+ _main(1, 10, 0) + _reference(1, 100, 0)
+ _main(2, 20, 0) + _reference(2, 200, 0)
+ _boundary()
)
sweeps: list = []
# Feed one byte at a time to exercise reassembly across feed() calls.
for byte in stream:
sweeps.extend(parser.feed(bytes([byte])))
self.assertEqual(len(sweeps), 1)
self.assertEqual(sweeps[0].steps.tolist(), [1, 2])
def test_multiple_sweeps_in_one_feed(self) -> None:
parser = KamilAdcStreamParser()
stream = (
_boundary()
+ _main(1, 1, 0) + _reference(1, 10, 0)
+ _boundary()
+ _main(1, 2, 0) + _reference(1, 20, 0)
+ _boundary()
)
sweeps = parser.feed(stream)
self.assertEqual(len(sweeps), 2)
self.assertEqual(sweeps[0].main.real.tolist(), [1])
self.assertEqual(sweeps[1].main.real.tolist(), [2])
def test_empty_sweep_between_boundaries_is_skipped(self) -> None:
parser = KamilAdcStreamParser()
stream = _boundary() + _boundary() + _main(1, 5, 0) + _reference(1, 1, 0) + _boundary()
sweeps = parser.feed(stream)
self.assertEqual(len(sweeps), 1)
self.assertEqual(sweeps[0].steps.tolist(), [1])
def test_corrupt_marker_raises(self) -> None:
parser = KamilAdcStreamParser()
corrupt = struct.pack("<HHhh", 0x001A, 1, 5, 5) # unknown marker
with self.assertRaisesRegex(ValueError, "protocol violation"):
parser.feed(_boundary() + _main(1, 1, 0) + corrupt + _boundary())
def test_partial_trailing_frame_is_buffered(self) -> None:
parser = KamilAdcStreamParser()
self.assertEqual(parser.feed(_boundary() + _main(1, 1, 0)[:5]), [])
# Supply the rest of the frame plus its reference and the closing boundary.
rest = _main(1, 1, 0)[5:]
(sweep,) = parser.feed(rest + _reference(1, 9, 0) + _boundary())
self.assertEqual(sweep.steps.tolist(), [1])
def test_reset_clears_state(self) -> None:
parser = KamilAdcStreamParser()
parser.feed(_boundary() + _main(1, 1, 0))
parser.reset()
# After reset we must re-align on a fresh boundary before collecting.
sweeps = parser.feed(_main(9, 9, 0) + _boundary() + _main(1, 2, 0) + _reference(1, 2, 0) + _boundary())
self.assertEqual(len(sweeps), 1)
self.assertEqual(sweeps[0].main.real.tolist(), [2])
def test_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()
+102 -174
View File
@@ -1,4 +1,4 @@
"""Tests for Kamil ADC config, frame parsing, TTY reader, and producer wiring."""
"""Tests for the Kamil ADC TTY reader, config round-trip, and producer wiring."""
from __future__ import annotations
@@ -7,40 +7,30 @@ 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_service import (
KamilAdcTtyReader,
_parse_point_frame,
)
from python_app.hardware_full.kamil_adc import KamilAdcService, KamilAdcTtyReader
from python_app.hardware_full.kamil_adc.protocol import MAIN_MARKER, REFERENCE_MARKER
from python_app.models.run_config_model import RunConfigModel
from python_app.orchestration.process_supervisor import ProcessSupervisor
def _start_frame() -> bytes:
return struct.pack("<HHHH", 0x000A, 0xFFFF, 0xFFFF, 0xFFFF)
def _boundary() -> bytes:
return struct.pack("<HHHH", MAIN_MARKER, 0xFFFF, 0xFFFF, 0xFFFF)
def _point_frame(step: int, real: int, imag: int, *, marker: int = 0x000A) -> bytes:
return struct.pack("<HHhh", marker, step, real, imag)
def _main(step: int, real: int, imag: int) -> bytes:
return struct.pack("<HHhh", MAIN_MARKER, step, real, imag)
class ParsePointFrameTest(unittest.TestCase):
def test_parses_valid_point(self) -> None:
value = _parse_point_frame(_point_frame(1, 123, -45), expected_step=1)
self.assertEqual(value, complex(123, -45))
def test_rejects_bad_marker(self) -> None:
with self.assertRaisesRegex(ValueError, "marker mismatch"):
_parse_point_frame(_point_frame(1, 10, 20, marker=0x001A), expected_step=1)
def test_rejects_wrong_step(self) -> None:
with self.assertRaisesRegex(ValueError, "step mismatch"):
_parse_point_frame(_point_frame(2, 10, 20), expected_step=1)
def _reference(step: int, real: int, imag: int) -> bytes:
return struct.pack("<HHhh", REFERENCE_MARKER, step, real, imag)
class KamilAdcTtyReaderTest(unittest.TestCase):
@@ -61,97 +51,68 @@ class KamilAdcTtyReaderTest(unittest.TestCase):
os.close(master_fd)
os.close(slave_fd)
def test_publishes_first_complete_sweep(self) -> None:
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,
_start_frame()
+ _point_frame(1, 10, -1)
+ _point_frame(2, -20, 2)
+ _start_frame(),
_boundary()
+ _main(1, 10, -1) + _reference(1, 100, 5)
+ _main(2, -20, 2) + _reference(2, 200, 6)
+ _boundary(),
)
values = reader.read_sweep(timeout_s=1.0)
self.assertEqual(values.tolist(), [complex(10, -1), complex(-20, 2)])
self.assertEqual(reader.locked_points, 2)
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_consecutive_constant_length_sweeps(self) -> None:
"""Each newly-completed sweep is delivered once new data arrives after a read."""
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:
os.write(
master_fd,
_start_frame()
+ _point_frame(1, 10, -1)
+ _point_frame(2, -20, 2)
+ _start_frame(),
)
# 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.tolist(), [complex(10, -1), complex(-20, 2)])
self.assertEqual(first.steps.tolist(), [1])
os.write(
master_fd,
_point_frame(1, 30, -3) + _point_frame(2, -40, 4) + _start_frame(),
_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.tolist(), [complex(30, -3), complex(-40, 4)])
self.assertEqual(second.steps.tolist(), [1, 2])
finally:
self._close(master_fd, slave_fd, reader)
def test_shorter_sweep_after_lock_raises(self) -> None:
"""A later sweep with fewer points than the locked-in count fails fast."""
def test_only_latest_sweep_is_published(self) -> None:
master_fd, slave_fd, reader = self._open_pty_reader()
try:
os.write(
master_fd,
_start_frame()
+ _point_frame(1, 10, -1)
+ _point_frame(2, -20, 2)
+ _start_frame()
+ _point_frame(1, 30, -3)
+ _start_frame(),
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()
)
first = reader.read_sweep(timeout_s=1.0)
self.assertEqual(first.tolist(), [complex(10, -1), complex(-20, 2)])
with self.assertRaisesRegex(RuntimeError, "sweep length changed"):
reader.read_sweep(timeout_s=1.0)
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_longer_sweep_after_lock_raises(self) -> None:
"""A later sweep with more points than the locked-in count fails fast."""
def test_corrupt_frame_fails_fast(self) -> None:
master_fd, slave_fd, reader = self._open_pty_reader()
try:
os.write(
master_fd,
_start_frame()
+ _point_frame(1, 10, -1)
+ _start_frame()
+ _point_frame(1, 30, -3)
+ _point_frame(2, -40, 4)
+ _start_frame(),
)
first = reader.read_sweep(timeout_s=1.0)
self.assertEqual(first.tolist(), [complex(10, -1)])
with self.assertRaisesRegex(RuntimeError, "exceeded locked point count"):
reader.read_sweep(timeout_s=1.0)
finally:
self._close(master_fd, slave_fd, reader)
def test_corrupt_frame_fails_fast_without_resync(self) -> None:
"""A garbage frame (bad marker) mid-stream surfaces on read; the reader does
NOT silently resync fail-fast lets the producer die and the supervisor relaunch."""
master_fd, slave_fd, reader = self._open_pty_reader()
try:
os.write(
master_fd,
_start_frame()
+ _point_frame(1, 10, -1)
+ _point_frame(2, 5, 5, marker=0x001A) # corrupt marker (not 0x000A)
+ _start_frame(),
)
corrupt = struct.pack("<HHhh", 0x001A, 1, 5, 5) # unknown marker
os.write(master_fd, _boundary() + _main(1, 10, -1) + corrupt + _boundary())
with self.assertRaises((ValueError, RuntimeError)):
reader.read_sweep(timeout_s=1.0)
finally:
@@ -160,44 +121,12 @@ class KamilAdcTtyReaderTest(unittest.TestCase):
def test_no_completed_sweep_times_out(self) -> None:
master_fd, slave_fd, reader = self._open_pty_reader()
try:
# Start marker plus a partial sweep with no follow-up boundary.
os.write(master_fd, _start_frame() + _point_frame(1, 10, -1))
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_only_latest_sweep_is_published(self) -> None:
"""If multiple sweeps arrive before the consumer reads, only the newest survives."""
master_fd, slave_fd, reader = self._open_pty_reader()
try:
payload = (
_start_frame()
+ _point_frame(1, 1, 0)
+ _point_frame(2, 2, 0)
+ _start_frame()
+ _point_frame(1, 3, 0)
+ _point_frame(2, 4, 0)
+ _start_frame()
+ _point_frame(1, 5, 0)
+ _point_frame(2, 6, 0)
+ _start_frame()
)
os.write(master_fd, payload)
# Wait until the reader thread has parsed all three sweeps before
# reading from the mailbox — otherwise we'd race the producer and
# might consume an intermediate value.
deadline = time.monotonic() + 1.0
while time.monotonic() < deadline and reader.published_count < 3:
time.sleep(0.005)
self.assertGreaterEqual(reader.published_count, 3)
values = reader.read_sweep(timeout_s=1.0)
# The reader thread overwrites unread sweeps; the consumer sees the
# most recently completed one.
self.assertEqual(values.tolist(), [complex(5, 0), complex(6, 0)])
finally:
self._close(master_fd, slave_fd, reader)
class KamilAdcConfigTest(unittest.TestCase):
def test_config_round_trip_preserves_kamil_sections(self) -> None:
@@ -207,85 +136,84 @@ class KamilAdcConfigTest(unittest.TestCase):
"serial": "kamil_adc",
"driver_mode": "native",
"kamil_adc": {
"project_dir": "/home/europa/Documents/kamil_adc",
"executable_path": "/home/europa/Documents/kamil_adc/kamil_adc_capture",
"project_dir": "",
"executable_path": "build/bin/kamil_adc_collector",
"tty_path": "/tmp/ttyADC_data",
"args": ["profile:phase", "do1_pair_subtract_avg"],
"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,
},
"laser_control": {
"enabled": True,
"port": "/dev/ttyUSB0",
"mode": "variation",
"pi_coeff1_p": 2560,
"pi_coeff1_i": 128,
"pi_coeff2_p": 2600,
"pi_coeff2_i": 140,
"manual": {
"temp1": 26.0,
"temp2": 27.0,
"current1": 31.0,
"current2": 32.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,
},
"variation": {
"variation_type": "CHANGE_CURRENT_LD2",
"static_temp1": 28.0,
"static_temp2": 29.0,
"static_current1": 33.0,
"static_current2": 34.0,
"min_value": 30.0,
"max_value": 40.0,
"step": 0.5,
"time_step": 50,
"delay_time": 10,
"band": {
"start_hz": 2_100_000_000.0,
"stop_hz": 5_500_000_000.0,
"points": 1024,
},
},
"sweep": {
"start_hz": 1.0,
"stop_hz": 2.0,
"points": 2,
"if_bandwidth_hz": 1.0,
"stimulus_power_dbm": -10.0,
},
},
"switches": {
"port1": {"positions": 1},
"port2": {"positions": 1},
"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.assertNotIn("points", encoded["radar"]["sweep"])
self.assertEqual(encoded["radar"]["kamil_adc"]["tty_path"], "/tmp/ttyADC_data")
self.assertEqual(encoded["radar"]["kamil_adc"]["args"], ["profile:phase", "do1_pair_subtract_avg"])
self.assertEqual(encoded["radar"]["kamil_adc"]["env"], {"ADC_ENV": "1"})
self.assertEqual(encoded["radar"]["laser_control"]["mode"], "variation")
self.assertEqual(
encoded["radar"]["laser_control"]["variation"]["variation_type"],
"CHANGE_CURRENT_LD2",
)
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)
class _UnreapableProcess:
pid = 2_000_000_000 # implausible; killpg is patched out below anyway
def poll(self) -> None:
return None # always "alive"
def wait(self, timeout: float | None = None) -> int:
raise subprocess.TimeoutExpired(cmd="kamil_adc_collector", timeout=timeout)
service._process = _UnreapableProcess() # type: ignore[assignment]
with mock.patch("python_app.hardware_full.kamil_adc.service.os.killpg"):
service.close() # 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),
],
[sys.executable, "-m", "python_app.scripts.kamil_adc_raw_producer", "--config", str(config_path)],
)
@@ -8,7 +8,7 @@ from unittest.mock import 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_service import apply_kamil_adc_laser_control
from python_app.hardware_full.kamil_adc import apply_kamil_adc_laser_control
from python_app.models.run_config_model import RunConfigModel
@@ -7,6 +7,10 @@ import time
import numpy as np
from python_app.hardware_full.kamil_adc import (
KamilAdcProcessingParams,
KamilAdcSweepProcessor,
)
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.models.run_config_model import ComboModel, RunConfigModel
@@ -15,33 +19,30 @@ logger = logging.getLogger(__name__)
def build_kamil_adc_neutral_s21_sets(
config: RunConfigModel,
point_count: int,
) -> tuple[SweepCollection, SweepCollection]:
"""Build neutral S21 calibration/reference collections for the Kamil ADC radar.
The calibration uses unit S21 (1+0j) and the reference uses zero S21 across
every configured combo, so applying them in the preprocessing pipeline leaves
the input S21 unchanged. Returns the ``(calibration, reference)`` collections.
the input S21 unchanged. The frequency axis is the exact acquisition grid
(``radar.kamil_adc.band``), so neutral sets line up sample-for-sample with
live sweeps. Returns the ``(calibration, reference)`` collections.
"""
if not config.is_kamil_adc:
raise ValueError("Neutral Kamil ADC sets require radar.model='kamil_adc'")
points = int(point_count)
if points <= 0:
raise ValueError("Kamil ADC neutral set point count must be > 0")
combos = list(config.combos)
if not combos:
combos = RunConfigModel.build_full_combos(config.input_switch.positions, config.output_switch.positions)
combos = RunConfigModel.build_full_combos(
config.input_switch.positions, config.output_switch.positions
)
if not combos:
raise ValueError("Kamil ADC neutral sets require at least one switch combo")
frequency_hz = np.linspace(
float(config.radar.sweep.start_hz),
float(config.radar.sweep.stop_hz),
points,
dtype=np.float32,
)
# Single source of truth for the axis: the same grid the processor emits.
processor = KamilAdcSweepProcessor(KamilAdcProcessingParams.from_kamil_model(config.radar.kamil_adc))
frequency_hz = processor.grid_hz
now_ns = time.monotonic_ns()
calibration = _neutral_collection(
combos=combos,
@@ -55,7 +56,9 @@ def build_kamil_adc_neutral_s21_sets(
s21_value=np.complex64(0.0 + 0.0j),
monotonic_ns=now_ns,
)
logger.info("Built neutral Kamil ADC S21 sets: combos=%d points=%d", len(combos), points)
logger.info(
"Built neutral Kamil ADC S21 sets: combos=%d points=%d", len(combos), int(frequency_hz.size)
)
return calibration, reference
@@ -66,17 +69,16 @@ def _neutral_collection(
s21_value: np.complex64,
monotonic_ns: int,
) -> SweepCollection:
traces = []
for combo in combos:
point_count = int(frequency_hz.size)
traces.append(
TraceData(
combo=ComboKey(input=int(combo.input), output=int(combo.output)),
frequency_hz=frequency_hz.copy(),
s11=np.zeros(point_count, dtype=np.complex64),
s21=np.full(point_count, s21_value, dtype=np.complex64),
)
point_count = int(frequency_hz.size)
traces = [
TraceData(
combo=ComboKey(input=int(combo.input), output=int(combo.output)),
frequency_hz=frequency_hz.copy(),
s11=np.zeros(point_count, dtype=np.complex64),
s21=np.full(point_count, s21_value, dtype=np.complex64),
)
for combo in combos
]
return SweepCollection(
collection_id=1,
monotonic_ns=monotonic_ns,
@@ -115,28 +115,8 @@ class MultiRadarSequentialCaptureSession:
self._output_switch = None
else:
self._radar = create_single_radar_service(base_config)
self._input_switch = SwitchService(
name=base_config.input_switch.name,
positions=base_config.input_switch.positions,
default_position=base_config.input_switch.default_position,
mode=base_config.input_switch.driver_mode,
driver=base_config.input_switch.driver,
gpio_chip=base_config.input_switch.gpio_chip,
pin_a=base_config.input_switch.pin_a,
pin_b=base_config.input_switch.pin_b,
invert_logic=base_config.input_switch.invert_logic,
)
self._output_switch = SwitchService(
name=base_config.output_switch.name,
positions=base_config.output_switch.positions,
default_position=base_config.output_switch.default_position,
mode=base_config.output_switch.driver_mode,
driver=base_config.output_switch.driver,
gpio_chip=base_config.output_switch.gpio_chip,
pin_a=base_config.output_switch.pin_a,
pin_b=base_config.output_switch.pin_b,
invert_logic=base_config.output_switch.invert_logic,
)
self._input_switch = SwitchService.from_model(base_config.input_switch)
self._output_switch = SwitchService.from_model(base_config.output_switch)
@property
def kind(self) -> str:
@@ -91,28 +91,8 @@ class SequentialCaptureSession:
self._output_switch = None
else:
self._radar = create_single_radar_service(config)
self._input_switch = SwitchService(
name=config.input_switch.name,
positions=config.input_switch.positions,
default_position=config.input_switch.default_position,
mode=config.input_switch.driver_mode,
driver=config.input_switch.driver,
gpio_chip=config.input_switch.gpio_chip,
pin_a=config.input_switch.pin_a,
pin_b=config.input_switch.pin_b,
invert_logic=config.input_switch.invert_logic,
)
self._output_switch = SwitchService(
name=config.output_switch.name,
positions=config.output_switch.positions,
default_position=config.output_switch.default_position,
mode=config.output_switch.driver_mode,
driver=config.output_switch.driver,
gpio_chip=config.output_switch.gpio_chip,
pin_a=config.output_switch.pin_a,
pin_b=config.output_switch.pin_b,
invert_logic=config.output_switch.invert_logic,
)
self._input_switch = SwitchService.from_model(config.input_switch)
self._output_switch = SwitchService.from_model(config.output_switch)
@property
def kind(self) -> str:
@@ -13,14 +13,39 @@
"recovery_attempts": 3
},
"kamil_adc": {
"project_dir": "/home/europa/Documents/kamil_adc",
"executable_path": "/home/europa/Documents/kamil_adc/run_do1_pair_subtract_avg.sh",
"project_dir": "",
"executable_path": "build/bin/kamil_adc_collector",
"tty_path": "/tmp/ttyADC_data",
"args": [],
"args": [
"profile:phase",
"clock:internal",
"internal_ref_hz:2000000",
"start:di_syn2_rise",
"stop:di_syn2_fall",
"sample_clock_hz:max",
"range:2",
"duration_ms:100",
"packet_limit:0",
"do1_toggle_per_frame",
"do1_pair_subtract_avg",
"do8_freq_ref",
"do8_cycle_period:8"
],
"env": {},
"startup_timeout_s": 5.0,
"sweep_timeout_s": 15.0,
"stop_timeout_s": 2.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": true,
+91 -32
View File
@@ -18,18 +18,21 @@ LOCK_FILE="/tmp/radar_system.lock"
SKIP_BUILD=0
BUILD_ONLY=0
CLEAN_SHM=0
KAMIL_ADC_MODE=0
AUTO_START=0
PRODUCER_ONLY=0
HEADLESS=0
# Acquisition device, detected from the active run config's radar.model. Drives
# device-specific provisioning, dependencies, and which collector binary to build
# — so every device launches the same way (no per-device flags).
RADAR_MODEL=""
print_usage() {
cat <<'EOF'
Usage: ./start.sh [options]
Options:
--kamil-adc Use the Raspberry Pi Kamil ADC profile
--profile PATH Use a specific GUI/run config profile
--profile PATH Use a specific GUI/run config profile (device is auto-detected
from its radar.model)
--auto-start Start the GUI pipeline automatically after launch
--headless Run without a display (Qt offscreen platform) and apply
the active radar config, then start the pipeline. Suitable
@@ -55,9 +58,6 @@ parse_args() {
--clean-shm)
CLEAN_SHM=1
;;
--kamil-adc)
KAMIL_ADC_MODE=1
;;
--profile)
if (($# < 2)); then
echo "--profile requires a path argument." >&2
@@ -100,16 +100,6 @@ absolute_path() {
}
resolve_profile_path() {
if ((KAMIL_ADC_MODE == 1)); then
if [[ -z "${PROFILE_PATH}" ]]; then
if [[ -f "${PROJECT_ROOT}/run_config_kamil_adc.pi.json" ]]; then
PROFILE_PATH="${PROJECT_ROOT}/run_config_kamil_adc.pi.json"
else
PROFILE_PATH="${PROJECT_ROOT}/run_config_kamil_adc.example.json"
fi
fi
fi
if [[ -n "${PROFILE_PATH}" ]]; then
PROFILE_PATH="$(absolute_path "${PROFILE_PATH}")"
if [[ ! -f "${PROFILE_PATH}" ]]; then
@@ -119,6 +109,44 @@ resolve_profile_path() {
fi
}
# Resolve the config that will actually be used (explicit --profile, else the
# active run_config.json) and read its radar.model. Best-effort: any failure
# falls back to 'librevna' (the full-provisioning superset), so detection can
# never make a launch less safe. Uses system python3 (the venv may not exist yet).
detect_radar_model() {
local config_path="${PROFILE_PATH:-${PROJECT_ROOT}/run_config.json}"
RADAR_MODEL="librevna"
[[ -f "${config_path}" ]] || return
local detected
detected="$(python3 -c '
import json, sys
try:
with open(sys.argv[1]) as handle:
data = json.load(handle)
radar = data.get("radar") if isinstance(data, dict) else {}
model = radar.get("model") if isinstance(radar, dict) else None
print(model or "librevna")
except Exception:
print("librevna")
' "${config_path}" 2>/dev/null)" || detected=""
[[ -n "${detected}" ]] && RADAR_MODEL="${detected}"
echo "[start.sh] Detected radar model: ${RADAR_MODEL} (config: ${config_path})"
}
# Acquisition producer for the active model, mirroring the process supervisor's
# selection so --producer-only behaves identically to a full pipeline launch.
producer_command() {
local config_path="$1"
case "${RADAR_MODEL}" in
kamil_adc)
printf '%s\0' "${PYTHON_CMD}" -m python_app.scripts.kamil_adc_raw_producer --config "${config_path}" ;;
librevna_multi|sn9000)
printf '%s\0' "${PYTHON_CMD}" -m python_app.scripts.matrix_raw_producer --config "${config_path}" ;;
*)
printf '%s\0' "${PROJECT_ROOT}/build/bin/sweep_orchestrator" --config "${config_path}" ;;
esac
}
check_environment() {
if ! command -v python3 >/dev/null 2>&1; then
echo "python3 is not installed or not found in PATH." >&2
@@ -138,7 +166,8 @@ check_environment() {
ensure_python_dependencies() {
local dependency_check
if ((KAMIL_ADC_MODE == 1)); then
if [[ "${RADAR_MODEL}" == "kamil_adc" ]]; then
# Kamil ADC has no VISA dependency (it talks to its own L-Card collector).
dependency_check='import numpy, serial, PyQt6, pyqtgraph, usb1'
else
dependency_check='import numpy, serial, PyQt6, pyqtgraph, usb1, pyvisa'
@@ -260,14 +289,20 @@ EOF
}
build_cpp_binaries() {
if make -C "${PROJECT_ROOT}" -q all >/dev/null 2>&1; then
local jobs
jobs="${BUILD_JOBS:-$(nproc)}"
# The Kamil ADC 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"
fi
if make -C "${PROJECT_ROOT}" -q ${targets} >/dev/null 2>&1; then
echo "[start.sh] C++ binaries are up to date; skipping build."
return
fi
local jobs
jobs="${BUILD_JOBS:-$(nproc)}"
echo "[start.sh] Building C++ binaries (jobs=${jobs})..."
make -C "${PROJECT_ROOT}" -j"${jobs}" all
echo "[start.sh] Building C++ binaries (jobs=${jobs}, targets: ${targets})..."
make -C "${PROJECT_ROOT}" -j"${jobs}" ${targets}
}
cleanup_known_shm() {
@@ -286,6 +321,17 @@ cleanup_known_shm() {
|| true
}
kill_stale_adc_collector() {
# The L-Card ADC collector runs in its own session and can outlive a crashed
# or force-killed run, holding the E-502 device and hanging the next start.
# Kill any leftover hard before launching so acquisition always opens cleanly.
if command -v pkill >/dev/null 2>&1; then
if pkill -9 -f kamil_adc_collector 2>/dev/null; then
echo "[start.sh] Killed leftover ADC collector process(es)."
fi
fi
}
run_gui() {
if [[ -n "${PROFILE_PATH}" ]]; then
export RADAR_SYSTEM_PROFILE="${PROFILE_PATH}"
@@ -311,18 +357,20 @@ run_gui() {
}
run_producer_only() {
if ((KAMIL_ADC_MODE != 1)); then
echo "--producer-only currently requires --kamil-adc." >&2
exit 1
fi
if [[ -z "${PROFILE_PATH}" ]]; then
echo "--producer-only requires a resolved config profile." >&2
local config_path="${PROFILE_PATH:-${PROJECT_ROOT}/run_config.json}"
if [[ ! -f "${config_path}" ]]; then
echo "--producer-only needs a config (pass --profile, or create run_config.json)." >&2
exit 1
fi
export PYTHONPATH="${PROJECT_ROOT}${PYTHONPATH:+:${PYTHONPATH}}"
echo "[start.sh] Starting Kamil ADC raw producer with profile: ${PROFILE_PATH}"
exec "${PYTHON_CMD}" -m python_app.scripts.kamil_adc_raw_producer --config "${PROFILE_PATH}"
# Run the acquisition producer the supervisor would pick for this model.
local -a command=()
while IFS= read -r -d '' token; do
command+=("${token}")
done < <(producer_command "${config_path}")
echo "[start.sh] Starting ${RADAR_MODEL} producer: ${command[*]}"
exec "${command[@]}"
}
stop_headless_service() {
@@ -345,7 +393,13 @@ verify_cpp_binaries() {
# Guard the daemon's --skip-build path: refuse to run against a tree whose C++
# binaries were never built, instead of failing obscurely at spawn time.
local missing=0 bin
for bin in data_processor data_preprocessor; do
local required_bins="data_processor data_preprocessor"
# Kamil ADC also needs its acquisition collector; other models use the C++
# sweep_orchestrator, which `all` always builds.
if [[ "${RADAR_MODEL}" == "kamil_adc" ]]; then
required_bins="${required_bins} kamil_adc_collector"
fi
for bin in ${required_bins}; do
if [[ ! -x "${PROJECT_ROOT}/build/bin/${bin}" ]]; then
echo "Required binary missing or not executable: build/bin/${bin}" >&2
missing=1
@@ -373,10 +427,13 @@ main() {
parse_args "$@"
resolve_profile_path
check_environment
detect_radar_model
# Skip first-time provisioning (build headers, USB udev rule) in headless
# mode: the daemon runs unattended at boot as a non-root user and must not
# block on sudo. A fresh machine is provisioned by one interactive launch.
if ((KAMIL_ADC_MODE == 0 && HEADLESS == 0)); then
# Also skip it for non-LibreVNA devices (e.g. Kamil ADC), which neither use
# libusb directly nor need the LibreVNA USB access rule.
if [[ "${RADAR_MODEL}" != "kamil_adc" ]] && ((HEADLESS == 0)); then
ensure_system_dependencies
ensure_usb_access_rules
fi
@@ -404,6 +461,8 @@ main() {
stop_headless_service
fi
acquire_single_instance_lock
# 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