Skip to main content

bloqade_lanes_bytecode_core/arch/
metrics.rs

1//! Move-duration metrics: the FLAIR constant-jerk motion/timing model.
2//!
3//! [`MotionModel`] owns the physical timing constants (ramp rate, max jerk,
4//! max acceleration) and the derived move-duration formula. It is the single
5//! source of truth for lane-duration timing across the whole workspace: the
6//! Rust search ([`crate`] consumers via `bloqade-lanes-search`) and the Python
7//! layer both compute durations through this type, the latter via the
8//! `bloqade.lanes.bytecode.MotionModel` PyO3 binding.
9//!
10//! The model is *configurable*: [`MotionModel::default`] reproduces the FLAIR
11//! constants exactly (see [`FLAIR_MAX_RAMP_US`] and friends), but callers may
12//! construct a [`MotionModel`] with different constants to model a different
13//! motion profile without touching this code.
14
15/// FLAIR maximum amplitude ramp rate (amplitude units per µs).
16///
17/// The ramp time for a pick or drop is `amplitude / max_ramp_us`.
18/// Extracted from bloqade-flair's constant-jerk motion model.
19pub const FLAIR_MAX_RAMP_US: f64 = 0.2;
20/// FLAIR maximum jerk in µm/µs³. Extracted from bloqade-flair.
21pub const FLAIR_MAX_JERK_UM_PER_US3: f64 = 0.0004;
22/// FLAIR maximum acceleration in µm/µs². Extracted from bloqade-flair.
23pub const FLAIR_MAX_ACCEL_UM_PER_US2: f64 = 0.0015;
24
25/// Distance below which a move is treated as zero-duration (µm).
26const MIN_MOVE_DISTANCE_UM: f64 = 1e-8;
27
28/// Constant-jerk motion/timing model for AOD moves.
29///
30/// Holds the three timing constants and computes move durations from a
31/// waypoint path. Defaults to the FLAIR constants; construct with
32/// [`MotionModel::new`] to override them.
33#[derive(Debug, Clone, Copy, PartialEq)]
34pub struct MotionModel {
35    /// Maximum amplitude ramp rate (amplitude units per µs). Must be > 0.
36    pub max_ramp_us: f64,
37    /// Maximum jerk in µm/µs³. Must be > 0.
38    pub max_jerk_um_per_us3: f64,
39    /// Maximum acceleration in µm/µs². Must be > 0.
40    pub max_accel_um_per_us2: f64,
41}
42
43impl Default for MotionModel {
44    /// The FLAIR constant-jerk motion model.
45    fn default() -> Self {
46        Self {
47            max_ramp_us: FLAIR_MAX_RAMP_US,
48            max_jerk_um_per_us3: FLAIR_MAX_JERK_UM_PER_US3,
49            max_accel_um_per_us2: FLAIR_MAX_ACCEL_UM_PER_US2,
50        }
51    }
52}
53
54impl MotionModel {
55    /// Construct a motion model from explicit constants.
56    ///
57    /// All three constants must be positive and finite. `max_ramp_us` and
58    /// `max_jerk_um_per_us3` appear as divisors; a non-positive
59    /// `max_accel_um_per_us2` drives `t1` to zero (or negative), which makes
60    /// the trajectory solver return `NaN`/negative durations. Returns `None`
61    /// for any non-physical constant.
62    pub fn new(
63        max_ramp_us: f64,
64        max_jerk_um_per_us3: f64,
65        max_accel_um_per_us2: f64,
66    ) -> Option<Self> {
67        if !max_ramp_us.is_finite()
68            || !max_jerk_um_per_us3.is_finite()
69            || !max_accel_um_per_us2.is_finite()
70            || max_ramp_us <= 0.0
71            || max_jerk_um_per_us3 <= 0.0
72            || max_accel_um_per_us2 <= 0.0
73        {
74            return None;
75        }
76        Some(Self {
77            max_ramp_us,
78            max_jerk_um_per_us3,
79            max_accel_um_per_us2,
80        })
81    }
82
83    /// The FLAIR constant-jerk motion model (alias for [`Default`]).
84    pub fn flair() -> Self {
85        Self::default()
86    }
87
88    /// Minimum duration (µs) for a constant-jerk move over `max_dist_um`.
89    ///
90    /// Solves the constant-jerk trajectory: below the acceleration cap the
91    /// move is jerk-limited (four jerk phases); above it, two extra
92    /// constant-acceleration phases are inserted.
93    ///
94    /// The sign of `max_dist_um` is ignored (the absolute distance is used);
95    /// a distance below ~1e-8 µm is treated as no move and returns 0.0.
96    pub fn const_jerk_min_duration_us(&self, max_dist_um: f64) -> f64 {
97        let max_dist_um = max_dist_um.abs();
98        if max_dist_um < MIN_MOVE_DISTANCE_UM {
99            return 0.0;
100        }
101
102        let t1 = self.max_accel_um_per_us2 / self.max_jerk_um_per_us3;
103        let a = self.max_jerk_um_per_us3 * t1;
104        let b = 3.0 * self.max_jerk_um_per_us3 * t1 * t1;
105        let c = 2.0 * self.max_jerk_um_per_us3 * t1 * t1 * t1 - max_dist_um;
106
107        if c >= 0.0 {
108            let t1_jerk = (max_dist_um / (2.0 * self.max_jerk_um_per_us3)).cbrt();
109            return 4.0 * t1_jerk;
110        }
111
112        let discriminant = b * b - 4.0 * a * c;
113        let t2 = (-b + discriminant.sqrt()) / (2.0 * a);
114        4.0 * t1 + 2.0 * t2
115    }
116
117    /// Compute lane duration (µs) from a waypoint path:
118    /// `ramp + Σ per-segment const-jerk duration + ramp`.
119    ///
120    /// The pick and drop ramps are always charged, so a path with no motion
121    /// segments (fewer than two waypoints) still costs `2 * ramp` rather than
122    /// collapsing to a free move. `amplitude_delta` scales the ramp time; its
123    /// sign is ignored.
124    pub fn lane_duration_us(&self, waypoints: &[[f64; 2]], amplitude_delta: f64) -> f64 {
125        let ramp = amplitude_delta.abs() / self.max_ramp_us;
126        let segment_sum: f64 = waypoints
127            .windows(2)
128            .map(|w| {
129                let dx = w[1][0] - w[0][0];
130                let dy = w[1][1] - w[0][1];
131                self.const_jerk_min_duration_us((dx * dx + dy * dy).sqrt())
132            })
133            .sum();
134        ramp + segment_sum + ramp
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn default_matches_flair_constants() {
144        let m = MotionModel::default();
145        assert_eq!(m.max_ramp_us, FLAIR_MAX_RAMP_US);
146        assert_eq!(m.max_jerk_um_per_us3, FLAIR_MAX_JERK_UM_PER_US3);
147        assert_eq!(m.max_accel_um_per_us2, FLAIR_MAX_ACCEL_UM_PER_US2);
148        assert_eq!(m, MotionModel::flair());
149    }
150
151    #[test]
152    fn zero_and_tiny_distance_is_zero_duration() {
153        let m = MotionModel::default();
154        assert_eq!(m.const_jerk_min_duration_us(0.0), 0.0);
155        assert_eq!(m.const_jerk_min_duration_us(1e-12), 0.0);
156    }
157
158    #[test]
159    fn duration_is_even_in_sign_and_increasing() {
160        let m = MotionModel::default();
161        let d10 = m.const_jerk_min_duration_us(10.0);
162        assert_eq!(d10, m.const_jerk_min_duration_us(-10.0));
163        assert!(d10 > 0.0);
164        assert!(m.const_jerk_min_duration_us(50.0) > d10);
165    }
166
167    #[test]
168    fn both_trajectory_branches_are_exercised() {
169        let m = MotionModel::default();
170        // t1 = accel/jerk = 3.75 µs; jerk-only reach = 2*jerk*t1^3 ≈ 0.0422 µm.
171        // A sub-threshold distance takes the pure-jerk (cbrt) branch; a large
172        // one takes the constant-acceleration (quadratic) branch. Both must be
173        // finite and ordered.
174        let small = m.const_jerk_min_duration_us(0.01);
175        let large = m.const_jerk_min_duration_us(100.0);
176        assert!(small.is_finite() && small > 0.0);
177        assert!(large.is_finite() && large > small);
178    }
179
180    #[test]
181    fn lane_duration_is_ramp_plus_segments_plus_ramp() {
182        let m = MotionModel::default();
183        let waypoints = [[0.0, 0.0], [3.0, 4.0]]; // one 5 µm segment
184        let ramp = 1.0 / m.max_ramp_us;
185        let expected = ramp + m.const_jerk_min_duration_us(5.0) + ramp;
186        assert_eq!(m.lane_duration_us(&waypoints, 1.0), expected);
187    }
188
189    #[test]
190    fn lane_duration_of_pathless_move_is_two_ramps() {
191        let m = MotionModel::default();
192        // No motion segments — still pays the pick and drop ramps, never free.
193        let two_ramps = 2.0 * (1.0 / m.max_ramp_us);
194        assert_eq!(m.lane_duration_us(&[], 1.0), two_ramps);
195        assert_eq!(m.lane_duration_us(&[[1.0, 2.0]], 1.0), two_ramps);
196    }
197
198    #[test]
199    fn amplitude_scales_ramp_only() {
200        let m = MotionModel::default();
201        let waypoints = [[0.0, 0.0], [3.0, 4.0]];
202        let seg = m.const_jerk_min_duration_us(5.0);
203        // ramp = amp / max_ramp; total ramp contribution is 2 * ramp.
204        assert_eq!(
205            m.lane_duration_us(&waypoints, 2.0),
206            2.0 * (2.0 / m.max_ramp_us) + seg
207        );
208        // Sign of amplitude is ignored.
209        assert_eq!(
210            m.lane_duration_us(&waypoints, -1.0),
211            m.lane_duration_us(&waypoints, 1.0)
212        );
213    }
214
215    #[test]
216    fn new_rejects_non_physical_constants() {
217        assert!(MotionModel::new(0.0, 1.0, 1.0).is_none());
218        assert!(MotionModel::new(1.0, 0.0, 1.0).is_none());
219        assert!(MotionModel::new(1.0, 1.0, 0.0).is_none());
220        assert!(MotionModel::new(1.0, 1.0, -1.0).is_none());
221        assert!(MotionModel::new(-1.0, 1.0, 1.0).is_none());
222        assert!(MotionModel::new(f64::NAN, 1.0, 1.0).is_none());
223        assert!(MotionModel::new(0.2, 0.0004, 0.0015).is_some());
224    }
225}