Skip to main content

edgefirst_tracker/
kalman.rs

1// SPDX-FileCopyrightText: Copyright 2025 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4//! Kalman filtering for tracklet motion.
5//!
6//! Each tracklet owns one [`ConstantVelocityXYAHModel2`], a constant-velocity
7//! filter over the state `[x, y, a, h, ẋ, ẏ, ȧ, ḣ]` — box centre, aspect
8//! ratio, height, and their velocities. Tracking in XYAH rather than XYXY
9//! means an object's aspect ratio is modelled as one slowly-varying quantity
10//! instead of being smeared across four correlated corner coordinates.
11//!
12//! The filter supplies the predicted box that
13//! [`crate::bytetrack::ByteTrack`] matches detections against, and the
14//! smoothed box reported as [`crate::TrackInfo::tracked_location`]. Nothing
15//! here is public API beyond the model type itself; the tracker drives it.
16
17use nalgebra::{
18    allocator::Allocator, convert, dimension::U4, DVector, DefaultAllocator, Dyn, OMatrix,
19    RealField, SVector, U1, U8,
20};
21
22/// Constant-velocity Kalman filter for tracking a bounding box in XYAH space.
23///
24/// The state vector is `[x, y, a, h, ẋ, ẏ, ȧ, ḣ]` (8 dimensions):
25///
26/// | Component | Meaning |
27/// |-----------|---------|
28/// | `x` | Centre x coordinate |
29/// | `y` | Centre y coordinate |
30/// | `a` | Aspect ratio (`width / height`), kept > 0 by a small epsilon floor |
31/// | `h` | Height |
32/// | `ẋ, ẏ, ȧ, ḣ` | First derivatives of the position components |
33///
34/// Measurements arriving via [`update`](Self::update) are in 4-dimensional
35/// XYAH space (no velocity), projected from the 8-D state by the observation
36/// matrix `H`.  The filter uses a scale-adaptive process-noise covariance:
37/// noise magnitude scales proportionally with the box height `h`, so
38/// fast-moving large objects tolerate wider uncertainty than small static ones.
39///
40/// This is the same Kalman filter parameterisation used in the original
41/// ByteTrack paper (Zhang et al., 2022).
42#[derive(Debug, Clone)]
43pub struct ConstantVelocityXYAHModel2<R>
44where
45    R: RealField,
46    DefaultAllocator: Allocator<U8, U8>,
47    DefaultAllocator: Allocator<U8>,
48{
49    /// Current state estimate: `[x, y, a, h, ẋ, ẏ, ȧ, ḣ]`.
50    pub mean: SVector<R, 8>,
51
52    /// Scale factor for position noise (`1/20` by default). Process noise for
53    /// position components is `std_weight_position * h`.
54    pub std_weight_position: R,
55
56    /// Scale factor for velocity noise (`1/160` by default). Process noise for
57    /// velocity components is `std_weight_velocity * h`.
58    pub std_weight_velocity: R,
59
60    /// Measurement-to-innovation scaling applied in [`update`](Self::update).
61    /// Set via [`ByteTrackBuilder::track_update`](crate::bytetrack::ByteTrackBuilder::track_update).
62    /// Values in `(0, 1]`; lower → smoother (trusts prediction more).
63    pub update_factor: R,
64
65    /// State-transition matrix `F` (identity + velocity block). Projects the
66    /// state forward by one time step.
67    motion_matrix: OMatrix<R, U8, U8>,
68
69    /// Observation matrix `H`. Extracts the `[x, y, a, h]` slice from the
70    /// 8-D state for comparison against incoming measurements.
71    update_matrix: OMatrix<R, U4, U8>,
72
73    /// Current state covariance matrix `P` (8×8). Grows on each
74    /// [`predict`](Self::predict) and shrinks on each [`update`](Self::update).
75    pub covariance: OMatrix<R, U8, U8>,
76}
77
78/// Distance metric used by [`ConstantVelocityXYAHModel2::gating_distance`].
79///
80/// `Mahalanobis` accounts for the current state uncertainty and is the
81/// theoretically correct gating criterion; `Gaussian` (squared Euclidean) is
82/// faster but scale-dependent.  In the current ByteTrack integration only the
83/// IoU-based cost matrix is used for assignment; this enum is available for
84/// future association strategies that want a filter-based gate.
85#[allow(dead_code)]
86pub enum GatingDistanceMetric {
87    /// Squared Euclidean distance (no covariance weighting).
88    Gaussian,
89    /// Mahalanobis distance — normalised by the projected state covariance.
90    Mahalanobis,
91}
92
93impl<R> ConstantVelocityXYAHModel2<R>
94where
95    R: RealField + Copy,
96{
97    /// Initialise the filter from the first observation.
98    ///
99    /// `measurement` is `[x, y, a, h]` in XYAH space.  Velocity components are
100    /// initialised to zero.  The initial covariance is set proportionally to
101    /// the box height so larger boxes start with wider uncertainty.
102    ///
103    /// `update_factor` is stored and applied as a measurement gain in
104    /// [`update`](Self::update).
105    pub fn new(measurement: &[R; 4], update_factor: R) -> Self {
106        let ndim = 4;
107        let dt: R = convert(1.0);
108
109        let mut motion_matrix = OMatrix::<R, U8, U8>::identity();
110        for i in 0..ndim {
111            motion_matrix[(i, ndim + i)] = dt * convert(3.0);
112        }
113        let mut update_matrix = OMatrix::<R, U4, U8>::identity();
114        for i in 0..ndim {
115            update_matrix[(i, ndim + i)] = dt * convert(1.0);
116        }
117        let zero: R = convert(0.0);
118        let two: R = convert(2.0);
119        let ten: R = convert(10.0);
120        let height = measurement[3];
121
122        let mean = SVector::<R, 8>::from_row_slice(&[
123            measurement[0],
124            measurement[1],
125            measurement[2],
126            measurement[3],
127            zero,
128            zero,
129            zero,
130            zero,
131        ]);
132        let std_weight_position = convert(1.0 / 20.0);
133        let std_weight_velocity = convert(1.0 / 160.0);
134        let diag = [
135            two * std_weight_position * height,
136            two * std_weight_position * height,
137            convert(0.01),
138            two * std_weight_position * height,
139            ten * std_weight_velocity * height,
140            ten * std_weight_velocity * height,
141            convert(0.00001),
142            ten * std_weight_velocity * height,
143        ];
144        let diag = SVector::<R, 8>::from_row_slice(&diag);
145
146        let covariance = OMatrix::<R, U8, U8>::from_diagonal(&diag.component_mul(&diag));
147        Self {
148            motion_matrix,
149            update_matrix,
150            mean,
151            covariance,
152            std_weight_position,
153            std_weight_velocity,
154            update_factor,
155        }
156    }
157
158    /// Advance the filter by one time step with no measurement.
159    ///
160    /// Applies the constant-velocity motion model (`mean' = F * mean`,
161    /// `cov' = F * cov * Fᵀ + Q`) using a scale-adaptive process-noise
162    /// covariance `Q` proportional to the current box height.  Called once
163    /// per frame for every active tracklet before the assignment step.
164    pub fn predict(&mut self) {
165        let height = self.mean[3];
166        let diag = [
167            self.std_weight_position * height,
168            self.std_weight_position * height,
169            convert(0.01),
170            self.std_weight_position * height,
171            self.std_weight_velocity * height,
172            self.std_weight_velocity * height,
173            convert(0.00001),
174            self.std_weight_velocity * height,
175        ];
176        let diag = SVector::<R, 8>::from_row_slice(&diag);
177        let motion_cov = OMatrix::<R, U8, U8>::from_diagonal(&diag.component_mul(&diag));
178
179        let mean = (self.mean.transpose() * self.motion_matrix.transpose()).transpose();
180        let covariance =
181            self.motion_matrix * self.covariance * self.motion_matrix.transpose() + motion_cov;
182        self.mean = mean;
183        self.covariance = covariance;
184    }
185
186    /// Project the 8-D state into 4-D measurement space.
187    ///
188    /// Returns `(projected_mean, projected_covariance)` — the expected
189    /// measurement and its uncertainty under the current state estimate.
190    /// Used by [`update`](Self::update) and by
191    /// [`gating_distance`](Self::gating_distance).
192    pub fn project(&self) -> (OMatrix<R, U4, U1>, OMatrix<R, U4, U4>) {
193        let height = self.mean[3];
194        let diag = [
195            self.std_weight_position * height,
196            self.std_weight_position * height,
197            convert(0.01),
198            self.std_weight_position * height,
199        ];
200        let diag = SVector::<R, 4>::from_row_slice(&diag);
201        let innovation_cov = OMatrix::<R, U4, U4>::from_diagonal(&diag.component_mul(&diag));
202        let mean = self.update_matrix * self.mean;
203        let covariance =
204            self.update_matrix * self.covariance * self.update_matrix.transpose() + innovation_cov;
205        (mean, covariance)
206    }
207
208    /// Correct the state estimate with a new measurement.
209    ///
210    /// `measurement` is `[x, y, a, h]` in XYAH space, as produced by
211    /// [`xyxy_to_xyah`](super::bytetrack) in the ByteTrack update loop.
212    ///
213    /// The Kalman gain is computed via Cholesky decomposition of the projected
214    /// covariance.  If the decomposition fails (degenerate covariance) the
215    /// update is silently skipped and the prior state is preserved.
216    ///
217    /// The innovation is scaled by `self.update_factor` before being applied,
218    /// which effectively attenuates the correction — lower values produce
219    /// smoother trajectories at the cost of slower response to abrupt motion.
220    pub fn update(&mut self, measurement: &[R; 4]) {
221        let measurement = SVector::<R, 4>::from_row_slice(&[
222            measurement[0],
223            measurement[1],
224            measurement[2],
225            measurement[3],
226        ]);
227
228        let (projected_mean, projected_cov) = self.project();
229        let cho_factor = match projected_cov.cholesky() {
230            None => return,
231            Some(v) => v,
232        };
233        let kalman_gain = cho_factor
234            .solve(&(self.covariance * self.update_matrix.transpose()).transpose())
235            .transpose();
236
237        let innovation = (measurement - projected_mean).scale(self.update_factor);
238        // println!("kalman_gain={}", kalman_gain);
239        // println!("innovation={}", innovation);
240        let diff = innovation.transpose() * kalman_gain.transpose();
241        self.mean += diff.transpose();
242        self.covariance -= kalman_gain * projected_cov * kalman_gain.transpose();
243        // let new_mean = self.mean + diff.transpose();
244        // let new_cov = self.covariance - kalman_gain * projected_cov *
245        // kalman_gain.transpose();
246    }
247
248    /// Compute per-measurement gating distances from the current state.
249    ///
250    /// Returns a vector of one scalar per row in `measurements`.  Each scalar
251    /// is either the squared Euclidean distance ([`GatingDistanceMetric::Gaussian`])
252    /// or the Mahalanobis distance ([`GatingDistanceMetric::Mahalanobis`]) from
253    /// the projected state mean to that measurement.
254    ///
255    /// `only_position` restricts the comparison to the first two components
256    /// (`x`, `y`), ignoring aspect and height.
257    ///
258    /// Not used by the current ByteTrack integration (which gates via IoU);
259    /// exposed for future association strategies.
260    #[allow(dead_code)]
261    pub fn gating_distance(
262        &self,
263        measurements: &OMatrix<R, Dyn, U4>,
264        only_position: bool,
265        metric: GatingDistanceMetric,
266    ) -> DVector<R> {
267        let (m, cov) = self.project();
268        let ndims = if only_position { 2 } else { 4 };
269        let mean = m.transpose();
270        let mean = mean.columns_range(0..ndims);
271        let covariance = cov.view_range(0..ndims, 0..ndims);
272        let measurements = measurements.columns_range(0..ndims);
273        // let _ = only_position;
274        // let mean = m.transpose();
275        // let covariance = cov;
276        // let measurements = measurements;
277
278        let mut mean_broadcast =
279            OMatrix::<R, Dyn, U4>::from_element(measurements.shape().0, convert(0.0));
280        for mut col in mean_broadcast.row_iter_mut() {
281            col.copy_from(&mean);
282        }
283        let d = measurements - mean_broadcast;
284        match metric {
285            GatingDistanceMetric::Gaussian => d.component_mul(&d).column_sum(),
286            GatingDistanceMetric::Mahalanobis => {
287                let cho_factor = match covariance.cholesky() {
288                    None => return DVector::<R>::zeros(measurements.shape().0),
289                    Some(v) => v,
290                };
291                let z = cho_factor.solve(&d.transpose());
292                z.component_mul(&z).row_sum_tr()
293            }
294        }
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use nalgebra::{Dyn, OMatrix, U4};
301
302    use super::{ConstantVelocityXYAHModel2, GatingDistanceMetric};
303    #[test]
304    fn filter() {
305        let mut t = ConstantVelocityXYAHModel2::new(&[0.5, 0.5, 1.0, 0.5], 0.25);
306        t.predict();
307        println!("1. t.mean={}", t.mean);
308        t.update(&[0.4, 0.5, 1.0, 0.5]);
309        t.predict();
310        println!("2. t.mean={}", t.mean);
311        t.update(&[0.3, 0.5, 1.0, 0.5]);
312        t.predict();
313        println!("3. t.mean={}", t.mean);
314        t.update(&[0.2, 0.5, 1.0, 0.5]);
315        t.predict();
316        println!("4. t.mean={}", t.mean);
317        t.update(&[0.2, 0.5, 1.0, 0.5]);
318        t.predict();
319        println!("5. t.mean={}", t.mean);
320        t.update(&[0.3, 0.5, 1.0, 0.5]);
321        t.predict();
322        println!("6. t.mean={}", t.mean);
323        t.update(&[0.4, 0.5, 1.0, 0.5]);
324    }
325
326    #[test]
327    fn gating() {
328        let mut t = ConstantVelocityXYAHModel2::new(&[0.5, 0.5, 1.0, 0.5], 0.25);
329        t.predict();
330        t.update(&[0.49, 0.5, 1.0, 0.5]);
331        t.predict();
332        t.update(&[0.48, 0.5, 1.0, 0.5]);
333        t.predict();
334        t.update(&[0.47, 0.5, 1.0, 0.5]);
335        t.predict();
336        t.update(&[0.46, 0.5, 1.0, 0.5]);
337        t.predict();
338        t.update(&[0.45, 0.5, 1.0, 0.5]);
339        t.predict();
340        t.update(&[0.44, 0.5, 1.0, 0.5]);
341        t.predict();
342        t.update(&[0.43, 0.5, 1.0, 0.5]);
343        t.predict();
344        t.update(&[0.42, 0.5, 1.0, 0.5]);
345        t.predict();
346
347        // distances range from 0 to 1e6 for maha
348        let mut measurements = OMatrix::<f32, Dyn, U4>::from_element(1, 0.0);
349        measurements.copy_from_slice(&[0.3, 0.5, 1.0, 0.5]);
350
351        let mut distances = OMatrix::<f32, Dyn, Dyn>::from_element(1, 1, 0.0);
352        for mut column in distances.column_iter_mut() {
353            let dist = t.gating_distance(&measurements, false, GatingDistanceMetric::Gaussian);
354            column.copy_from(&dist);
355        }
356        let dist = t.gating_distance(&measurements, false, GatingDistanceMetric::Mahalanobis);
357        println!("Dist(false, maha): {dist}");
358
359        let dist = t.gating_distance(&measurements, false, GatingDistanceMetric::Gaussian);
360        println!("Dist(false, gaussian): {dist}");
361    }
362
363    #[test]
364    fn test_predict_constant_velocity() {
365        // Initialize filter and give it a few updates to establish velocity,
366        // then verify predictions drift in the expected direction.
367        let mut t = ConstantVelocityXYAHModel2::new(&[0.5, 0.5, 0.1, 2.0], 0.25);
368        t.predict();
369        t.update(&[0.5, 0.5, 0.1, 2.0]);
370
371        // Record position after first predict-update cycle
372        let x_before: f32 = t.mean[0];
373        let y_before: f32 = t.mean[1];
374
375        // Run several predict-only cycles to let velocity dominate
376        for _ in 0..5 {
377            t.predict();
378        }
379
380        let x_after: f32 = t.mean[0];
381        let y_after: f32 = t.mean[1];
382        let h_after: f32 = t.mean[3];
383
384        // The position should remain numerically reasonable (no NaN/Inf)
385        assert!(x_after.is_finite(), "x should be finite after predictions");
386        assert!(y_after.is_finite(), "y should be finite after predictions");
387        assert!(
388            h_after.is_finite(),
389            "height should be finite after predictions"
390        );
391
392        // With near-zero velocity the predicted position should not explode
393        assert!(
394            (x_after - x_before).abs() < 5.0,
395            "x drift should be bounded, got delta={}",
396            (x_after - x_before).abs()
397        );
398        assert!(
399            (y_after - y_before).abs() < 5.0,
400            "y drift should be bounded, got delta={}",
401            (y_after - y_before).abs()
402        );
403    }
404
405    #[test]
406    fn test_numerical_stability_1000_cycles() {
407        let mut t = ConstantVelocityXYAHModel2::new(&[0.5, 0.5, 1.0, 0.5], 0.25);
408
409        // Run 1000 predict-only cycles without any update
410        for _ in 0..1000 {
411            t.predict();
412        }
413
414        // Verify no NaN or Inf in the mean vector
415        for i in 0..8 {
416            let val: f32 = t.mean[i];
417            assert!(
418                val.is_finite(),
419                "mean[{i}] should be finite after 1000 predictions, got {val}",
420            );
421        }
422
423        // Verify no NaN or Inf in the covariance matrix
424        for r in 0..8 {
425            for c in 0..8 {
426                let val: f32 = t.covariance[(r, c)];
427                assert!(
428                    val.is_finite(),
429                    "covariance[({r},{c})] should be finite after 1000 predictions, got {val}",
430                );
431            }
432        }
433    }
434
435    #[test]
436    fn test_gating_distance_edge_cases() {
437        let mut t = ConstantVelocityXYAHModel2::new(&[0.5, 0.5, 1.0, 0.5], 0.25);
438        // Run a few predict-update cycles to stabilize
439        for _ in 0..3 {
440            t.predict();
441            t.update(&[0.5, 0.5, 1.0, 0.5]);
442        }
443        t.predict();
444
445        // Measurement exactly at the predicted state -- distance should be near 0
446        let (projected_mean, _) = t.project();
447        let mut meas_close = OMatrix::<f32, Dyn, U4>::from_element(1, 0.0);
448        meas_close
449            .row_mut(0)
450            .copy_from_slice(projected_mean.as_slice());
451
452        let dist_close = t.gating_distance(&meas_close, false, GatingDistanceMetric::Mahalanobis);
453        assert!(
454            dist_close[0].is_finite(),
455            "Close-measurement distance should be finite"
456        );
457        assert!(
458            dist_close[0] < 1.0,
459            "Distance for exact-match measurement should be near 0, got {}",
460            dist_close[0]
461        );
462
463        // Measurement far away -- distance should be large
464        let mut meas_far = OMatrix::<f32, Dyn, U4>::from_element(1, 0.0);
465        meas_far.copy_from_slice(&[10.0, 10.0, 5.0, 10.0]);
466
467        let dist_far = t.gating_distance(&meas_far, false, GatingDistanceMetric::Mahalanobis);
468        assert!(
469            dist_far[0].is_finite(),
470            "Far-measurement distance should be finite"
471        );
472        assert!(
473            dist_far[0] > dist_close[0],
474            "Far measurement should have larger distance than close one: {} vs {}",
475            dist_far[0],
476            dist_close[0]
477        );
478    }
479
480    #[test]
481    fn test_update_moves_mean_toward_measurement() {
482        let mut t = ConstantVelocityXYAHModel2::new(&[0.5, 0.5, 1.0, 0.5], 0.25);
483        t.predict();
484
485        let x_before: f32 = t.mean[0];
486        // Update with a measurement shifted to the right
487        t.update(&[0.6, 0.5, 1.0, 0.5]);
488        let x_after: f32 = t.mean[0];
489
490        assert!(
491            x_after > x_before,
492            "Mean x should move toward the measurement (0.6), was {x_before}, now {x_after}"
493        );
494        assert!(
495            x_after <= 0.6,
496            "Mean x should not overshoot the measurement, got {x_after}"
497        );
498    }
499
500    #[test]
501    fn test_covariance_positive_diagonal() {
502        let t = ConstantVelocityXYAHModel2::new(&[0.5, 0.5, 1.0, 0.5], 0.25);
503
504        // All diagonal elements of the covariance should be positive
505        for i in 0..8 {
506            let val: f32 = t.covariance[(i, i)];
507            assert!(
508                val > 0.0,
509                "Covariance diagonal[{i}] should be positive, got {val}"
510            );
511        }
512    }
513
514    #[test]
515    fn test_predict_increases_uncertainty() {
516        let mut t = ConstantVelocityXYAHModel2::new(&[0.5, 0.5, 1.0, 0.5], 0.25);
517
518        let cov_before: f32 = t.covariance[(0, 0)];
519        t.predict();
520        let cov_after: f32 = t.covariance[(0, 0)];
521
522        assert!(
523            cov_after > cov_before,
524            "Predict should increase position uncertainty: {cov_before} -> {cov_after}"
525        );
526    }
527
528    #[test]
529    fn test_update_decreases_uncertainty() {
530        let mut t = ConstantVelocityXYAHModel2::new(&[0.5, 0.5, 1.0, 0.5], 0.25);
531        t.predict();
532
533        let cov_before: f32 = t.covariance[(0, 0)];
534        t.update(&[0.5, 0.5, 1.0, 0.5]);
535        let cov_after: f32 = t.covariance[(0, 0)];
536
537        assert!(
538            cov_after < cov_before,
539            "Update should decrease position uncertainty: {cov_before} -> {cov_after}"
540        );
541    }
542
543    #[test]
544    fn test_gating_distance_gaussian_vs_mahalanobis() {
545        let mut t = ConstantVelocityXYAHModel2::new(&[0.5, 0.5, 1.0, 0.5], 0.25);
546        for _ in 0..3 {
547            t.predict();
548            t.update(&[0.5, 0.5, 1.0, 0.5]);
549        }
550        t.predict();
551
552        let mut measurements = OMatrix::<f32, Dyn, U4>::from_element(1, 0.0);
553        measurements.copy_from_slice(&[0.6, 0.5, 1.0, 0.5]);
554
555        let dist_gauss = t.gating_distance(&measurements, false, GatingDistanceMetric::Gaussian);
556        let dist_maha = t.gating_distance(&measurements, false, GatingDistanceMetric::Mahalanobis);
557
558        assert!(dist_gauss[0].is_finite());
559        assert!(dist_maha[0].is_finite());
560
561        // Both should be non-negative for a non-zero offset
562        assert!(
563            dist_gauss[0] > 0.0,
564            "Gaussian distance should be > 0 for offset measurement"
565        );
566        assert!(
567            dist_maha[0] > 0.0,
568            "Mahalanobis distance should be > 0 for offset measurement"
569        );
570    }
571
572    #[test]
573    fn test_gating_distance_multiple_measurements() {
574        let mut t = ConstantVelocityXYAHModel2::new(&[0.5, 0.5, 1.0, 0.5], 0.25);
575        t.predict();
576        t.update(&[0.5, 0.5, 1.0, 0.5]);
577        t.predict();
578
579        // Two measurements: one close, one far
580        let mut measurements = OMatrix::<f32, Dyn, U4>::from_element(2, 0.0);
581        measurements
582            .row_mut(0)
583            .copy_from_slice(&[0.5, 0.5, 1.0, 0.5]); // close
584        measurements
585            .row_mut(1)
586            .copy_from_slice(&[5.0, 5.0, 1.0, 0.5]); // far
587
588        let dists = t.gating_distance(&measurements, false, GatingDistanceMetric::Mahalanobis);
589        assert_eq!(dists.len(), 2, "Should return one distance per measurement");
590        assert!(dists[0].is_finite());
591        assert!(dists[1].is_finite());
592        assert!(
593            dists[1] > dists[0],
594            "Far measurement should have larger distance: {} vs {}",
595            dists[1],
596            dists[0]
597        );
598    }
599
600    #[test]
601    fn test_initiate_mean_matches_measurement() {
602        let measurement = [0.3, 0.7, 1.5, 2.0];
603        let t = ConstantVelocityXYAHModel2::new(&measurement, 0.25);
604
605        // Position portion of mean should match the measurement exactly
606        let x: f32 = t.mean[0];
607        let y: f32 = t.mean[1];
608        let a: f32 = t.mean[2];
609        let h: f32 = t.mean[3];
610        assert!((x - 0.3).abs() < 1e-6, "Mean x should be 0.3, got {x}");
611        assert!((y - 0.7).abs() < 1e-6, "Mean y should be 0.7, got {y}");
612        assert!((a - 1.5).abs() < 1e-6, "Mean a should be 1.5, got {a}");
613        assert!((h - 2.0).abs() < 1e-6, "Mean h should be 2.0, got {h}");
614
615        // Velocity portion should be zero
616        for i in 4..8 {
617            let v: f32 = t.mean[i];
618            assert!((v).abs() < 1e-6, "Velocity mean[{i}] should be 0, got {v}");
619        }
620    }
621}