some kamil_adc fixes

This commit is contained in:
Ayzen
2026-07-02 17:44:24 +03:00
parent 42532c9868
commit 3efe968dd1
10 changed files with 644 additions and 42 deletions
@@ -0,0 +1,147 @@
#pragma once
#include <cstddef>
#include <memory>
#include <utility>
#include <vector>
#include "shared_types.hpp"
#include "switch_driver.hpp"
namespace radar::kamil {
/**
* @brief Drives the input/output RF switches in lock-step with sweep boundaries.
*
* The E-502 stream is free-running: sweeps are delimited by DI_SYN2 edges with a
* hardware idle gap between them. On every completed sweep we step to the next
* switch combination *during that gap*, so the next sweep starts already settled
* in the new state and no samples are lost.
*
* The one failure mode of doing this purely in software is the readout backlog:
* if, at the moment we observe a sweep's end, we are running so far behind real
* time that the toggle could not have landed within the gap, the upcoming sweep
* straddles the transition. We flag that sweep *dirty*; the consumer drops it and
* we re-take the same combination on the following sweep (which is then clean,
* because no transition happens during its gap). A clean sweep costs nothing; a
* miss costs exactly one extra sweep period for that one combination.
*
* The sequencer owns the two switch drivers and exposes only what the collector
* needs at a sweep boundary: which combination the upcoming sweep belongs to and
* whether it is expected to be clean. All timing policy lives here so the recv
* loop stays readable.
*/
class SwitchSequencer {
public:
struct Settings {
/// Combinations to cycle through, one sweep each, in order. Empty disables
/// the sequencer entirely (the collector then streams a single passthrough
/// channel exactly as before).
std::vector<radar::ipc::ComboKey> combos{};
/// Guaranteed minimum inter-sweep idle window, in milliseconds. This is a
/// hardware property of the sweep generator; treat it as a lower bound.
double gap_ms = 10.0;
/// RF settle time required after a position change, in milliseconds.
double settle_ms = 1.0;
};
SwitchSequencer(
Settings settings,
std::unique_ptr<drivers::SwitchDriver> input_switch,
std::unique_ptr<drivers::SwitchDriver> output_switch
)
: settings_(std::move(settings)),
input_switch_(std::move(input_switch)),
output_switch_(std::move(output_switch)) {}
[[nodiscard]] auto enabled() const -> bool { return !settings_.combos.empty(); }
/// Open both switch drivers and park at the first combination.
void open() {
if (!enabled()) {
return;
}
output_switch_->open();
input_switch_->open();
index_ = 0;
applied_ = settings_.combos.front();
apply(applied_);
upcoming_dirty_ = false; // the first sweep is captured in a settled state
}
void close() {
if (!enabled()) {
return;
}
// Best-effort: teardown must never throw out of the collector's stop path.
try {
output_switch_->close();
} catch (...) {
}
try {
input_switch_->close();
} catch (...) {
}
}
/// Combination the in-progress / upcoming sweep is captured under.
[[nodiscard]] auto current_combo() const -> radar::ipc::ComboKey {
return settings_.combos[index_];
}
/// Whether the in-progress / upcoming sweep is expected to straddle a switch
/// transition and must be dropped by the consumer.
[[nodiscard]] auto current_dirty() const -> bool { return upcoming_dirty_; }
/**
* @brief Advance the sequence at a completed sweep's end (start of the gap).
*
* @param backlog_ms Estimated readout lag at this instant — how far behind
* real time we are, i.e. how late the toggle we issue now will land.
*
* If the sweep that just ended was clean we step to the next combination and
* toggle the switches now, inside the gap. If it was dirty we re-take the same
* combination (the switches are already there and long settled). The upcoming
* sweep is marked dirty only when a real transition happens *and* the backlog
* leaves no room for it to settle before the next sweep starts.
*/
void on_sweep_end(double backlog_ms) {
if (!enabled()) {
return;
}
if (upcoming_dirty_) {
// The sweep that just ended straddled a transition: re-take the same
// combination. The switches are already applied and settled, so the
// next sweep is clean without touching the hardware again.
upcoming_dirty_ = false;
return;
}
index_ = (index_ + 1U) % settings_.combos.size();
const radar::ipc::ComboKey next = settings_.combos[index_];
const bool position_changed = !(next == applied_);
if (position_changed) {
apply(next);
applied_ = next;
}
upcoming_dirty_ = position_changed && ((backlog_ms + settings_.settle_ms) > settings_.gap_ms);
}
private:
void apply(const radar::ipc::ComboKey& combo) {
// Output first, then input — same order the sweep orchestrator uses, so
// both acquisition paths drive an identical hardware sequence.
output_switch_->switch_to(combo.output_pos);
input_switch_->switch_to(combo.input_pos);
}
Settings settings_;
std::unique_ptr<drivers::SwitchDriver> input_switch_;
std::unique_ptr<drivers::SwitchDriver> output_switch_;
std::size_t index_ = 0;
radar::ipc::ComboKey applied_{};
bool upcoming_dirty_ = false;
};
} // namespace radar::kamil
@@ -17,6 +17,11 @@
#include "capture_file_writer.h"
#include "tty_protocol_writer.h"
#include "switch_sequencer.h"
#include "run_config.hpp"
#include "h7992_minimal_driver.hpp"
#include "hmc349a_minimal_driver.hpp"
#include <algorithm>
#include <array>
#include <atomic>
@@ -113,6 +118,12 @@ struct Config {
std::string live_html_path = "live_plot.html";
std::string live_json_path = "live_plot.json";
std::optional<std::string> tty_path;
// When set, the collector reads switch and combo configuration from this
// run_config.json and drives the RF switches itself, one combination per
// sweep, tagging each emitted sweep with its combo (see SwitchSequencer).
std::optional<std::string> run_config_path;
double switch_gap_ms = 10.0;
double switch_settle_ms = 1.0;
bool di1_group_average = false;
bool do1_toggle_per_frame = false;
bool do1_noise_subtract = false;
@@ -479,6 +490,7 @@ void print_help(const char* exe_name) {
<< " [di1:zero|trace|ignore]\n"
<< " [duration_ms:100] [packet_limit:0] [csv:capture.csv] [svg:capture.svg]\n"
<< " [live_html:live_plot.html] [live_json:live_plot.json] [tty:/tmp/ttyADC_data] [di1_group_avg]\n"
<< " [config:run_config.json] [switch_gap_ms:10] [switch_settle_ms:1]\n"
<< " [do1_toggle_per_frame] [do1_noise_subtract] [do1_raw_tty_marked] [do1_pair_subtract_avg] [noise_avg_steps:N]\n"
<< " [do8_freq_ref] [do8_cycle_period:10] [do8_threshold:X]\n"
<< " [recv_block:32768] [stats_period_ms:1000] [live_update_period_ms:1000] [svg_history_packets:50] [start_wait_ms:10000]\n"
@@ -514,6 +526,13 @@ void print_help(const char* exe_name) {
<< " step_words:32768 -> input stream transfer step in 32-bit words\n"
<< " live_html/live_json -> live graph files updated as packets arrive outside tty fast stream-only modes\n"
<< " tty:/tmp/ttyADC_data -> write a continuous legacy 4-word CH1/CH2 stream; with channels:1, CH2 is 0\n"
<< " config:run_config.json -> switch-aware mode: read the input/output switch and combo\n"
<< " configuration from this run_config.json and drive the RF switches\n"
<< " in lock-step with sweeps; each sweep is tagged with its combo via a\n"
<< " 0x00C0 frame (input_pos, output_pos, dirty). Omit for standalone mode.\n"
<< " switch_gap_ms:10 -> guaranteed minimum inter-sweep idle window; lower bound used to decide\n"
<< " whether an in-gap switch lands before the next sweep starts\n"
<< " switch_settle_ms:1 -> RF settle time required after a switch position change\n"
<< " di1_group_avg -> with tty + di1:trace, emit one averaged 4-word step per constant DI1 run\n"
<< " do1_toggle_per_frame -> hardware cyclic DO1 pattern in module memory:\n"
<< " DO1 outputs 00110011... continuously (toggle every 2 ADC ticks)\n"
@@ -793,6 +812,18 @@ Config parse_args(int argc, char** argv) {
cfg.tty_path = arg.substr(4);
continue;
}
if (starts_with(arg, "config:")) {
cfg.run_config_path = arg.substr(7);
continue;
}
if (starts_with(arg, "switch_gap_ms:")) {
cfg.switch_gap_ms = parse_double(arg.substr(14), "switch_gap_ms");
continue;
}
if (starts_with(arg, "switch_settle_ms:")) {
cfg.switch_settle_ms = parse_double(arg.substr(17), "switch_settle_ms");
continue;
}
fail("Unknown argument: " + arg);
}
@@ -1203,6 +1234,10 @@ constexpr uint32_t kDo1TogglePeriodTicks = 2U;
constexpr uint32_t kDo1CyclePatternWords = kDo1TogglePeriodTicks * 2U;
constexpr uint32_t kDo8HighTicks = 2U;
constexpr uint16_t kTtyMarkerDi8High = 0x00A8U;
// Combo tag frame: [0x00C0, input_pos, output_pos, dirty]. Emitted right after a
// sweep-boundary frame to tell the consumer which switch combination the upcoming
// sweep belongs to, and whether it straddled a switch transition (dirty != 0).
constexpr uint16_t kTtyMarkerComboTag = 0x00C0U;
constexpr uint32_t kStreamInputAdcFlag = 0x80000000U;
constexpr uint32_t kStreamInputCalibratedAdcFlag = 0x40000000U;
@@ -1802,6 +1837,55 @@ void print_device_info(const t_x502_info& info) {
<< "MCU firmware: " << info.mcu_firmware_ver << "\n";
}
// Build one switch driver (native GPIO or mock) from a parsed switch config,
// reusing the exact driver implementations the sweep orchestrator uses so both
// acquisition paths drive identical hardware.
std::unique_ptr<radar::drivers::SwitchDriver> make_switch_driver(const radar::config::SwitchConfig& sc) {
if (sc.driver_kind == radar::config::SwitchDriverKind::HMC349A) {
return std::make_unique<radar::drivers::HMC349AMinimalDriver>(
radar::drivers::HMC349AMinimalDriverSettings{
.name = sc.name,
.mode = sc.driver_mode,
.positions = sc.positions,
.default_position = sc.default_position,
.gpio_chip = sc.gpio_chip,
.pin_a = sc.pin_a,
.invert_logic = sc.invert_logic,
});
}
return std::make_unique<radar::drivers::H7992MinimalDriver>(
radar::drivers::H7992MinimalDriverSettings{
.name = sc.name,
.mode = sc.driver_mode,
.positions = sc.positions,
.default_position = sc.default_position,
.gpio_chip = sc.gpio_chip,
.pin_a = sc.pin_a,
.pin_b = sc.pin_b,
});
}
// Construct the switch sequencer from the run_config given via `config:<path>`.
// Returns nullptr (switching disabled, single-channel passthrough) when no config
// was supplied or it declares no combinations.
std::unique_ptr<radar::kamil::SwitchSequencer> make_switch_sequencer(const Config& cfg) {
if (!cfg.run_config_path.has_value()) {
return nullptr;
}
const radar::config::RunConfig rc = radar::config::load_run_config(*cfg.run_config_path);
if (rc.run_combos.empty()) {
return nullptr;
}
radar::kamil::SwitchSequencer::Settings settings;
settings.combos = rc.run_combos;
settings.gap_ms = cfg.switch_gap_ms;
settings.settle_ms = cfg.switch_settle_ms;
return std::make_unique<radar::kamil::SwitchSequencer>(
std::move(settings),
make_switch_driver(rc.input_switch),
make_switch_driver(rc.output_switch));
}
int run(const Config& cfg) {
Api api;
DeviceHandle device(api);
@@ -2029,6 +2113,21 @@ int run(const Config& cfg) {
tty_writer->emit_packet_start(tty_packet_start_marker);
}
}
// Switch sequencer (optional): drives the RF switches in lock-step with sweep
// boundaries and tags each sweep with its combination. Disabled (nullptr) when
// no run_config was passed, so the standalone/calibration use stays unchanged.
std::unique_ptr<radar::kamil::SwitchSequencer> sequencer = make_switch_sequencer(cfg);
if (sequencer && sequencer->enabled()) {
sequencer->open();
std::cout << "Switch sequencer enabled: cycling switch combinations per sweep "
<< "(gap_ms=" << cfg.switch_gap_ms << ", settle_ms=" << cfg.switch_settle_ms << ")\n";
} else {
sequencer.reset(); // normalize to nullptr so call sites can test the pointer
}
// Estimated readout backlog (ms) at the current loop iteration; how far behind
// real time we are, used to decide whether an in-gap switch lands in time.
double current_backlog_ms = 0.0;
std::unique_ptr<CaptureFileWriter> writer;
if (!fast_tty_avg_stream_mode) {
writer = std::make_unique<CaptureFileWriter>(cfg.csv_path, cfg.svg_path, cfg.live_html_path, cfg.live_json_path);
@@ -2317,6 +2416,15 @@ int run(const Config& cfg) {
auto append_tty_packet_start = [&]() {
append_tty_frame(tty_packet_start_marker, 0xFFFF, 0xFFFF, 0xFFFF);
// Right after the boundary, tag the upcoming sweep with its switch combo so
// the consumer can group sweeps and drop the dirty ones.
if (sequencer) {
const radar::ipc::ComboKey combo = sequencer->current_combo();
append_tty_frame(kTtyMarkerComboTag,
static_cast<uint16_t>(combo.input_pos),
static_cast<uint16_t>(combo.output_pos),
static_cast<uint16_t>(sequencer->current_dirty() ? 1U : 0U));
}
};
auto append_tty_group_step = [&]() {
@@ -2714,6 +2822,12 @@ int run(const Config& cfg) {
std::cout << "\n";
}
// A real sweep just completed: step the switch sequence into the gap. A
// user-stop teardown is not a sweep boundary, so skip it.
if (sequencer && (frames != 0U) && (reason != PacketCloseReason::UserStop)) {
sequencer->on_sweep_end(current_backlog_ms);
}
packet_active = false;
packet_avg_steps = 0;
fast_packet_frames = 0;
@@ -2768,6 +2882,11 @@ int run(const Config& cfg) {
recv_request_words = std::min<uint32_t>(ready_words, read_capacity_words);
recv_timeout_ms = 0;
}
// Backlog already queued in the driver = how far behind real time we are.
// Drives the switch sequencer's in-gap clean/dirty decision.
if ((ready_err == X502_ERR_OK) && (combined_input_rate_hz > 0.0)) {
current_backlog_ms = 1000.0 * static_cast<double>(ready_words) / combined_input_rate_hz;
}
const int32_t recvd = api.Recv(device.hnd, raw.data(), recv_request_words, recv_timeout_ms);
if (recvd < 0) {
@@ -3170,6 +3289,11 @@ int run(const Config& cfg) {
expect_ok(api, api.StreamsStop(device.hnd), "Stop streams");
device.streams_started = false;
// Park the switches in their safe default and release the GPIO lines.
if (sequencer) {
sequencer->close();
}
if (cfg.do1_toggle_per_frame) {
const uint32_t clear_mask = kE502Do1Mask | kE502Do2Mask | (cfg.do8_freq_ref ? kE502Do8Mask : 0U);
expect_ok(api,