From 3870ca208e64c34c56fe35c6c0b5a2cc3f11d4fd Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sat, 4 Jul 2026 21:17:24 -0400 Subject: [PATCH 1/8] feat(seq): add sequencer program types and validation (native-tested) Phase A Task A1 of the firmware-v2 plan (AI-docs 2026-07-04-firmware-v2-plan.md). Pure C++11, no Arduino deps; structs are the future SEQ_UPLOAD_PROGRAM wire format. Co-Authored-By: Claude Fable 5 --- .../controller/src/sequencer/seq_types.cpp | 38 ++++++ firmware/controller/src/sequencer/seq_types.h | 78 ++++++++++++ .../test/test_seq_types/test_seq_types.cpp | 116 ++++++++++++++++++ 3 files changed, 232 insertions(+) create mode 100644 firmware/controller/src/sequencer/seq_types.cpp create mode 100644 firmware/controller/src/sequencer/seq_types.h create mode 100644 firmware/controller/test/test_seq_types/test_seq_types.cpp diff --git a/firmware/controller/src/sequencer/seq_types.cpp b/firmware/controller/src/sequencer/seq_types.cpp new file mode 100644 index 000000000..88ed4c19f --- /dev/null +++ b/firmware/controller/src/sequencer/seq_types.cpp @@ -0,0 +1,38 @@ +#include "sequencer/seq_types.h" + +namespace seq { + +static ValidationResult err(SeqError e, uint8_t detail = 0) { return {e, detail}; } + +ValidationResult validate(const SeqLoop& loop, const SeqChannel* channels, + const SeqCameraConfig* cams, uint8_t n_cameras, uint8_t n_axes, + uint8_t n_dacs) { + (void)cams; + if (loop.n_layers < 1) return err(SeqError::BadLayerCount); + if (loop.n_channels < 1 || loop.n_channels > kMaxChannels) + return err(SeqError::BadChannelCount); + if (loop.stack_axis_type == (uint8_t)StackAxisType::Stepper) { + if (loop.stack_axis_id >= n_axes) return err(SeqError::BadStackAxis); + } else if (loop.stack_axis_type == (uint8_t)StackAxisType::Piezo) { + if (loop.stack_axis_id >= n_dacs) return err(SeqError::BadStackAxis); + } else { + return err(SeqError::BadStackAxis); + } + for (uint8_t i = 0; i < loop.n_channels; i++) { + const SeqChannel& c = channels[i]; + if (c.exposure_us == 0) return err(SeqError::BadExposure, i); + if (c.camera_mask == 0) return err(SeqError::BadCamera, i); + for (uint8_t cam = 0; cam < 8; cam++) { + if ((c.camera_mask >> cam) & 1) { + if (cam >= n_cameras || cam >= kMaxCameras) return err(SeqError::BadCamera, i); + } + } + if (c.filter_wheel != kNone && c.filter_wheel >= n_axes) + return err(SeqError::BadChannel, i); + if (c.intensity_dac != kNone && c.intensity_dac >= n_dacs) + return err(SeqError::BadChannel, i); + } + return err(SeqError::None); +} + +} // namespace seq diff --git a/firmware/controller/src/sequencer/seq_types.h b/firmware/controller/src/sequencer/seq_types.h new file mode 100644 index 000000000..e2280263c --- /dev/null +++ b/firmware/controller/src/sequencer/seq_types.h @@ -0,0 +1,78 @@ +#pragma once +#include + +// Sequencer program types — pure C++11, NO Arduino dependencies. +// These structs are the wire format for SEQ_UPLOAD_PROGRAM (protocol v2, Phase B/C) +// and the input to the sequencer engine (seq_engine.h). Packed little-endian. +// Design: AI-docs/Squid/to-do/2026-07-04-firmware-v2-design.md §5. + +namespace seq { + +constexpr uint8_t kMaxChannels = 16; +constexpr uint8_t kMaxCameras = 8; // board v2 has 8 trigger channels (v1 has 4) +constexpr uint8_t kNone = 0xFF; +constexpr uint32_t kEdgePulseUs = 50; // matches v1 TRIGGER_PULSE_LENGTH_us + +enum class StackAxisType : uint8_t { Stepper = 0, Piezo = 1 }; +enum class Order : uint8_t { ChannelsInner = 0, ZInner = 1 }; +enum class TriggerMode : uint8_t { Edge = 0, Level = 1 }; + +struct __attribute__((packed)) SeqLoop { + uint8_t stack_axis_type; // StackAxisType + uint8_t stack_axis_id; // stepper axis id, or DAC id when Piezo + int32_t dz; // usteps (stepper) or DAC LSB (piezo) per layer, signed + uint16_t n_layers; // >= 1 + uint8_t order; // Order + uint32_t z_settle_us; // wait after stack move reports done + uint8_t return_to_start; // bool: move stack axis back after the sequence + uint8_t n_channels; // 1..kMaxChannels +}; + +struct __attribute__((packed)) SeqChannel { + uint8_t filter_wheel; // kNone, or wheel axis id + uint8_t filter_pos; // wheel slot index (absolute target) + uint8_t illum_ttl_mask; // TTL ports ON during exposure (0 = LED-matrix only) + uint8_t led_pattern; // kNone, or LED-matrix pattern id + uint8_t intensity_dac; // kNone, or DAC id (pre-armed during previous readout) + uint16_t intensity; // DAC value + uint32_t exposure_us; // > 0 + uint8_t camera_mask; // != 0; bit i = camera i + int32_t z_offset; // per-channel stack-axis offset + uint8_t flags; // reserved, 0 +}; + +// Runtime per-camera config (set via SET_CAMERA_PARAMS, not uploaded with programs). +struct SeqCameraConfig { + uint8_t trigger_mode; // TriggerMode + uint32_t strobe_delay_us; // trigger assert -> illumination on + uint32_t readout_time_us; // model-based readiness after exposure end + uint32_t min_trigger_period_us; // 0 = no constraint + uint8_t ready_line; // kNone = model-only, else ready input index + uint8_t ready_active_high; + uint8_t readout_overlap_safe; // 0 = no motion during this camera's readout +}; + +enum class SeqError : uint8_t { + None = 0, + BadLayerCount, + BadChannelCount, + BadStackAxis, + BadChannel, + BadCamera, + BadExposure, + WaitTimeout, + MoveFailed, + ReadyTimeout, + Canceled, +}; + +struct ValidationResult { + SeqError error; + uint8_t detail; // channel index (or axis id) the error refers to +}; + +ValidationResult validate(const SeqLoop& loop, const SeqChannel* channels, + const SeqCameraConfig* cams, uint8_t n_cameras, uint8_t n_axes, + uint8_t n_dacs); + +} // namespace seq diff --git a/firmware/controller/test/test_seq_types/test_seq_types.cpp b/firmware/controller/test/test_seq_types/test_seq_types.cpp new file mode 100644 index 000000000..2727b117a --- /dev/null +++ b/firmware/controller/test/test_seq_types/test_seq_types.cpp @@ -0,0 +1,116 @@ +#include +#include "sequencer/seq_types.h" + +// Include source directly for native tests (same convention as test_crc8) +#include "sequencer/seq_types.cpp" + +using namespace seq; + +static SeqLoop good_loop() { + SeqLoop l{}; + l.stack_axis_type = (uint8_t)StackAxisType::Piezo; + l.stack_axis_id = 7; // DAC7 = piezo on current boards + l.dz = 120; + l.n_layers = 10; + l.order = (uint8_t)Order::ChannelsInner; + l.z_settle_us = 2000; + l.return_to_start = 1; + l.n_channels = 2; + return l; +} + +static SeqChannel good_channel() { + SeqChannel c{}; + c.filter_wheel = kNone; + c.filter_pos = 0; + c.illum_ttl_mask = 0x01; + c.led_pattern = kNone; + c.intensity_dac = 0; + c.intensity = 30000; + c.exposure_us = 10000; + c.camera_mask = 0x01; + c.z_offset = 0; + c.flags = 0; + return c; +} + +static SeqCameraConfig cam_level() { + SeqCameraConfig c{}; + c.trigger_mode = (uint8_t)TriggerMode::Level; + c.strobe_delay_us = 500; + c.readout_time_us = 20000; + c.min_trigger_period_us = 0; + c.ready_line = kNone; + c.ready_active_high = 1; + c.readout_overlap_safe = 1; + return c; +} + +void setUp(void) {} +void tearDown(void) {} + +void test_valid_program_passes(void) { + SeqLoop l = good_loop(); + SeqChannel ch[2] = {good_channel(), good_channel()}; + SeqCameraConfig cams[1] = {cam_level()}; + ValidationResult r = validate(l, ch, cams, 1, 8, 8); + TEST_ASSERT_EQUAL_UINT8((uint8_t)SeqError::None, (uint8_t)r.error); +} + +void test_zero_layers_rejected(void) { + SeqLoop l = good_loop(); + l.n_layers = 0; + SeqChannel ch[2] = {good_channel(), good_channel()}; + SeqCameraConfig cams[1] = {cam_level()}; + TEST_ASSERT_EQUAL_UINT8((uint8_t)SeqError::BadLayerCount, + (uint8_t)validate(l, ch, cams, 1, 8, 8).error); +} + +void test_zero_exposure_rejected_with_channel_index(void) { + SeqLoop l = good_loop(); + SeqChannel ch[2] = {good_channel(), good_channel()}; + ch[1].exposure_us = 0; + SeqCameraConfig cams[1] = {cam_level()}; + ValidationResult r = validate(l, ch, cams, 1, 8, 8); + TEST_ASSERT_EQUAL_UINT8((uint8_t)SeqError::BadExposure, (uint8_t)r.error); + TEST_ASSERT_EQUAL_UINT8(1, r.detail); +} + +void test_camera_mask_beyond_configured_cameras_rejected(void) { + SeqLoop l = good_loop(); + SeqChannel ch[2] = {good_channel(), good_channel()}; + ch[0].camera_mask = 0x02; // camera 1, but only 1 camera configured + SeqCameraConfig cams[1] = {cam_level()}; + TEST_ASSERT_EQUAL_UINT8((uint8_t)SeqError::BadCamera, + (uint8_t)validate(l, ch, cams, 1, 8, 8).error); +} + +void test_stepper_axis_out_of_range_rejected(void) { + SeqLoop l = good_loop(); + l.stack_axis_type = (uint8_t)StackAxisType::Stepper; + l.stack_axis_id = 8; + SeqChannel ch[2] = {good_channel(), good_channel()}; + SeqCameraConfig cams[1] = {cam_level()}; + TEST_ASSERT_EQUAL_UINT8((uint8_t)SeqError::BadStackAxis, + (uint8_t)validate(l, ch, cams, 1, 8, 8).error); +} + +void test_channel_count_bounds(void) { + SeqLoop l = good_loop(); + l.n_channels = 0; + SeqChannel ch[2] = {good_channel(), good_channel()}; + SeqCameraConfig cams[1] = {cam_level()}; + TEST_ASSERT_EQUAL_UINT8((uint8_t)SeqError::BadChannelCount, + (uint8_t)validate(l, ch, cams, 1, 8, 8).error); +} + +int main(int, char**) { + UNITY_BEGIN(); + RUN_TEST(test_valid_program_passes); + RUN_TEST(test_zero_layers_rejected); + RUN_TEST(test_zero_exposure_rejected_with_channel_index); + RUN_TEST(test_camera_mask_beyond_configured_cameras_rejected); + RUN_TEST(test_stepper_axis_out_of_range_rejected); + RUN_TEST(test_channel_count_bounds); + return UNITY_END(); +} From f387fc05d1d3974eef9360631ba7b1f04a1c032d Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sat, 4 Jul 2026 21:19:25 -0400 Subject: [PATCH 2/8] feat(seq): sequencer engine skeleton with HAL interface and virtual-clock tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A Task A2: SeqHal + ExposurePlan (µs-timestamped edges owned by the HAL), FakeHal scripted test harness, engine state machine passing single-frame and piezo-settle scenarios. Co-Authored-By: Claude Fable 5 --- .../controller/src/sequencer/seq_engine.cpp | 199 ++++++++++++++++++ .../controller/src/sequencer/seq_engine.h | 74 +++++++ firmware/controller/src/sequencer/seq_hal.h | 41 ++++ .../test/test_seq_engine/fake_hal.h | 59 ++++++ .../test/test_seq_engine/test_seq_engine.cpp | 122 +++++++++++ 5 files changed, 495 insertions(+) create mode 100644 firmware/controller/src/sequencer/seq_engine.cpp create mode 100644 firmware/controller/src/sequencer/seq_engine.h create mode 100644 firmware/controller/src/sequencer/seq_hal.h create mode 100644 firmware/controller/test/test_seq_engine/fake_hal.h create mode 100644 firmware/controller/test/test_seq_engine/test_seq_engine.cpp diff --git a/firmware/controller/src/sequencer/seq_engine.cpp b/firmware/controller/src/sequencer/seq_engine.cpp new file mode 100644 index 000000000..8cedba79d --- /dev/null +++ b/firmware/controller/src/sequencer/seq_engine.cpp @@ -0,0 +1,199 @@ +#include "sequencer/seq_engine.h" + +namespace seq { + +SeqEngine::SeqEngine(SeqHal& hal) : hal_(hal) {} + +ValidationResult SeqEngine::load(const SeqLoop& loop, const SeqChannel* channels, + const SeqCameraConfig* cams, uint8_t n_cameras, + int32_t stack_axis_start) { + ValidationResult r = validate(loop, channels, cams, n_cameras, 8, 8); + if (r.error != SeqError::None) return r; + loop_ = loop; + n_cameras_ = (n_cameras < kMaxCameras) ? n_cameras : kMaxCameras; + stack_start_ = stack_axis_start; + for (uint8_t i = 0; i < loop.n_channels; i++) channels_[i] = channels[i]; + for (uint8_t i = 0; i < n_cameras_; i++) cams_[i] = cams[i]; + state_ = SeqState::Idle; + return r; +} + +uint32_t SeqEngine::total_steps() const { + return (uint32_t)loop_.n_layers * loop_.n_channels; +} + +void SeqEngine::step_to_layer_channel(uint32_t k, uint16_t* layer, uint8_t* ch) const { + if (loop_.order == (uint8_t)Order::ChannelsInner) { + *layer = (uint16_t)(k / loop_.n_channels); + *ch = (uint8_t)(k % loop_.n_channels); + } else { + *ch = (uint8_t)(k / loop_.n_layers); + *layer = (uint16_t)(k % loop_.n_layers); + } +} + +int32_t SeqEngine::stack_target_for(uint16_t layer, uint8_t ch) const { + return stack_start_ + (int32_t)layer * loop_.dz + channels_[ch].z_offset; +} + +bool SeqEngine::start(uint32_t now_us, uint32_t wait_timeout_us) { + if (state_ != SeqState::Idle) return false; + wait_timeout_us_ = wait_timeout_us; + progress_ = SeqProgress{}; + progress_.total_layers = loop_.n_layers; + progress_.total_channels = loop_.n_channels; + for (uint8_t i = 0; i < kMaxCameras; i++) { + last_trigger_us_[i] = 0; + readout_done_us_[i] = 0; + } + step_ = 0; + cancel_requested_ = false; + begin_prep(0, now_us); + if (state_ == SeqState::Failed) return true; // started, then immediately failed + wait_deadline_us_ = now_us + wait_timeout_us_; + state_ = SeqState::WaitHw; + return true; +} + +void SeqEngine::cancel() { cancel_requested_ = true; } + +void SeqEngine::begin_prep(uint32_t k, uint32_t now_us) { + uint16_t layer; + uint8_t chi; + step_to_layer_channel(k, &layer, &chi); + const SeqChannel& ch = channels_[chi]; + // Stack axis + int32_t target = stack_target_for(layer, chi); + if (loop_.stack_axis_type == (uint8_t)StackAxisType::Piezo) { + hal_.set_dac(loop_.stack_axis_id, (uint16_t)target); + settle_armed_ = true; + settle_done_us_ = now_us + loop_.z_settle_us; + } else { + if (!hal_.start_axis_move(loop_.stack_axis_id, target)) { + fail(SeqError::MoveFailed, loop_.stack_axis_id); + return; + } + settle_armed_ = false; // armed on first in-position observation + settle_done_us_ = 0; + } + // Filter wheel + if (ch.filter_wheel != kNone) { + if (!hal_.start_axis_move(ch.filter_wheel, ch.filter_pos)) { + fail(SeqError::MoveFailed, ch.filter_wheel); + return; + } + } + // Intensity pre-arm + LED pattern (loop-context SPI: only ever in PREP) + if (ch.intensity_dac != kNone) hal_.set_dac(ch.intensity_dac, ch.intensity); + if (ch.led_pattern != kNone) hal_.set_led_pattern(ch.led_pattern); +} + +bool SeqEngine::hw_ready_for(uint32_t k, uint32_t now_us) { + uint16_t layer; + uint8_t chi; + step_to_layer_channel(k, &layer, &chi); + const SeqChannel& ch = channels_[chi]; + // Stack axis settled? + if (loop_.stack_axis_type == (uint8_t)StackAxisType::Stepper) { + if (!hal_.axis_in_position(loop_.stack_axis_id)) return false; + if (!settle_armed_) { + settle_armed_ = true; + settle_done_us_ = now_us + loop_.z_settle_us; + } + } + if (now_us < settle_done_us_) return false; + // Filter wheel in position? + if (ch.filter_wheel != kNone && !hal_.axis_in_position(ch.filter_wheel)) return false; + // Every camera in the mask ready? + for (uint8_t cam = 0; cam < n_cameras_; cam++) { + if (!((ch.camera_mask >> cam) & 1)) continue; + const SeqCameraConfig& cc = cams_[cam]; + if (cc.ready_line != kNone) { + if (hal_.ready_line(cc.ready_line) != (bool)cc.ready_active_high) return false; + } else { + if (now_us < readout_done_us_[cam]) return false; + } + if (cc.min_trigger_period_us && last_trigger_us_[cam] != 0 && + now_us - last_trigger_us_[cam] < cc.min_trigger_period_us) + return false; + } + return true; +} + +void SeqEngine::schedule_exposures(uint32_t k, uint32_t now_us) { + uint16_t layer; + uint8_t chi; + step_to_layer_channel(k, &layer, &chi); + const SeqChannel& ch = channels_[chi]; + cur_exposure_end_us_ = 0; + for (uint8_t cam = 0; cam < n_cameras_; cam++) { + if (!((ch.camera_mask >> cam) & 1)) continue; + const SeqCameraConfig& cc = cams_[cam]; + ExposurePlan p{}; + p.camera_id = cam; + p.trigger_mode = cc.trigger_mode; + p.illum_ttl_mask = ch.illum_ttl_mask; + p.t_assert_us = now_us; + p.t_illum_on_us = now_us + cc.strobe_delay_us; + p.t_illum_off_us = p.t_illum_on_us + ch.exposure_us; + p.t_deassert_us = (cc.trigger_mode == (uint8_t)TriggerMode::Level) + ? p.t_illum_off_us + : now_us + kEdgePulseUs; + hal_.schedule_exposure(p); + last_trigger_us_[cam] = now_us; + uint32_t end = (p.t_illum_off_us > p.t_deassert_us) ? p.t_illum_off_us + : p.t_deassert_us; + readout_done_us_[cam] = end + cc.readout_time_us; + if (end > cur_exposure_end_us_) cur_exposure_end_us_ = end; + } + progress_.frames_fired++; + progress_.layer = layer; + progress_.channel = chi; + state_ = SeqState::Exposing; +} + +void SeqEngine::fail(SeqError e, uint8_t detail) { + hal_.all_off(); + progress_.abort_error = (uint8_t)e; + progress_.abort_detail = detail; + state_ = SeqState::Failed; +} + +void SeqEngine::tick(uint32_t now_us) { + switch (state_) { + case SeqState::WaitHw: + if (hw_ready_for(step_, now_us)) { + schedule_exposures(step_, now_us); + break; + } + if (now_us >= wait_deadline_us_) fail(SeqError::WaitTimeout, 0); + break; + case SeqState::Exposing: { + if (now_us < cur_exposure_end_us_) break; + // Exposure over -> readout window begins: advance and PREP the next step + // NOW — this is the overlap that hides filter/z moves behind readout. + step_++; + if (cancel_requested_ || step_ >= total_steps()) { + if (loop_.return_to_start) { + if (loop_.stack_axis_type == (uint8_t)StackAxisType::Piezo) + hal_.set_dac(loop_.stack_axis_id, (uint16_t)stack_start_); + else + hal_.start_axis_move(loop_.stack_axis_id, stack_start_); + } + if (cancel_requested_ && step_ < total_steps()) + progress_.abort_error = (uint8_t)SeqError::Canceled; + state_ = SeqState::Done; + break; + } + begin_prep(step_, now_us); + if (state_ == SeqState::Failed) break; // begin_prep may fail() + wait_deadline_us_ = now_us + wait_timeout_us_; + state_ = SeqState::WaitHw; + break; + } + default: + break; + } +} + +} // namespace seq diff --git a/firmware/controller/src/sequencer/seq_engine.h b/firmware/controller/src/sequencer/seq_engine.h new file mode 100644 index 000000000..2f9972877 --- /dev/null +++ b/firmware/controller/src/sequencer/seq_engine.h @@ -0,0 +1,74 @@ +#pragma once +#include + +#include "sequencer/seq_hal.h" +#include "sequencer/seq_types.h" + +// Sequencer engine — pure C++11 state machine, NO Arduino deps. +// Consumes (program, now_us, HAL inputs); emits HAL commands. Timing semantics are +// specified in AI-docs design §5.2 and enforced by test/test_seq_engine/. +// +// Per acquisition step k (a (layer, channel) pair in the configured order): +// PREP(k) launched during step k-1's readout window (or at start for k=0): +// stack-axis move (TMC or piezo DAC), filter-wheel move, intensity DAC +// pre-arm, LED pattern — all loop-context SPI happens HERE only. +// WAIT(k) stack axis settled ∧ filter in position ∧ all masked cameras ready +// (ready line or timing model) ∧ min trigger period elapsed. +// EXPOSE(k) schedule µs-timestamped trigger + illumination edges via the HAL. +// at exposure end: advance to k+1, PREP immediately (this IS the readout overlap). + +namespace seq { + +enum class SeqState : uint8_t { Idle, Prep, WaitHw, Exposing, Aborting, Done, Failed }; + +struct SeqProgress { + uint16_t layer; + uint16_t total_layers; + uint8_t channel; + uint8_t total_channels; + uint32_t frames_fired; + uint8_t abort_error; // SeqError + uint8_t abort_detail; +}; + +class SeqEngine { + public: + explicit SeqEngine(SeqHal& hal); + ValidationResult load(const SeqLoop& loop, const SeqChannel* channels, + const SeqCameraConfig* cams, uint8_t n_cameras, + int32_t stack_axis_start); + bool start(uint32_t now_us, uint32_t wait_timeout_us); + void cancel(); // finish current exposure, then wind down (never truncates) + void tick(uint32_t now_us); + SeqState state() const { return state_; } + const SeqProgress& progress() const { return progress_; } + + private: + uint32_t total_steps() const; + void step_to_layer_channel(uint32_t k, uint16_t* layer, uint8_t* ch) const; + int32_t stack_target_for(uint16_t layer, uint8_t ch) const; + void begin_prep(uint32_t k, uint32_t now_us); // moves + DAC pre-arm + LED + bool hw_ready_for(uint32_t k, uint32_t now_us); // WAIT gate (design §5.2) + void schedule_exposures(uint32_t k, uint32_t now_us); + void fail(SeqError e, uint8_t detail); + + SeqHal& hal_; + SeqLoop loop_{}; + SeqChannel channels_[kMaxChannels]{}; + SeqCameraConfig cams_[kMaxCameras]{}; + uint8_t n_cameras_ = 0; + int32_t stack_start_ = 0; + SeqState state_ = SeqState::Idle; + SeqProgress progress_{}; + uint32_t step_ = 0; // current step index k + uint32_t wait_deadline_us_ = 0; + uint32_t wait_timeout_us_ = 0; + uint32_t settle_done_us_ = 0; // stack-move settle gate + bool settle_armed_ = false; // stepper: set on first in-position observation + uint32_t cur_exposure_end_us_ = 0; + uint32_t last_trigger_us_[kMaxCameras]{}; + uint32_t readout_done_us_[kMaxCameras]{}; + bool cancel_requested_ = false; +}; + +} // namespace seq diff --git a/firmware/controller/src/sequencer/seq_hal.h b/firmware/controller/src/sequencer/seq_hal.h new file mode 100644 index 000000000..e4a46a356 --- /dev/null +++ b/firmware/controller/src/sequencer/seq_hal.h @@ -0,0 +1,41 @@ +#pragma once +#include + +#include "sequencer/seq_types.h" + +// Hardware interface consumed by the sequencer engine — pure C++11, NO Arduino deps. +// Tests inject FakeHal (test/test_seq_engine/fake_hal.h); Phase D binds real hardware +// (seq_bind.cpp: TMC moves, DAC80508, TTL pins via the µs event timer). + +namespace seq { + +// One camera's exposure, fully timestamped. The HAL owns µs-precise edge execution; +// the engine owns the semantics (when to schedule, what the times mean). +struct ExposurePlan { + uint8_t camera_id; + uint8_t trigger_mode; // TriggerMode + uint8_t illum_ttl_mask; // TTL ports driven for this exposure + uint32_t t_assert_us; // trigger asserted (active edge) + uint32_t t_illum_on_us; // = t_assert + strobe_delay + uint32_t t_illum_off_us; // = t_illum_on + exposure + uint32_t t_deassert_us; // Level: == t_illum_off ; Edge: t_assert + kEdgePulseUs +}; + +class SeqHal { + public: + virtual ~SeqHal() {} + // Motion (stepper axes and filter wheels). Returns false if the move is rejected. + virtual bool start_axis_move(uint8_t axis_id, int32_t target_usteps) = 0; + virtual bool axis_in_position(uint8_t axis_id) = 0; + // Analog / illumination setup (loop-context SPI — engine only calls these in PREP). + virtual void set_dac(uint8_t dac_id, uint16_t value) = 0; + virtual void set_led_pattern(uint8_t pattern_id) = 0; + // Exposure execution (µs-precise trigger + illumination edges). + virtual void schedule_exposure(const ExposurePlan& plan) = 0; + // Camera trigger-ready input (polarity-raw; engine normalizes). + virtual bool ready_line(uint8_t line) = 0; + // Abort path: all illumination off, all triggers deasserted. + virtual void all_off() = 0; +}; + +} // namespace seq diff --git a/firmware/controller/test/test_seq_engine/fake_hal.h b/firmware/controller/test/test_seq_engine/fake_hal.h new file mode 100644 index 000000000..b5395ac53 --- /dev/null +++ b/firmware/controller/test/test_seq_engine/fake_hal.h @@ -0,0 +1,59 @@ +#pragma once +#include +#include + +#include "sequencer/seq_engine.h" +#include "sequencer/seq_hal.h" + +// Records every HAL call with the virtual timestamp at which the engine made it, +// and simulates axis motion with scripted per-axis move durations. +struct FakeHal : seq::SeqHal { + struct Call { + std::string what; + uint32_t t_us; + long a; + long b; + }; + std::vector calls; + std::vector plans; + uint32_t now_us = 0; // test advances this; engine sees tick(now) + uint32_t move_duration_us[8] = {0}; // scripted per-axis + uint32_t move_done_at_us[8] = {0}; + bool moving[8] = {false}; + // 10 = board-v2 maximum (2 direct + 8 expander); index space matches + // SeqCameraConfig.ready_line + bool ready_lines[10] = {true, true, true, true, true, true, true, true, true, true}; + bool fail_next_move = false; + + bool start_axis_move(uint8_t axis, int32_t target) override { + calls.push_back({"move", now_us, axis, target}); + if (fail_next_move) { + fail_next_move = false; + return false; + } + moving[axis] = true; + move_done_at_us[axis] = now_us + move_duration_us[axis]; + return true; + } + bool axis_in_position(uint8_t axis) override { + if (moving[axis] && now_us >= move_done_at_us[axis]) moving[axis] = false; + return !moving[axis]; + } + void set_dac(uint8_t dac, uint16_t v) override { calls.push_back({"dac", now_us, dac, v}); } + void set_led_pattern(uint8_t p) override { calls.push_back({"led", now_us, p, 0}); } + void schedule_exposure(const seq::ExposurePlan& p) override { + calls.push_back({"expose", now_us, p.camera_id, (long)p.t_assert_us}); + plans.push_back(p); + } + bool ready_line(uint8_t line) override { return ready_lines[line]; } + void all_off() override { calls.push_back({"all_off", now_us, 0, 0}); } +}; + +// Advance the engine in fixed virtual-time steps (default 100 µs ~ main-loop cadence). +inline void run_until(seq::SeqEngine& e, FakeHal& hal, uint32_t t_end_us, + uint32_t step_us = 100) { + while (hal.now_us < t_end_us) { + hal.now_us += step_us; + e.tick(hal.now_us); + } +} diff --git a/firmware/controller/test/test_seq_engine/test_seq_engine.cpp b/firmware/controller/test/test_seq_engine/test_seq_engine.cpp new file mode 100644 index 000000000..8af9fc201 --- /dev/null +++ b/firmware/controller/test/test_seq_engine/test_seq_engine.cpp @@ -0,0 +1,122 @@ +#include + +#include "sequencer/seq_types.h" +// Include sources directly for native tests (same convention as test_crc8) +#include "sequencer/seq_engine.cpp" +#include "sequencer/seq_types.cpp" + +#include "fake_hal.h" + +using namespace seq; + +static SeqLoop good_loop() { + SeqLoop l{}; + l.stack_axis_type = (uint8_t)StackAxisType::Piezo; + l.stack_axis_id = 7; // DAC7 = piezo on current boards + l.dz = 120; + l.n_layers = 10; + l.order = (uint8_t)Order::ChannelsInner; + l.z_settle_us = 2000; + l.return_to_start = 1; + l.n_channels = 2; + return l; +} + +static SeqChannel good_channel() { + SeqChannel c{}; + c.filter_wheel = kNone; + c.filter_pos = 0; + c.illum_ttl_mask = 0x01; + c.led_pattern = kNone; + c.intensity_dac = 0; + c.intensity = 30000; + c.exposure_us = 10000; + c.camera_mask = 0x01; + c.z_offset = 0; + c.flags = 0; + return c; +} + +static SeqCameraConfig cam_level() { + SeqCameraConfig c{}; + c.trigger_mode = (uint8_t)TriggerMode::Level; + c.strobe_delay_us = 500; + c.readout_time_us = 20000; + c.min_trigger_period_us = 0; + c.ready_line = kNone; + c.ready_active_high = 1; + c.readout_overlap_safe = 1; + return c; +} + +void setUp(void) {} +void tearDown(void) {} + +// Simplest program: 1 layer, 1 channel, no filter, piezo stack axis, level trigger. +void test_single_frame_program_completes(void) { + FakeHal hal; + SeqEngine e(hal); + SeqLoop l = good_loop(); + l.n_layers = 1; + l.n_channels = 1; + l.z_settle_us = 1000; + SeqChannel ch[1] = {good_channel()}; + SeqCameraConfig cams[1] = {cam_level()}; + TEST_ASSERT_EQUAL_UINT8((uint8_t)SeqError::None, + (uint8_t)e.load(l, ch, cams, 1, /*stack_start=*/40000).error); + TEST_ASSERT_TRUE(e.start(hal.now_us, /*wait_timeout_us=*/5000000)); + run_until(e, hal, 2000000); + TEST_ASSERT_EQUAL_UINT8((uint8_t)SeqState::Done, (uint8_t)e.state()); + TEST_ASSERT_EQUAL_UINT32(1, e.progress().frames_fired); + TEST_ASSERT_EQUAL(1, (int)hal.plans.size()); + const ExposurePlan& p = hal.plans[0]; + // Level semantics: illum on at assert+strobe; deassert == illum_off; + // pulse width = strobe + exposure. + TEST_ASSERT_EQUAL_UINT32(p.t_assert_us + 500, p.t_illum_on_us); + TEST_ASSERT_EQUAL_UINT32(p.t_illum_on_us + 10000, p.t_illum_off_us); + TEST_ASSERT_EQUAL_UINT32(p.t_illum_off_us, p.t_deassert_us); +} + +// Piezo stack axis: layer z = DAC steps of dz; settle honored before exposure. +void test_piezo_step_and_settle_gate_exposure(void) { + FakeHal hal; + SeqEngine e(hal); + SeqLoop l = good_loop(); + l.n_layers = 2; + l.n_channels = 1; + l.dz = 120; + l.z_settle_us = 3000; + SeqChannel ch[1] = {good_channel()}; + SeqCameraConfig cams[1] = {cam_level()}; + cams[0].readout_time_us = 5000; + e.load(l, ch, cams, 1, 40000); + e.start(hal.now_us, 5000000); + run_until(e, hal, 3000000); + TEST_ASSERT_EQUAL_UINT8((uint8_t)SeqState::Done, (uint8_t)e.state()); + TEST_ASSERT_EQUAL_UINT32(2, e.progress().frames_fired); + // DAC writes to the piezo (dac 7): layer0 = 40000, layer1 = 40120, + // + return_to_start = 40000 at the end. + int dac_writes = 0; + uint16_t last = 0; + for (auto& c : hal.calls) { + if (c.what == "dac" && c.a == 7) { + dac_writes++; + last = (uint16_t)c.b; + } + } + TEST_ASSERT_EQUAL(3, dac_writes); + TEST_ASSERT_EQUAL_UINT16(40000, last); + // Second exposure must start >= settle after the layer-1 DAC step. + uint32_t t_dac1 = 0; + for (auto& c : hal.calls) { + if (c.what == "dac" && c.a == 7 && (uint16_t)c.b == 40120) t_dac1 = c.t_us; + } + TEST_ASSERT_TRUE(hal.plans[1].t_assert_us >= t_dac1 + 3000); +} + +int main(int, char**) { + UNITY_BEGIN(); + RUN_TEST(test_single_frame_program_completes); + RUN_TEST(test_piezo_step_and_settle_gate_exposure); + return UNITY_END(); +} From d50e812d902a800a1aa5720049f39a1932d80ef5 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sat, 4 Jul 2026 21:20:22 -0400 Subject: [PATCH 3/8] test(seq): WAIT gating on settle, filter wheel, and model readiness Phase A Task A3. Engine already used the explicit settle_armed_ latch the plan's refactor step targets; tests lock the behavior in. Co-Authored-By: Claude Fable 5 --- .../test/test_seq_engine/test_seq_engine.cpp | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/firmware/controller/test/test_seq_engine/test_seq_engine.cpp b/firmware/controller/test/test_seq_engine/test_seq_engine.cpp index 8af9fc201..7ad8ceef5 100644 --- a/firmware/controller/test/test_seq_engine/test_seq_engine.cpp +++ b/firmware/controller/test/test_seq_engine/test_seq_engine.cpp @@ -114,9 +114,77 @@ void test_piezo_step_and_settle_gate_exposure(void) { TEST_ASSERT_TRUE(hal.plans[1].t_assert_us >= t_dac1 + 3000); } +// Stepper stack axis: exposure gated on in_position + settle. +void test_stepper_settle_gates_exposure(void) { + FakeHal hal; + SeqEngine e(hal); + SeqLoop l = good_loop(); + l.stack_axis_type = (uint8_t)StackAxisType::Stepper; + l.stack_axis_id = 2; // Z + l.n_layers = 1; + l.n_channels = 1; + l.z_settle_us = 4000; + hal.move_duration_us[2] = 8000; + SeqChannel ch[1] = {good_channel()}; + SeqCameraConfig cams[1] = {cam_level()}; + e.load(l, ch, cams, 1, 100000); + e.start(0, 5000000); + run_until(e, hal, 1000000); + TEST_ASSERT_EQUAL_UINT8((uint8_t)SeqState::Done, (uint8_t)e.state()); + // assert >= move done (8000) + settle (4000); 100 µs tick quantum tolerance + TEST_ASSERT_TRUE(hal.plans[0].t_assert_us >= 12000); + TEST_ASSERT_TRUE(hal.plans[0].t_assert_us <= 12300); +} + +// Filter-wheel move longer than z move dominates the WAIT. +void test_filter_wheel_gates_exposure(void) { + FakeHal hal; + SeqEngine e(hal); + SeqLoop l = good_loop(); + l.n_layers = 1; + l.n_channels = 1; + l.z_settle_us = 0; + SeqChannel ch[1] = {good_channel()}; + ch[0].filter_wheel = 3; // FILTER1 axis id + ch[0].filter_pos = 5; + hal.move_duration_us[3] = 50000; + SeqCameraConfig cams[1] = {cam_level()}; + e.load(l, ch, cams, 1, 40000); + e.start(0, 5000000); + run_until(e, hal, 1000000); + TEST_ASSERT_TRUE(hal.plans[0].t_assert_us >= 50000); + // Filter move command must have been issued at PREP time (t=0), not lazily: + // calls[0] = dac (piezo target), calls[1] = move (filter wheel). + TEST_ASSERT_EQUAL_STRING("move", hal.calls[1].what.c_str()); + TEST_ASSERT_EQUAL_UINT32(0, hal.calls[1].t_us); +} + +// Model-based readiness: second frame waits out readout_time even though motion is +// instant. +void test_model_readiness_spaces_triggers(void) { + FakeHal hal; + SeqEngine e(hal); + SeqLoop l = good_loop(); + l.n_layers = 2; + l.n_channels = 1; + l.dz = 0; + l.z_settle_us = 0; + SeqChannel ch[1] = {good_channel()}; // exposure 10000 + SeqCameraConfig cams[1] = {cam_level()}; // strobe 500, readout 20000 + e.load(l, ch, cams, 1, 40000); + e.start(0, 5000000); + run_until(e, hal, 2000000); + TEST_ASSERT_EQUAL(2, (int)hal.plans.size()); + uint32_t end0 = hal.plans[0].t_deassert_us; // = assert0 + 10500 + TEST_ASSERT_TRUE(hal.plans[1].t_assert_us >= end0 + 20000); +} + int main(int, char**) { UNITY_BEGIN(); RUN_TEST(test_single_frame_program_completes); RUN_TEST(test_piezo_step_and_settle_gate_exposure); + RUN_TEST(test_stepper_settle_gates_exposure); + RUN_TEST(test_filter_wheel_gates_exposure); + RUN_TEST(test_model_readiness_spaces_triggers); return UNITY_END(); } From 710d883b1a5e6f2c1fe93435a342c17fcc2ae720 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sat, 4 Jul 2026 21:21:05 -0400 Subject: [PATCH 4/8] test(seq): readout overlap for filter and z moves; z-inner order; per-channel z offsets Phase A Task A4. Locks in the core time-saving behavior: PREP(k+1) launches at exposure end (readout start), and the next exposure waits for readout AND moves. Co-Authored-By: Claude Fable 5 --- .../test/test_seq_engine/test_seq_engine.cpp | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/firmware/controller/test/test_seq_engine/test_seq_engine.cpp b/firmware/controller/test/test_seq_engine/test_seq_engine.cpp index 7ad8ceef5..ef8295824 100644 --- a/firmware/controller/test/test_seq_engine/test_seq_engine.cpp +++ b/firmware/controller/test/test_seq_engine/test_seq_engine.cpp @@ -179,6 +179,87 @@ void test_model_readiness_spaces_triggers(void) { TEST_ASSERT_TRUE(hal.plans[1].t_assert_us >= end0 + 20000); } +// THE core feature: next channel's filter move starts at exposure end (readout +// begins), NOT after readout completes. Saves (filter_move ∥ readout) per frame. +void test_filter_move_overlaps_readout(void) { + FakeHal hal; + SeqEngine e(hal); + SeqLoop l = good_loop(); + l.n_layers = 1; + l.n_channels = 2; + l.z_settle_us = 0; + l.dz = 0; + SeqChannel ch[2] = {good_channel(), good_channel()}; + ch[0].filter_wheel = 3; + ch[0].filter_pos = 1; + ch[1].filter_wheel = 3; + ch[1].filter_pos = 2; + hal.move_duration_us[3] = 15000; + SeqCameraConfig cams[1] = {cam_level()}; // strobe 500, exposure 10000, readout 20000 + e.load(l, ch, cams, 1, 40000); + e.start(0, 5000000); + run_until(e, hal, 2000000); + // exposure0 ends at assert0 + 10500; find the filter move to pos 2: + uint32_t end0 = hal.plans[0].t_deassert_us; + uint32_t t_move2 = 0; + for (auto& c : hal.calls) { + if (c.what == "move" && c.a == 3 && c.b == 2) t_move2 = c.t_us; + } + // within a tick of exposure end — i.e., DURING readout: + TEST_ASSERT_TRUE(t_move2 >= end0 && t_move2 <= end0 + 200); + // and frame1 fires when BOTH readout (end0+20000) and move (t_move2+15000) done: + TEST_ASSERT_TRUE(hal.plans[1].t_assert_us >= end0 + 20000); + TEST_ASSERT_TRUE(hal.plans[1].t_assert_us <= end0 + 20000 + 200); +} + +// Z step for the next layer also overlaps the last channel's readout. +void test_z_step_overlaps_readout_between_layers(void) { + FakeHal hal; + SeqEngine e(hal); + SeqLoop l = good_loop(); + l.n_layers = 2; + l.n_channels = 1; // piezo dz = 120 + SeqChannel ch[1] = {good_channel()}; + SeqCameraConfig cams[1] = {cam_level()}; + e.load(l, ch, cams, 1, 40000); + e.start(0, 5000000); + run_until(e, hal, 2000000); + uint32_t end0 = hal.plans[0].t_deassert_us; + uint32_t t_dac1 = 0; + for (auto& c : hal.calls) { + if (c.what == "dac" && c.a == 7 && (uint16_t)c.b == 40120) t_dac1 = c.t_us; + } + TEST_ASSERT_TRUE(t_dac1 >= end0 && t_dac1 <= end0 + 200); +} + +// Z_INNER order: full stack of channel 0, then channel 1; per-channel z_offset applied. +void test_z_inner_order_and_z_offset(void) { + FakeHal hal; + SeqEngine e(hal); + SeqLoop l = good_loop(); + l.n_layers = 2; + l.n_channels = 2; + l.order = (uint8_t)Order::ZInner; + l.z_settle_us = 0; + SeqChannel ch[2] = {good_channel(), good_channel()}; + ch[1].z_offset = 40; // channel 1 offset + SeqCameraConfig cams[1] = {cam_level()}; + cams[0].readout_time_us = 0; + e.load(l, ch, cams, 1, 40000); + e.start(0, 5000000); + run_until(e, hal, 3000000); + TEST_ASSERT_EQUAL_UINT32(4, e.progress().frames_fired); + // Piezo targets in order: 40000, 40120 (ch0 L0,L1), 40040, 40160 (ch1 L0,L1), + // then 40000 (return_to_start). + std::vector targets; + for (auto& c : hal.calls) { + if (c.what == "dac" && c.a == 7) targets.push_back((uint16_t)c.b); + } + uint16_t expect[5] = {40000, 40120, 40040, 40160, 40000}; + TEST_ASSERT_EQUAL(5, (int)targets.size()); + for (int i = 0; i < 5; i++) TEST_ASSERT_EQUAL_UINT16(expect[i], targets[i]); +} + int main(int, char**) { UNITY_BEGIN(); RUN_TEST(test_single_frame_program_completes); @@ -186,5 +267,8 @@ int main(int, char**) { RUN_TEST(test_stepper_settle_gates_exposure); RUN_TEST(test_filter_wheel_gates_exposure); RUN_TEST(test_model_readiness_spaces_triggers); + RUN_TEST(test_filter_move_overlaps_readout); + RUN_TEST(test_z_step_overlaps_readout_between_layers); + RUN_TEST(test_z_inner_order_and_z_offset); return UNITY_END(); } From 337e56496d5806a0d9aeeb0dcdc7f8d8eaa27e34 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sat, 4 Jul 2026 21:21:38 -0400 Subject: [PATCH 5/8] test(seq): edge-trigger plans and simultaneous multi-camera exposure Phase A Task A5. Co-Authored-By: Claude Fable 5 --- .../test/test_seq_engine/test_seq_engine.cpp | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/firmware/controller/test/test_seq_engine/test_seq_engine.cpp b/firmware/controller/test/test_seq_engine/test_seq_engine.cpp index ef8295824..462d86a36 100644 --- a/firmware/controller/test/test_seq_engine/test_seq_engine.cpp +++ b/firmware/controller/test/test_seq_engine/test_seq_engine.cpp @@ -260,6 +260,44 @@ void test_z_inner_order_and_z_offset(void) { for (int i = 0; i < 5; i++) TEST_ASSERT_EQUAL_UINT16(expect[i], targets[i]); } +void test_edge_mode_pulse_and_modeled_exposure_end(void) { + FakeHal hal; + SeqEngine e(hal); + SeqLoop l = good_loop(); + l.n_layers = 1; + l.n_channels = 1; + SeqChannel ch[1] = {good_channel()}; + SeqCameraConfig cams[1] = {cam_level()}; + cams[0].trigger_mode = (uint8_t)TriggerMode::Edge; // strobe 500 + e.load(l, ch, cams, 1, 40000); + e.start(0, 5000000); + run_until(e, hal, 1000000); + const ExposurePlan& p = hal.plans[0]; + TEST_ASSERT_EQUAL_UINT32(p.t_assert_us + kEdgePulseUs, p.t_deassert_us); // 50 µs + TEST_ASSERT_EQUAL_UINT32(p.t_assert_us + 500 + 10000, p.t_illum_off_us); // model +} + +// Two cameras, different strobe delays: both scheduled at the same assert instant; +// the step is one frame event; readiness tracked per camera. +void test_two_cameras_simultaneous_exposure(void) { + FakeHal hal; + SeqEngine e(hal); + SeqLoop l = good_loop(); + l.n_layers = 1; + l.n_channels = 1; + SeqChannel ch[1] = {good_channel()}; + ch[0].camera_mask = 0x03; + SeqCameraConfig cams[2] = {cam_level(), cam_level()}; + cams[1].strobe_delay_us = 2000; + cams[1].readout_time_us = 40000; + e.load(l, ch, cams, 2, 40000); + e.start(0, 5000000); + run_until(e, hal, 1000000); + TEST_ASSERT_EQUAL(2, (int)hal.plans.size()); + TEST_ASSERT_EQUAL_UINT32(hal.plans[0].t_assert_us, hal.plans[1].t_assert_us); + TEST_ASSERT_EQUAL_UINT32(1, e.progress().frames_fired); // one step = one frame event +} + int main(int, char**) { UNITY_BEGIN(); RUN_TEST(test_single_frame_program_completes); @@ -270,5 +308,7 @@ int main(int, char**) { RUN_TEST(test_filter_move_overlaps_readout); RUN_TEST(test_z_step_overlaps_readout_between_layers); RUN_TEST(test_z_inner_order_and_z_offset); + RUN_TEST(test_edge_mode_pulse_and_modeled_exposure_end); + RUN_TEST(test_two_cameras_simultaneous_exposure); return UNITY_END(); } From 1e8780ab591d9d2a2f741dc5dc98349bab4ce3b5 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sat, 4 Jul 2026 21:22:58 -0400 Subject: [PATCH 6/8] feat(seq): ready-line gating, WAIT-timeout abort, rolling-shutter overlap opt-out Phase A Task A6. readout_overlap_safe=0 defers PREP(k+1) until that camera's readout completes (no motion during rolling-shutter readout); ready-line and timeout paths locked by tests. Co-Authored-By: Claude Fable 5 --- .../controller/src/sequencer/seq_engine.cpp | 5 +- .../controller/src/sequencer/seq_engine.h | 3 + .../test/test_seq_engine/test_seq_engine.cpp | 73 +++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/firmware/controller/src/sequencer/seq_engine.cpp b/firmware/controller/src/sequencer/seq_engine.cpp index 8cedba79d..4aab2e60d 100644 --- a/firmware/controller/src/sequencer/seq_engine.cpp +++ b/firmware/controller/src/sequencer/seq_engine.cpp @@ -126,6 +126,7 @@ void SeqEngine::schedule_exposures(uint32_t k, uint32_t now_us) { step_to_layer_channel(k, &layer, &chi); const SeqChannel& ch = channels_[chi]; cur_exposure_end_us_ = 0; + overlap_hold_until_us_ = 0; for (uint8_t cam = 0; cam < n_cameras_; cam++) { if (!((ch.camera_mask >> cam) & 1)) continue; const SeqCameraConfig& cc = cams_[cam]; @@ -145,6 +146,8 @@ void SeqEngine::schedule_exposures(uint32_t k, uint32_t now_us) { : p.t_deassert_us; readout_done_us_[cam] = end + cc.readout_time_us; if (end > cur_exposure_end_us_) cur_exposure_end_us_ = end; + if (!cc.readout_overlap_safe && readout_done_us_[cam] > overlap_hold_until_us_) + overlap_hold_until_us_ = readout_done_us_[cam]; } progress_.frames_fired++; progress_.layer = layer; @@ -169,7 +172,7 @@ void SeqEngine::tick(uint32_t now_us) { if (now_us >= wait_deadline_us_) fail(SeqError::WaitTimeout, 0); break; case SeqState::Exposing: { - if (now_us < cur_exposure_end_us_) break; + if (now_us < cur_exposure_end_us_ || now_us < overlap_hold_until_us_) break; // Exposure over -> readout window begins: advance and PREP the next step // NOW — this is the overlap that hides filter/z moves behind readout. step_++; diff --git a/firmware/controller/src/sequencer/seq_engine.h b/firmware/controller/src/sequencer/seq_engine.h index 2f9972877..2f8515e6d 100644 --- a/firmware/controller/src/sequencer/seq_engine.h +++ b/firmware/controller/src/sequencer/seq_engine.h @@ -66,6 +66,9 @@ class SeqEngine { uint32_t settle_done_us_ = 0; // stack-move settle gate bool settle_armed_ = false; // stepper: set on first in-position observation uint32_t cur_exposure_end_us_ = 0; + // Rolling-shutter support: PREP of the next step is deferred until cameras with + // readout_overlap_safe == 0 finish reading out (no motion during their readout). + uint32_t overlap_hold_until_us_ = 0; uint32_t last_trigger_us_[kMaxCameras]{}; uint32_t readout_done_us_[kMaxCameras]{}; bool cancel_requested_ = false; diff --git a/firmware/controller/test/test_seq_engine/test_seq_engine.cpp b/firmware/controller/test/test_seq_engine/test_seq_engine.cpp index 462d86a36..552e342e1 100644 --- a/firmware/controller/test/test_seq_engine/test_seq_engine.cpp +++ b/firmware/controller/test/test_seq_engine/test_seq_engine.cpp @@ -298,6 +298,76 @@ void test_two_cameras_simultaneous_exposure(void) { TEST_ASSERT_EQUAL_UINT32(1, e.progress().frames_fired); // one step = one frame event } +void test_ready_line_blocks_until_asserted(void) { + FakeHal hal; + SeqEngine e(hal); + SeqLoop l = good_loop(); + l.n_layers = 1; + l.n_channels = 1; + l.z_settle_us = 0; + SeqChannel ch[1] = {good_channel()}; + SeqCameraConfig cams[1] = {cam_level()}; + cams[0].ready_line = 0; + cams[0].ready_active_high = 1; + hal.ready_lines[0] = false; + e.load(l, ch, cams, 1, 40000); + e.start(0, 5000000); + run_until(e, hal, 30000); + TEST_ASSERT_EQUAL(0, (int)hal.plans.size()); // still gated + hal.ready_lines[0] = true; + run_until(e, hal, 60000); + TEST_ASSERT_EQUAL(1, (int)hal.plans.size()); + TEST_ASSERT_TRUE(hal.plans[0].t_assert_us >= 30000); +} + +void test_wait_timeout_aborts_with_all_off(void) { + FakeHal hal; + SeqEngine e(hal); + SeqLoop l = good_loop(); + l.n_layers = 1; + l.n_channels = 1; + SeqChannel ch[1] = {good_channel()}; + SeqCameraConfig cams[1] = {cam_level()}; + cams[0].ready_line = 0; + hal.ready_lines[0] = false; // never ready + e.load(l, ch, cams, 1, 40000); + e.start(0, /*wait_timeout_us=*/100000); + run_until(e, hal, 300000); + TEST_ASSERT_EQUAL_UINT8((uint8_t)SeqState::Failed, (uint8_t)e.state()); + TEST_ASSERT_EQUAL_UINT8((uint8_t)SeqError::WaitTimeout, e.progress().abort_error); + bool all_off_called = false; + for (auto& c : hal.calls) { + if (c.what == "all_off") all_off_called = true; + } + TEST_ASSERT_TRUE(all_off_called); +} + +// Rolling shutter: readout_overlap_safe=0 defers PREP(k+1) until the camera is done +// reading out — no motion during its readout. +void test_no_overlap_when_readout_unsafe(void) { + FakeHal hal; + SeqEngine e(hal); + SeqLoop l = good_loop(); + l.n_layers = 1; + l.n_channels = 2; + l.dz = 0; + l.z_settle_us = 0; + SeqChannel ch[2] = {good_channel(), good_channel()}; + ch[1].filter_wheel = 3; + ch[1].filter_pos = 2; + SeqCameraConfig cams[1] = {cam_level()}; // readout 20000 + cams[0].readout_overlap_safe = 0; + e.load(l, ch, cams, 1, 40000); + e.start(0, 5000000); + run_until(e, hal, 2000000); + uint32_t end0 = hal.plans[0].t_deassert_us; + uint32_t t_move = 0; + for (auto& c : hal.calls) { + if (c.what == "move" && c.a == 3) t_move = c.t_us; + } + TEST_ASSERT_TRUE(t_move >= end0 + 20000); // move waited out the readout +} + int main(int, char**) { UNITY_BEGIN(); RUN_TEST(test_single_frame_program_completes); @@ -310,5 +380,8 @@ int main(int, char**) { RUN_TEST(test_z_inner_order_and_z_offset); RUN_TEST(test_edge_mode_pulse_and_modeled_exposure_end); RUN_TEST(test_two_cameras_simultaneous_exposure); + RUN_TEST(test_ready_line_blocks_until_asserted); + RUN_TEST(test_wait_timeout_aborts_with_all_off); + RUN_TEST(test_no_overlap_when_readout_unsafe); return UNITY_END(); } From fbdb6b064e27eaa1d763e1da7ba1b455f32cc660 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sat, 4 Jul 2026 21:23:43 -0400 Subject: [PATCH 7/8] feat(seq): cancel semantics, min trigger period, run invariants Phase A Task A7. Cancel never truncates an exposure and honors return_to_start; min_trigger_period_us spacing enforced; whole-run invariants: frames == Nz*Nch, exposures never overlap, no motion/DAC command inside any exposure window. Co-Authored-By: Claude Fable 5 --- .../test/test_seq_engine/test_seq_engine.cpp | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/firmware/controller/test/test_seq_engine/test_seq_engine.cpp b/firmware/controller/test/test_seq_engine/test_seq_engine.cpp index 552e342e1..8c18b6566 100644 --- a/firmware/controller/test/test_seq_engine/test_seq_engine.cpp +++ b/firmware/controller/test/test_seq_engine/test_seq_engine.cpp @@ -368,6 +368,86 @@ void test_no_overlap_when_readout_unsafe(void) { TEST_ASSERT_TRUE(t_move >= end0 + 20000); // move waited out the readout } +void test_cancel_finishes_current_exposure_then_stops(void) { + FakeHal hal; + SeqEngine e(hal); + SeqLoop l = good_loop(); + l.n_layers = 10; + l.n_channels = 1; + SeqChannel ch[1] = {good_channel()}; + SeqCameraConfig cams[1] = {cam_level()}; + e.load(l, ch, cams, 1, 40000); + e.start(0, 5000000); + // run until mid-exposure of frame 2, then cancel: + while (e.progress().frames_fired < 2) { + hal.now_us += 100; + e.tick(hal.now_us); + } + uint32_t t_cancel = hal.now_us; + e.cancel(); + run_until(e, hal, t_cancel + 2000000); + TEST_ASSERT_EQUAL_UINT8((uint8_t)SeqState::Done, (uint8_t)e.state()); + TEST_ASSERT_EQUAL_UINT32(2, e.progress().frames_fired); // no frame 3 + TEST_ASSERT_EQUAL_UINT8((uint8_t)SeqError::Canceled, e.progress().abort_error); + // exposure 2's plan was never truncated: its deassert time stands as scheduled + TEST_ASSERT_TRUE(hal.plans[1].t_deassert_us > t_cancel); + // return_to_start honored: last HAL call is the piezo returning to 40000 + TEST_ASSERT_EQUAL_STRING("dac", hal.calls.back().what.c_str()); + TEST_ASSERT_EQUAL_UINT16(40000, (uint16_t)hal.calls.back().b); +} + +void test_min_trigger_period_enforced(void) { + FakeHal hal; + SeqEngine e(hal); + SeqLoop l = good_loop(); + l.n_layers = 3; + l.n_channels = 1; + l.dz = 0; + l.z_settle_us = 0; + SeqChannel ch[1] = {good_channel()}; + SeqCameraConfig cams[1] = {cam_level()}; + cams[0].readout_time_us = 0; + cams[0].min_trigger_period_us = 50000; + e.load(l, ch, cams, 1, 40000); + e.start(0, 5000000); + run_until(e, hal, 1000000); + TEST_ASSERT_EQUAL(3, (int)hal.plans.size()); + for (int i = 1; i < 3; i++) + TEST_ASSERT_TRUE(hal.plans[i].t_assert_us - hal.plans[i - 1].t_assert_us >= 50000); +} + +// Whole-run invariants over a mixed program (property-style, deterministic inputs). +void test_run_invariants(void) { + FakeHal hal; + SeqEngine e(hal); + SeqLoop l = good_loop(); + l.n_layers = 5; + l.n_channels = 3; + l.z_settle_us = 1000; + SeqChannel ch[3] = {good_channel(), good_channel(), good_channel()}; + ch[1].filter_wheel = 3; + ch[1].filter_pos = 2; + ch[2].filter_wheel = 3; + ch[2].filter_pos = 4; + hal.move_duration_us[3] = 7000; + SeqCameraConfig cams[1] = {cam_level()}; + e.load(l, ch, cams, 1, 40000); + e.start(0, 5000000); + run_until(e, hal, 10000000); + TEST_ASSERT_EQUAL_UINT8((uint8_t)SeqState::Done, (uint8_t)e.state()); + TEST_ASSERT_EQUAL_UINT32(15, e.progress().frames_fired); // Nz × Nch + // Invariant 1: exposures never overlap each other. + for (size_t i = 1; i < hal.plans.size(); i++) + TEST_ASSERT_TRUE(hal.plans[i].t_assert_us >= hal.plans[i - 1].t_deassert_us); + // Invariant 2: no motion/DAC command lands inside any exposure window. + for (auto& c : hal.calls) { + if (c.what != "move" && c.what != "dac") continue; + for (auto& p : hal.plans) { + TEST_ASSERT_FALSE(c.t_us > p.t_assert_us && c.t_us < p.t_deassert_us); + } + } +} + int main(int, char**) { UNITY_BEGIN(); RUN_TEST(test_single_frame_program_completes); @@ -383,5 +463,8 @@ int main(int, char**) { RUN_TEST(test_ready_line_blocks_until_asserted); RUN_TEST(test_wait_timeout_aborts_with_all_off); RUN_TEST(test_no_overlap_when_readout_unsafe); + RUN_TEST(test_cancel_finishes_current_exposure_then_stops); + RUN_TEST(test_min_trigger_period_enforced); + RUN_TEST(test_run_invariants); return UNITY_END(); } From 6258f3678bd3d4247d460c6e054fc2d8450cf24d Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sat, 4 Jul 2026 21:25:28 -0400 Subject: [PATCH 8/8] docs(firmware): document src/sequencer/ module in README Co-Authored-By: Claude Fable 5 --- firmware/README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/firmware/README.md b/firmware/README.md index 33b191ef7..ad330fd71 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -155,7 +155,8 @@ controller/ ├── platformio.ini # PlatformIO config ├── test/ # Unit tests (run with pio test -e native) │ ├── test_crc8/ # CRC8 checksum tests -│ └── test_protocol/ # Protocol/command ID tests +│ ├── test_protocol/ # Protocol/command ID tests +│ └── test_seq_engine/ # Sequencer engine timing tests (virtual clock) └── src/ ├── commands/ # Command handlers │ ├── commands.cpp/h # General commands @@ -163,6 +164,13 @@ controller/ │ └── stage_commands.cpp/h # Motion control ├── def/ │ └── def_v1.h # Hardware configuration + ├── sequencer/ # Hardware-sequenced acquisition engine (pure C++, + │ │ # natively tested; NOT yet wired to hardware — + │ │ # protocol v2 Phase D does the binding) + │ ├── seq_types.cpp/h # Acquisition program structs + validation + │ ├── seq_hal.h # Hardware interface the engine drives + │ └── seq_engine.cpp/h # Timing state machine (readout overlap, trigger- + │ # ready gating, cancel/abort semantics) ├── tmc/ # TMC stepper driver library ├── utils/ │ └── crc8.cpp/h # CRC calculation