Skip to content
Open
10 changes: 9 additions & 1 deletion firmware/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,14 +155,22 @@ 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
│ ├── light_commands.cpp/h # Illumination control
│ └── 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
Expand Down
202 changes: 202 additions & 0 deletions firmware/controller/src/sequencer/seq_engine.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
#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;
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];
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;
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;
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_ || 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_++;
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
77 changes: 77 additions & 0 deletions firmware/controller/src/sequencer/seq_engine.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
#pragma once
#include <stdint.h>

#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;
// 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;
};

} // namespace seq
41 changes: 41 additions & 0 deletions firmware/controller/src/sequencer/seq_hal.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#pragma once
#include <stdint.h>

#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
38 changes: 38 additions & 0 deletions firmware/controller/src/sequencer/seq_types.cpp
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading