[AZ-654] [AZ-655] [AZ-656] gimbal_controller primitives + monotonic clock fix (batch 11)
ci/woodpecker/push/build-arm Pipeline failed

AZ-654 SweepEngine: pendulum default, Raster/LawnMower variants
reserved and explicitly NotImplemented (no silent fallback per AC-3).
Time injected via next_step(now) for deterministic dwell tests.

AZ-655 PlanExecutor: linear yaw/pitch interpolation between PanGoals
with self-throttle (default 50 ms); stats expose
commands_emitted/dropped_to_throttle counters. PanGoal/PanPlan added
to shared::models::gimbal (spec drift: data_model.md §PanPlan flagged
for next doc sync).

AZ-656 CentreOnTarget: zoom-aware proportional control loop (correction
~ 1/zoom); target_lost debounced — fires once per loss streak, resets
on bbox return. Also fixes the misleadingly-named monotonic_ns() helper
introduced by AZ-653 that used SystemTime::now(): GimbalController now
owns a shared::clock::MonoClock and stamps GimbalState::ts_monotonic_ns
via clock.elapsed_ns(). AZ-656 AC-2 forced the correction; integration
test verifies the fix end-to-end.

58/58 gimbal_controller tests green (47 unit + 7 AZ-653 integration +
4 new batch_11 integration). Workspace test suite green this run.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-19 20:21:00 +03:00
parent 288e7f8c46
commit 4c63829ccd
12 changed files with 1470 additions and 13 deletions
@@ -0,0 +1,323 @@
//! AZ-656 — Centre-on-target primitive.
//!
//! Proportional control loop that consumes a normalized target bbox
//! stream (from `scan_controller`) and emits the gimbal yaw/pitch
//! command needed to drag the target toward the centre 25% region of
//! the frame. The actual `GimbalState` publish (with monotonic
//! timestamp) is handled by [`crate::GimbalControllerHandle::set_pose`]
//! when the emitted command is applied — this primitive is a pure
//! per-tick controller, no I/O.
//!
//! Loss detection: after [`CentreOnTargetConfig::max_missed_ticks`]
//! consecutive ticks with no bbox, a one-shot `target_lost` signal is
//! returned to the caller (debounced — never re-emits while the loss
//! state persists). On bbox return, the loss counter resets and the
//! signal becomes available again for the next loss streak.
use shared::models::frame::BoundingBox;
use crate::GimbalCommand;
pub const DEFAULT_TARGET_GAIN: f32 = 0.6;
pub const DEFAULT_CENTRE_WINDOW: f32 = 0.25;
pub const DEFAULT_MAX_MISSED_TICKS: u32 = 3;
/// Tuning knobs for the centre-on-target loop. `fov_deg_at_zoom1` is
/// the camera's nominal horizontal FOV at 1× zoom; the per-tick yaw
/// step scales inversely with the gimbal's current zoom (narrower FOV
/// → smaller correction needed for the same pixel-error).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CentreOnTargetConfig {
pub fov_deg_at_zoom1: f32,
pub gain: f32,
/// Half-width of the on-target window (e.g. 0.125 → bbox centre in
/// [0.375, 0.625] is considered centred). Tests use this to assert
/// AC-1 convergence; production uses it for `command_in_flight`
/// telemetry.
pub centre_half_width: f32,
pub max_missed_ticks: u32,
}
impl Default for CentreOnTargetConfig {
fn default() -> Self {
Self {
fov_deg_at_zoom1: 60.0,
gain: DEFAULT_TARGET_GAIN,
centre_half_width: DEFAULT_CENTRE_WINDOW / 2.0,
max_missed_ticks: DEFAULT_MAX_MISSED_TICKS,
}
}
}
/// What `tick` decided. `command` is `None` when no bbox is present
/// (the gimbal holds its current pose); `target_lost_signal` fires
/// exactly once per loss streak, on the tick that crosses the
/// `max_missed_ticks` threshold.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CentreOnTargetOutput {
pub command: Option<GimbalCommand>,
pub target_lost_signal: bool,
pub on_target: bool,
}
pub struct CentreOnTarget {
config: CentreOnTargetConfig,
consecutive_missed: u32,
/// True while in a sustained loss state. Reset to `false` on bbox
/// return so the next loss streak gets its own `target_lost`
/// signal. Without this, a flickering target would re-fire the
/// signal every `max_missed_ticks` ticks.
in_loss_state: bool,
}
impl CentreOnTarget {
pub fn new(config: CentreOnTargetConfig) -> Self {
Self {
config,
consecutive_missed: 0,
in_loss_state: false,
}
}
pub fn config(&self) -> &CentreOnTargetConfig {
&self.config
}
/// Step the loop with this tick's bbox observation and the gimbal's
/// current pose. Pass `bbox = None` to indicate the target was not
/// visible in this frame.
pub fn tick(
&mut self,
bbox: Option<BoundingBox>,
current_yaw_deg: f32,
current_pitch_deg: f32,
current_zoom: f32,
) -> CentreOnTargetOutput {
let Some(bbox) = bbox else {
return self.handle_missed_tick();
};
self.consecutive_missed = 0;
self.in_loss_state = false;
let cx = (bbox.x_min + bbox.x_max) * 0.5;
let cy = (bbox.y_min + bbox.y_max) * 0.5;
let err_x = cx - 0.5;
let err_y = cy - 0.5;
let on_target =
err_x.abs() <= self.config.centre_half_width && err_y.abs() <= self.config.centre_half_width;
// Effective FOV shrinks as zoom grows; the same pixel error
// therefore corresponds to a smaller angular error at high
// zoom and we apply a proportionally smaller correction.
let zoom_factor = current_zoom.max(0.1);
let fov = self.config.fov_deg_at_zoom1 / zoom_factor;
let delta_yaw = err_x * fov * self.config.gain;
let delta_pitch = -err_y * fov * self.config.gain;
CentreOnTargetOutput {
command: Some(GimbalCommand {
yaw_deg: current_yaw_deg + delta_yaw,
pitch_deg: current_pitch_deg + delta_pitch,
}),
target_lost_signal: false,
on_target,
}
}
fn handle_missed_tick(&mut self) -> CentreOnTargetOutput {
if !self.in_loss_state {
self.consecutive_missed = self.consecutive_missed.saturating_add(1);
}
let should_signal =
!self.in_loss_state && self.consecutive_missed >= self.config.max_missed_ticks;
if should_signal {
self.in_loss_state = true;
}
CentreOnTargetOutput {
command: None,
target_lost_signal: should_signal,
on_target: false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn bbox_at(cx: f32, cy: f32, w: f32, h: f32) -> BoundingBox {
BoundingBox {
x_min: cx - w / 2.0,
y_min: cy - h / 2.0,
x_max: cx + w / 2.0,
y_max: cy + h / 2.0,
}
}
/// AC-1 — convergence within 3 ticks under nominal kinematics.
///
/// The unit test models the gimbal as a rigid yaw/pitch rig where
/// commanding `delta_yaw` shifts the apparent bbox in the opposite
/// direction by `delta_yaw / fov_at_zoom`. This is the same
/// linearised camera model the proportional controller assumes; it
/// lets us verify the loop *direction* and convergence speed in
/// isolation, without a real frame source.
#[test]
fn ac1_centre_25pct_within_3_ticks() {
// Arrange
let cfg = CentreOnTargetConfig::default();
let mut ctrl = CentreOnTarget::new(cfg);
let mut yaw = 0.0_f32;
let mut pitch = 0.0_f32;
let zoom = 1.0;
let mut bbox = bbox_at(0.75, 0.55, 0.1, 0.1);
let fov = cfg.fov_deg_at_zoom1 / zoom;
// Act: three ticks of the closed loop
let mut on_target_after = None;
for tick_idx in 0..3 {
let out = ctrl.tick(Some(bbox), yaw, pitch, zoom);
let cmd = out.command.expect("loop should emit a command on every tick with bbox");
let dy = cmd.yaw_deg - yaw;
let dp = cmd.pitch_deg - pitch;
yaw = cmd.yaw_deg;
pitch = cmd.pitch_deg;
// Kinematic model: commanding +dy yaw moves the world's
// image -dy/fov in normalized x.
let cx = (bbox.x_min + bbox.x_max) * 0.5 - dy / fov;
let cy = (bbox.y_min + bbox.y_max) * 0.5 + dp / fov;
bbox = bbox_at(cx, cy, 0.1, 0.1);
if out.on_target {
on_target_after = Some(tick_idx + 1);
}
}
let final_cx = (bbox.x_min + bbox.x_max) * 0.5;
let final_cy = (bbox.y_min + bbox.y_max) * 0.5;
// Assert
let centre_lo = 0.5 - cfg.centre_half_width;
let centre_hi = 0.5 + cfg.centre_half_width;
assert!(
(centre_lo..=centre_hi).contains(&final_cx),
"x = {final_cx} outside centre 25% window after 3 ticks"
);
assert!(
(centre_lo..=centre_hi).contains(&final_cy),
"y = {final_cy} outside centre 25% window after 3 ticks"
);
assert!(on_target_after.is_some(), "on_target flag never raised");
}
/// AC-3 — three consecutive missing bboxes signal target_lost
/// once, subsequent missed ticks do not re-emit, and a visible
/// bbox resets the counter so a later loss streak signals again.
#[test]
fn ac3_target_lost_emits_once_per_loss_streak() {
// Arrange
let mut ctrl = CentreOnTarget::new(CentreOnTargetConfig::default());
// Act 1: two missing ticks — no signal
for i in 0..2 {
let out = ctrl.tick(None, 0.0, 0.0, 1.0);
assert!(
!out.target_lost_signal,
"tick {i}: target_lost fired before threshold"
);
assert!(out.command.is_none());
}
// Act 2: third missing tick — signal fires exactly once
let out3 = ctrl.tick(None, 0.0, 0.0, 1.0);
// Act 3: fourth and fifth missing ticks — silent
let out4 = ctrl.tick(None, 0.0, 0.0, 1.0);
let out5 = ctrl.tick(None, 0.0, 0.0, 1.0);
// Assert
assert!(out3.target_lost_signal, "target_lost did not fire at tick 3");
assert!(!out4.target_lost_signal, "target_lost re-fired during sustained loss");
assert!(!out5.target_lost_signal, "target_lost re-fired during sustained loss");
// Act 4: bbox returns → loss state clears, new streak can re-fire
let recovered = ctrl.tick(Some(bbox_at(0.5, 0.5, 0.1, 0.1)), 0.0, 0.0, 1.0);
assert!(recovered.command.is_some(), "recovery tick must emit command");
assert!(!recovered.target_lost_signal);
for _ in 0..2 {
assert!(!ctrl.tick(None, 0.0, 0.0, 1.0).target_lost_signal);
}
let lost_again = ctrl.tick(None, 0.0, 0.0, 1.0);
assert!(lost_again.target_lost_signal, "second loss streak did not fire");
}
#[test]
fn bbox_already_centred_marks_on_target_with_small_command() {
// Arrange
let mut ctrl = CentreOnTarget::new(CentreOnTargetConfig::default());
// Act
let out = ctrl.tick(Some(bbox_at(0.5, 0.5, 0.1, 0.1)), 0.0, 0.0, 1.0);
// Assert
assert!(out.on_target);
let cmd = out.command.unwrap();
assert!(cmd.yaw_deg.abs() < 0.001);
assert!(cmd.pitch_deg.abs() < 0.001);
}
#[test]
fn higher_zoom_yields_smaller_correction() {
// Arrange
let mut ctrl = CentreOnTarget::new(CentreOnTargetConfig::default());
let bbox = bbox_at(0.75, 0.5, 0.1, 0.1);
// Act
let at_1x = ctrl.tick(Some(bbox), 0.0, 0.0, 1.0).command.unwrap();
let mut ctrl2 = CentreOnTarget::new(CentreOnTargetConfig::default());
let at_4x = ctrl2.tick(Some(bbox), 0.0, 0.0, 4.0).command.unwrap();
// Assert: 4× zoom should produce ~1/4 the yaw correction
assert!(at_4x.yaw_deg.abs() < at_1x.yaw_deg.abs());
let ratio = at_4x.yaw_deg / at_1x.yaw_deg;
assert!(
(ratio - 0.25).abs() < 0.01,
"zoom-scaled correction ratio {ratio} not close to 0.25"
);
}
#[test]
fn loss_counter_caps_safely_without_overflow() {
// Arrange
let mut ctrl = CentreOnTarget::new(CentreOnTargetConfig {
max_missed_ticks: 1,
..CentreOnTargetConfig::default()
});
// Act + Assert: hammering tick(None) doesn't overflow consecutive_missed
for i in 0..10_000 {
let out = ctrl.tick(None, 0.0, 0.0, 1.0);
if i == 0 {
assert!(out.target_lost_signal);
} else {
assert!(!out.target_lost_signal);
}
}
}
#[test]
fn loss_streak_below_threshold_then_recovery_does_not_signal() {
// Arrange
let mut ctrl = CentreOnTarget::new(CentreOnTargetConfig {
max_missed_ticks: 5,
..CentreOnTargetConfig::default()
});
// Act
for _ in 0..3 {
assert!(!ctrl.tick(None, 0.0, 0.0, 1.0).target_lost_signal);
}
let recovered = ctrl.tick(Some(bbox_at(0.5, 0.5, 0.1, 0.1)), 0.0, 0.0, 1.0);
// Assert
assert!(!recovered.target_lost_signal);
assert!(recovered.command.is_some());
}
}
@@ -1,4 +1,7 @@
//! Internal modules for `gimbal_controller`. Not part of the public API.
pub mod a40_protocol;
pub mod centre_on_target;
pub mod smooth_pan;
pub mod sweep;
pub mod transport;
@@ -0,0 +1,374 @@
//! AZ-655 — Smooth-pan path-tracking plan executor.
//!
//! Linearly interpolates `(yaw, pitch, zoom)` between adjacent
//! [`PanGoal`]s in a [`PanPlan`] and self-throttles emission so the
//! vendor command rate is bounded. The composition root constructs one
//! executor per `gimbal_controller`; the executor is stateless until a
//! plan is loaded.
//!
//! Throttle vs. interpolation is the key design decision: callers may
//! tick the executor at any rate (e.g. the scan_controller's 10 ms
//! frame loop), and the executor decides whether enough wall-time has
//! elapsed since the last emission to issue a new command. Calls that
//! fall inside the throttle window return [`NextStep::Throttled`] (and
//! bump the dropped-commands counter); calls outside the window
//! interpolate to the current plan-time and return
//! [`NextStep::Emit(cmd)`].
use std::time::{Duration, Instant};
use shared::error::{AutopilotError, Result};
use shared::models::gimbal::{PanGoal, PanPlan};
use crate::GimbalCommand;
pub const DEFAULT_MIN_CMD_INTERVAL: Duration = Duration::from_millis(50);
/// What `next_step` decided for this tick. `Emit` carries the
/// interpolated command; `Throttled` means the call fell inside the
/// throttle window and no command should be sent.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum NextStep {
Emit(GimbalCommand),
Throttled,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ExecutorStats {
pub plan_loaded_at: Option<Instant>,
pub commands_emitted_total: u64,
pub commands_dropped_to_throttle_total: u64,
}
pub struct PlanExecutor {
min_cmd_interval: Duration,
plan: Option<LoadedPlan>,
last_emit_at: Option<Instant>,
stats: ExecutorStats,
}
struct LoadedPlan {
plan: PanPlan,
loaded_at: Instant,
}
impl PlanExecutor {
pub fn new(min_cmd_interval: Duration) -> Self {
Self {
min_cmd_interval,
plan: None,
last_emit_at: None,
stats: ExecutorStats::default(),
}
}
pub fn with_default_throttle() -> Self {
Self::new(DEFAULT_MIN_CMD_INTERVAL)
}
/// Load a new plan, anchoring its relative `at_ns` axis to `now`.
/// Goals must be ordered strictly increasing in `at_ns`; empty
/// plans are rejected. Re-loading replaces the current plan.
pub fn load(&mut self, plan: PanPlan, now: Instant) -> Result<()> {
validate_plan(&plan)?;
self.plan = Some(LoadedPlan {
plan,
loaded_at: now,
});
self.stats.plan_loaded_at = Some(now);
// Clear the throttle anchor on plan reload so the first command
// of a new plan emits immediately instead of being held back by
// the previous plan's last_emit_at.
self.last_emit_at = None;
Ok(())
}
pub fn has_plan(&self) -> bool {
self.plan.is_some()
}
pub fn stats(&self) -> ExecutorStats {
self.stats
}
/// Compute the command for `now`. Returns `NextStep::Throttled`
/// when called inside the min-interval window since the last
/// emission; returns `Err` only when no plan is loaded.
pub fn next_step(&mut self, now: Instant) -> Result<NextStep> {
let loaded = self.plan.as_ref().ok_or_else(|| {
AutopilotError::Validation("PlanExecutor::next_step: no plan loaded".into())
})?;
if let Some(last) = self.last_emit_at {
if now.duration_since(last) < self.min_cmd_interval {
self.stats.commands_dropped_to_throttle_total += 1;
return Ok(NextStep::Throttled);
}
}
let elapsed_ns = now
.saturating_duration_since(loaded.loaded_at)
.as_nanos()
.min(u64::MAX as u128) as u64;
let cmd = interpolate(&loaded.plan, elapsed_ns);
self.last_emit_at = Some(now);
self.stats.commands_emitted_total += 1;
Ok(NextStep::Emit(cmd))
}
}
fn validate_plan(plan: &PanPlan) -> Result<()> {
if plan.goals.is_empty() {
return Err(AutopilotError::Validation(
"PanPlan: goals must not be empty".into(),
));
}
for win in plan.goals.windows(2) {
if win[1].at_ns <= win[0].at_ns {
return Err(AutopilotError::Validation(format!(
"PanPlan: at_ns must be strictly increasing ({}{})",
win[0].at_ns, win[1].at_ns
)));
}
}
Ok(())
}
/// Linear interpolation between adjacent goals. Before the first goal:
/// extrapolate linearly from the first two goals (or clamp to the
/// first goal if only one exists). After the last goal: clamp to the
/// last goal.
fn interpolate(plan: &PanPlan, t_ns: u64) -> GimbalCommand {
let goals = &plan.goals;
if goals.len() == 1 {
return goal_to_command(&goals[0]);
}
if t_ns <= goals[0].at_ns {
return linear_at(&goals[0], &goals[1], t_ns);
}
if t_ns >= goals[goals.len() - 1].at_ns {
return goal_to_command(&goals[goals.len() - 1]);
}
for win in goals.windows(2) {
if t_ns >= win[0].at_ns && t_ns <= win[1].at_ns {
return linear_at(&win[0], &win[1], t_ns);
}
}
goal_to_command(&goals[goals.len() - 1])
}
fn linear_at(a: &PanGoal, b: &PanGoal, t_ns: u64) -> GimbalCommand {
let span = b.at_ns as f64 - a.at_ns as f64;
let frac = if span.abs() < 1.0 {
0.0
} else {
(t_ns as f64 - a.at_ns as f64) / span
};
let frac = frac as f32;
GimbalCommand {
yaw_deg: lerp(a.yaw_deg, b.yaw_deg, frac),
pitch_deg: lerp(a.pitch_deg, b.pitch_deg, frac),
}
}
fn lerp(a: f32, b: f32, t: f32) -> f32 {
a + (b - a) * t
}
fn goal_to_command(g: &PanGoal) -> GimbalCommand {
GimbalCommand {
yaw_deg: g.yaw_deg,
pitch_deg: g.pitch_deg,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn two_goal_plan() -> PanPlan {
PanPlan {
goals: vec![
PanGoal {
yaw_deg: 0.0,
pitch_deg: 0.0,
zoom: 1.0,
at_ns: 0,
},
PanGoal {
yaw_deg: 30.0,
pitch_deg: 0.0,
zoom: 1.0,
at_ns: 1_000_000_000,
},
],
}
}
#[test]
fn ac1_linear_interp_midpoint() {
// Arrange
let mut exe = PlanExecutor::new(Duration::ZERO);
let t0 = Instant::now();
exe.load(two_goal_plan(), t0).unwrap();
// Act
let step = exe.next_step(t0 + Duration::from_millis(500)).unwrap();
// Assert
match step {
NextStep::Emit(cmd) => {
let diff = (cmd.yaw_deg - 15.0).abs();
assert!(diff < 0.01, "yaw at t=500ms was {}, want ~15.0", cmd.yaw_deg);
}
NextStep::Throttled => panic!("first emission should not be throttled"),
}
}
#[test]
fn ac2_throttle_drops_intermediate_calls() {
// Arrange
let mut exe = PlanExecutor::new(Duration::from_millis(100));
let t0 = Instant::now();
exe.load(two_goal_plan(), t0).unwrap();
// Act: call every 10 ms for ~1 s
let mut emitted = 0_u64;
let mut throttled = 0_u64;
for i in 0..100 {
match exe.next_step(t0 + Duration::from_millis(i * 10)).unwrap() {
NextStep::Emit(_) => emitted += 1,
NextStep::Throttled => throttled += 1,
}
}
// Assert: at 100 ms cadence over 1 s window we get ~10 emissions
assert!(
(9..=11).contains(&emitted),
"expected ~10 emits, got {emitted}"
);
assert_eq!(emitted + throttled, 100, "every tick must be accounted for");
assert_eq!(exe.stats().commands_emitted_total, emitted);
assert_eq!(exe.stats().commands_dropped_to_throttle_total, throttled);
}
#[test]
fn ac3_past_plan_end_clamps_to_last_goal() {
// Arrange
let mut exe = PlanExecutor::new(Duration::ZERO);
let t0 = Instant::now();
exe.load(two_goal_plan(), t0).unwrap();
// Act: plan ends at 1s; query at 5s
let step = exe.next_step(t0 + Duration::from_secs(5)).unwrap();
// Assert
match step {
NextStep::Emit(cmd) => {
assert!((cmd.yaw_deg - 30.0).abs() < 0.01);
assert!((cmd.pitch_deg - 0.0).abs() < 0.01);
}
NextStep::Throttled => panic!(),
}
}
#[test]
fn empty_plan_rejected() {
// Arrange
let mut exe = PlanExecutor::new(Duration::ZERO);
// Act
let err = exe
.load(PanPlan { goals: vec![] }, Instant::now())
.unwrap_err();
// Assert
assert!(matches!(err, AutopilotError::Validation(_)));
}
#[test]
fn non_monotonic_plan_rejected() {
// Arrange
let plan = PanPlan {
goals: vec![
PanGoal {
yaw_deg: 0.0,
pitch_deg: 0.0,
zoom: 1.0,
at_ns: 1_000,
},
PanGoal {
yaw_deg: 1.0,
pitch_deg: 0.0,
zoom: 1.0,
at_ns: 1_000,
},
],
};
let mut exe = PlanExecutor::new(Duration::ZERO);
// Act + Assert
assert!(matches!(
exe.load(plan, Instant::now()).unwrap_err(),
AutopilotError::Validation(_)
));
}
#[test]
fn no_plan_returns_error() {
// Arrange
let mut exe = PlanExecutor::new(Duration::ZERO);
// Act + Assert
assert!(matches!(
exe.next_step(Instant::now()).unwrap_err(),
AutopilotError::Validation(_)
));
}
#[test]
fn reload_clears_throttle_anchor() {
// Arrange
let mut exe = PlanExecutor::new(Duration::from_millis(100));
let t0 = Instant::now();
exe.load(two_goal_plan(), t0).unwrap();
let _ = exe.next_step(t0).unwrap();
// Act: reload immediately and step again at the same tick
exe.load(two_goal_plan(), t0 + Duration::from_millis(10))
.unwrap();
let step = exe.next_step(t0 + Duration::from_millis(10)).unwrap();
// Assert: new plan emits on first tick (no carry-over throttle)
assert!(matches!(step, NextStep::Emit(_)));
}
#[test]
fn single_goal_plan_holds_value() {
// Arrange
let plan = PanPlan {
goals: vec![PanGoal {
yaw_deg: 12.5,
pitch_deg: -3.0,
zoom: 2.0,
at_ns: 500,
}],
};
let mut exe = PlanExecutor::new(Duration::ZERO);
let t0 = Instant::now();
exe.load(plan, t0).unwrap();
// Act
let step = exe.next_step(t0 + Duration::from_secs(10)).unwrap();
// Assert
match step {
NextStep::Emit(cmd) => {
assert!((cmd.yaw_deg - 12.5).abs() < 0.001);
assert!((cmd.pitch_deg - (-3.0)).abs() < 0.001);
}
NextStep::Throttled => panic!(),
}
}
}
@@ -0,0 +1,350 @@
//! AZ-654 — Zoom-out sweep pattern primitive.
//!
//! Implements [`SweepPattern::Pendulum`] as the default; `Raster` and
//! `LawnMower` are reserved enum variants that return
//! [`AutopilotError::NotImplemented`] from [`SweepEngine::next_step`].
//! This explicit failure (vs. a silent fallback to Pendulum) is required
//! by AC-3 in the task spec — pattern selection must never silently
//! drift.
//!
//! Time is injected via `next_step(now: Instant)` so the dwell-timer
//! behaviour (AC-2) can be tested deterministically without sleeping.
//! Production callers pass `Instant::now()` from the actor's tick loop.
use std::time::{Duration, Instant};
use shared::error::{AutopilotError, Result};
use crate::GimbalCommand;
/// Selectable sweep pattern. `Pendulum` is the default; `Raster` and
/// `LawnMower` are reserved for future implementation (see
/// `architecture.md §8 Q1`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SweepPattern {
#[default]
Pendulum,
Raster,
LawnMower,
}
/// Sweep envelope + step kinematics. Loaded from startup config; the
/// composition root passes one instance per `gimbal_controller`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SweepConfig {
pub min_yaw_deg: f32,
pub max_yaw_deg: f32,
pub pitch_deg: f32,
/// How far the pendulum advances per `next_step` call. Must be
/// strictly positive; sign is chosen internally by the engine.
pub step_deg: f32,
/// Time to hold at a bound before reversing direction.
pub dwell: Duration,
}
impl SweepConfig {
/// Validates that the config is internally consistent. Called once
/// by [`SweepEngine::new`]; further calls to `next_step` assume
/// validity (no per-tick re-check).
fn validate(&self) -> Result<()> {
if self.min_yaw_deg >= self.max_yaw_deg {
return Err(AutopilotError::Validation(format!(
"sweep: min_yaw_deg ({}) must be < max_yaw_deg ({})",
self.min_yaw_deg, self.max_yaw_deg
)));
}
if !self.step_deg.is_finite() || self.step_deg <= 0.0 {
return Err(AutopilotError::Validation(format!(
"sweep: step_deg must be > 0, got {}",
self.step_deg
)));
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Direction {
Forward,
Reverse,
}
impl Direction {
fn flip(self) -> Self {
match self {
Self::Forward => Self::Reverse,
Self::Reverse => Self::Forward,
}
}
fn sign(self) -> f32 {
match self {
Self::Forward => 1.0,
Self::Reverse => -1.0,
}
}
}
/// State machine that produces the next sweep command on each tick.
/// Owns the current yaw target and the dwell-at-bound timer.
#[derive(Debug)]
pub struct SweepEngine {
pattern: SweepPattern,
config: SweepConfig,
current_yaw: f32,
direction: Direction,
/// `Some(instant)` while the engine is dwelling at a bound waiting
/// for `config.dwell` to elapse; `None` while traversing.
dwell_started_at: Option<Instant>,
}
impl SweepEngine {
pub fn new(pattern: SweepPattern, config: SweepConfig) -> Result<Self> {
config.validate()?;
Ok(Self {
pattern,
config,
current_yaw: config.min_yaw_deg,
direction: Direction::Forward,
dwell_started_at: None,
})
}
pub fn pattern(&self) -> SweepPattern {
self.pattern
}
/// Produce the command for tick `now`. Behaviour by pattern:
/// - `Pendulum`: advances yaw by `step_deg` toward the active
/// bound; on reaching the bound, dwells for `config.dwell` then
/// reverses direction.
/// - `Raster` / `LawnMower`: returns
/// [`AutopilotError::NotImplemented`] — wired but not yet
/// implemented. The caller must surface this error rather than
/// silently fall back to Pendulum (AC-3).
pub fn next_step(&mut self, now: Instant) -> Result<GimbalCommand> {
match self.pattern {
SweepPattern::Pendulum => Ok(self.next_pendulum(now)),
SweepPattern::Raster => Err(AutopilotError::NotImplemented(
"gimbal_controller::sweep: Raster pattern not implemented (Q1 pending)",
)),
SweepPattern::LawnMower => Err(AutopilotError::NotImplemented(
"gimbal_controller::sweep: LawnMower pattern not implemented (Q1 pending)",
)),
}
}
fn next_pendulum(&mut self, now: Instant) -> GimbalCommand {
if let Some(started) = self.dwell_started_at {
if now.duration_since(started) < self.config.dwell {
return self.command();
}
self.dwell_started_at = None;
self.direction = self.direction.flip();
}
let next = self.current_yaw + self.direction.sign() * self.config.step_deg;
let (clamped, hit_bound) = self.clamp_to_bounds(next);
self.current_yaw = clamped;
if hit_bound {
self.dwell_started_at = Some(now);
}
self.command()
}
fn clamp_to_bounds(&self, candidate: f32) -> (f32, bool) {
if candidate >= self.config.max_yaw_deg {
(self.config.max_yaw_deg, true)
} else if candidate <= self.config.min_yaw_deg {
(self.config.min_yaw_deg, true)
} else {
(candidate, false)
}
}
fn command(&self) -> GimbalCommand {
GimbalCommand {
yaw_deg: self.current_yaw,
pitch_deg: self.config.pitch_deg,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg() -> SweepConfig {
SweepConfig {
min_yaw_deg: -30.0,
max_yaw_deg: 30.0,
pitch_deg: -10.0,
step_deg: 5.0,
dwell: Duration::from_millis(500),
}
}
#[test]
fn ac1_pendulum_stays_within_bounds_over_100_steps() {
// Arrange
let mut engine = SweepEngine::new(SweepPattern::Pendulum, cfg()).unwrap();
let mut t = Instant::now();
// Act
let mut last_dir_sign = 0.0_f32;
let mut reversals = 0_u32;
for _ in 0..100 {
let cmd = engine.next_step(t).unwrap();
assert!(
cmd.yaw_deg >= cfg().min_yaw_deg && cmd.yaw_deg <= cfg().max_yaw_deg,
"yaw {} out of bounds",
cmd.yaw_deg
);
let dir = engine.direction.sign();
if last_dir_sign != 0.0 && (dir - last_dir_sign).abs() > 0.01 {
reversals += 1;
}
last_dir_sign = dir;
t += Duration::from_secs(1);
}
// Assert
assert!(
reversals > 0,
"pendulum never reversed direction in 100 steps"
);
}
#[test]
fn ac2_dwell_holds_yaw_at_bound() {
// Arrange
let mut engine = SweepEngine::new(SweepPattern::Pendulum, cfg()).unwrap();
let start = Instant::now();
let mut t = start;
for _ in 0..20 {
let _ = engine.next_step(t).unwrap();
if engine.dwell_started_at.is_some() {
break;
}
t += Duration::from_millis(10);
}
let yaw_at_bound = engine.current_yaw;
let dir_at_bound = engine.direction;
assert!(
engine.dwell_started_at.is_some(),
"engine never reached a bound"
);
// Act: poll repeatedly during dwell window
let dwell_start = t;
for offset_ms in [100_u64, 200, 300, 400, 499] {
let cmd = engine
.next_step(dwell_start + Duration::from_millis(offset_ms))
.unwrap();
assert_eq!(
cmd.yaw_deg, yaw_at_bound,
"yaw moved during dwell window at +{offset_ms}ms"
);
assert_eq!(
engine.direction, dir_at_bound,
"direction flipped before dwell elapsed at +{offset_ms}ms"
);
}
// After 500 ms the dwell is satisfied; next call may reverse
let _ = engine
.next_step(dwell_start + Duration::from_millis(501))
.unwrap();
// Assert
assert_ne!(
engine.direction, dir_at_bound,
"direction did not flip after dwell elapsed"
);
}
#[test]
fn ac3_raster_returns_not_implemented() {
// Arrange
let mut engine = SweepEngine::new(SweepPattern::Raster, cfg()).unwrap();
// Act
let err = engine.next_step(Instant::now()).unwrap_err();
// Assert
assert!(matches!(err, AutopilotError::NotImplemented(_)));
}
#[test]
fn ac3_lawnmower_returns_not_implemented() {
// Arrange
let mut engine = SweepEngine::new(SweepPattern::LawnMower, cfg()).unwrap();
// Act
let err = engine.next_step(Instant::now()).unwrap_err();
// Assert
assert!(matches!(err, AutopilotError::NotImplemented(_)));
}
#[test]
fn pattern_default_is_pendulum() {
assert_eq!(SweepPattern::default(), SweepPattern::Pendulum);
}
#[test]
fn invalid_config_rejected() {
// Arrange
let bad = SweepConfig {
min_yaw_deg: 10.0,
max_yaw_deg: 10.0,
pitch_deg: 0.0,
step_deg: 1.0,
dwell: Duration::from_millis(100),
};
// Act
let err = SweepEngine::new(SweepPattern::Pendulum, bad).unwrap_err();
// Assert
assert!(matches!(err, AutopilotError::Validation(_)));
}
#[test]
fn invalid_step_rejected() {
// Arrange
let bad = SweepConfig {
min_yaw_deg: -10.0,
max_yaw_deg: 10.0,
pitch_deg: 0.0,
step_deg: 0.0,
dwell: Duration::from_millis(100),
};
// Act + Assert
assert!(matches!(
SweepEngine::new(SweepPattern::Pendulum, bad).unwrap_err(),
AutopilotError::Validation(_)
));
}
#[test]
fn pendulum_advances_in_step_increments_then_clamps() {
// Arrange
let mut engine = SweepEngine::new(SweepPattern::Pendulum, cfg()).unwrap();
let t = Instant::now();
// Act: starting at -30°, step +5° per tick; should not exceed +30°.
let mut yaws = vec![];
for _ in 0..15 {
yaws.push(engine.next_step(t).unwrap().yaw_deg);
}
// Assert: forward sweep produces -25, -20, ... 30, 30 (clamped + dwell)
assert_eq!(yaws[0], -25.0);
assert_eq!(yaws[1], -20.0);
assert!(yaws.iter().all(|&y| y <= 30.0));
assert!(yaws.iter().any(|&y| (y - 30.0).abs() < 0.01));
}
}
+16 -10
View File
@@ -14,6 +14,7 @@
use serde::{Deserialize, Serialize};
use tokio::sync::watch;
use shared::clock::MonoClock;
use shared::error::{AutopilotError, Result};
use shared::health::ComponentHealth;
use shared::models::gimbal::GimbalState;
@@ -24,6 +25,14 @@ pub use internal::a40_protocol::{
build_a1_angles, build_c1_camera, build_c2_set_zoom, decode_frame, encode_frame, xor_checksum,
CameraCommand, Frame, FrameDecodeError, FrameId, ImageSensor, ServoStatus, MAX_PACKET_LEN,
};
pub use internal::centre_on_target::{
CentreOnTarget, CentreOnTargetConfig, CentreOnTargetOutput, DEFAULT_CENTRE_WINDOW,
DEFAULT_MAX_MISSED_TICKS, DEFAULT_TARGET_GAIN,
};
pub use internal::smooth_pan::{
ExecutorStats, NextStep, PlanExecutor, DEFAULT_MIN_CMD_INTERVAL,
};
pub use internal::sweep::{SweepConfig, SweepEngine, SweepPattern};
pub use internal::transport::{
A40Error, A40Transport, VendorFaults, VendorFaultsSnapshot, DEFAULT_COMMAND_DEADLINE,
DEFAULT_MAX_RETRIES, INBOUND_CHANNEL_CAPACITY,
@@ -49,6 +58,7 @@ pub struct GimbalCommand {
pub struct GimbalController {
state_tx: watch::Sender<GimbalState>,
transport: Option<A40Transport>,
clock: MonoClock,
}
impl GimbalController {
@@ -57,6 +67,7 @@ impl GimbalController {
Self {
state_tx,
transport: None,
clock: MonoClock::new(),
}
}
@@ -68,6 +79,7 @@ impl GimbalController {
Self {
state_tx,
transport: Some(transport),
clock: MonoClock::new(),
}
}
@@ -75,6 +87,7 @@ impl GimbalController {
GimbalControllerHandle {
state_tx: self.state_tx.clone(),
transport: self.transport.clone(),
clock: self.clock,
}
}
}
@@ -83,6 +96,7 @@ impl GimbalController {
pub struct GimbalControllerHandle {
state_tx: watch::Sender<GimbalState>,
transport: Option<A40Transport>,
clock: MonoClock,
}
impl GimbalControllerHandle {
@@ -105,7 +119,7 @@ impl GimbalControllerHandle {
let mut state = *self.state_tx.borrow();
state.yaw = command.yaw_deg;
state.pitch = command.pitch_deg;
state.ts_monotonic_ns = monotonic_ns();
state.ts_monotonic_ns = self.clock.elapsed_ns();
self.state_tx.send_replace(state);
Ok(())
}
@@ -127,7 +141,7 @@ impl GimbalControllerHandle {
.map_err(map_a40_error)?;
let mut state = *self.state_tx.borrow();
state.zoom = level;
state.ts_monotonic_ns = monotonic_ns();
state.ts_monotonic_ns = self.clock.elapsed_ns();
self.state_tx.send_replace(state);
Ok(())
}
@@ -192,14 +206,6 @@ fn map_a40_error(e: A40Error) -> AutopilotError {
}
}
fn monotonic_ns() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;