Skip to main content

fusion_nav/
state.rs

1//! The navigation state estimate and the error-state covariance.
2
3use nalgebra::{Matrix3, SMatrix, SVector, Vector3};
4
5use crate::frames::{Body, Ned};
6use crate::health::{Status, Validity};
7use crate::math::enforce_symmetry;
8use crate::units::{Acceleration, AngularRate, Attitude, Position, Velocity};
9
10/// Dimension of the error state: three each of position, velocity, attitude,
11/// accelerometer bias, and gyroscope bias.
12pub const STATES: usize = 15;
13
14/// The navigation estimate, returned by [`Eskf::state`](crate::Eskf::state).
15///
16/// Small and `Copy`, so reading it every control cycle costs nothing. The [`status`] is a
17/// field rather than a separate accessor so that the trust level travels with the numbers
18/// it qualifies. Nothing forces a caller to read it — `state().position` compiles — but it
19/// is in hand rather than behind a second call that is easy not to make.
20///
21/// [`status`]: State::status
22#[cfg_attr(feature = "defmt", derive(defmt::Format))]
23#[derive(Clone, Copy, Debug, Default, PartialEq)]
24pub struct State {
25    /// Rotation from body to NED.
26    pub attitude: Attitude,
27    /// Position relative to the navigation origin.
28    pub position: Position<Ned>,
29    /// Velocity in the navigation frame.
30    pub velocity: Velocity<Ned>,
31    /// Estimated accelerometer bias, to be subtracted from raw measurements.
32    pub accel_bias: Acceleration<Body>,
33    /// Estimated gyroscope bias, to be subtracted from raw measurements.
34    pub gyro_bias: AngularRate<Body>,
35    /// Whether the estimate is currently aided, and by how much.
36    pub status: Status,
37    /// Which parts of this estimate are good enough to use.
38    ///
39    /// Alongside [`status`](Self::status) rather than behind an accessor, for the same
40    /// reason: the trust travels with the numbers it qualifies. `status` is the summary a
41    /// human reads; this is what a controller branches on.
42    pub validity: Validity,
43}
44
45impl State {
46    /// Whether every number in the estimate is finite.
47    ///
48    /// Two callers with one question: [`Eskf::initialize_from`](crate::Eskf::initialize_from)
49    /// asks it of a seed the application supplied, and
50    /// [`Eskf::predict`](crate::Eskf::predict) asks it of what propagation produced, since
51    /// (11)–(14) can overflow f32 from finite inputs. Beside the type rather than in either
52    /// caller's module, because neither owns it.
53    ///
54    /// The quaternion is unit by construction, so only its finiteness is in question.
55    pub(crate) fn is_finite(&self) -> bool {
56        let q = self.attitude.quaternion();
57        [q.w, q.i, q.j, q.k].iter().all(|v| v.is_finite())
58            && self.position.is_finite()
59            && self.velocity.is_finite()
60            && self.accel_bias.is_finite()
61            && self.gyro_bias.is_finite()
62    }
63}
64
65/// Index of an error-state component within the covariance.
66///
67/// The ordering is that of equation (2):
68/// `[δp δv δθ δβa δβg]`.
69#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
70#[repr(usize)]
71pub enum ErrorState {
72    /// North position error.
73    PositionNorth = 0,
74    /// East position error.
75    PositionEast = 1,
76    /// Down position error.
77    PositionDown = 2,
78    /// North velocity error.
79    VelocityNorth = 3,
80    /// East velocity error.
81    VelocityEast = 4,
82    /// Down velocity error.
83    VelocityDown = 5,
84    /// Attitude error about body x. Local (body-frame) perturbation.
85    AttitudeX = 6,
86    /// Attitude error about body y.
87    AttitudeY = 7,
88    /// Attitude error about body z.
89    AttitudeZ = 8,
90    /// Accelerometer bias error, body x.
91    AccelBiasX = 9,
92    /// Accelerometer bias error, body y.
93    AccelBiasY = 10,
94    /// Accelerometer bias error, body z.
95    AccelBiasZ = 11,
96    /// Gyroscope bias error, body x.
97    GyroBiasX = 12,
98    /// Gyroscope bias error, body y.
99    GyroBiasY = 13,
100    /// Gyroscope bias error, body z.
101    GyroBiasZ = 14,
102}
103
104impl ErrorState {
105    /// The index of this component, for indexing [`Covariance::to_rows`].
106    pub const fn index(self) -> usize {
107        self as usize
108    }
109}
110
111/// The 15 x 15 error covariance `P`, in the [`ErrorState`] ordering.
112///
113/// 900 bytes in `f32`, so the filter hands it around by reference.
114pub(crate) type CovarianceMatrix = SMatrix<f32, STATES, STATES>;
115
116/// The error covariance, with named access to its components.
117#[derive(Clone, Copy, Debug, PartialEq)]
118pub struct Covariance(CovarianceMatrix);
119
120impl Covariance {
121    /// All entries zero. Not a valid filter prior; see equation (8).
122    pub fn zero() -> Self {
123        Self(CovarianceMatrix::zeros())
124    }
125
126    /// A diagonal covariance from per-component standard deviations, in the
127    /// [`ErrorState`] ordering. Equation (8).
128    pub fn from_sigmas(sigmas: [f32; STATES]) -> Self {
129        let variances = SVector::<f32, STATES>::from_iterator(sigmas.iter().map(|s| s * s));
130        Self(CovarianceMatrix::from_diagonal(&variances))
131    }
132
133    /// From rows, `p[row][column]` in the [`ErrorState`] ordering. Not checked for symmetry
134    /// or positive-definiteness here; [`Eskf::initialize_from`](crate::Eskf::initialize_from)
135    /// checks what it needs of a seed.
136    pub fn from_rows(p: [[f32; STATES]; STATES]) -> Self {
137        Self(CovarianceMatrix::from_fn(|row, column| p[row][column]))
138    }
139
140    /// Wrap a matrix the filter computed.
141    pub(crate) const fn from_matrix(p: CovarianceMatrix) -> Self {
142        Self(p)
143    }
144
145    /// One entry, named rather than indexed.
146    pub fn get(&self, row: ErrorState, column: ErrorState) -> f32 {
147        self.0[(row.index(), column.index())]
148    }
149
150    /// The variance of one error-state component.
151    pub fn variance(&self, state: ErrorState) -> f32 {
152        self.get(state, state)
153    }
154
155    /// Replace one component's variance and drop its correlations with everything else.
156    ///
157    /// The reset of a quantity the filter is adopting rather than correcting: the new
158    /// error came from the measurement, so it carries the measurement's variance and is
159    /// uncorrelated with the errors the filter accumulated before it. Zeroing the row and
160    /// column is what makes the second part true; leaving them would let the old
161    /// correlation pull the reset value straight back.
162    pub(crate) fn reset_state(&mut self, state: ErrorState, variance: f32) {
163        let i = state.index();
164        for k in 0..STATES {
165            self.0[(i, k)] = 0.0;
166            self.0[(k, i)] = 0.0;
167        }
168        self.0[(i, i)] = variance;
169    }
170
171    /// Replace the attitude block, `P_θθ`, leaving its correlations with the other states.
172    pub(crate) fn set_attitude_block(&mut self, block: Matrix3<f32>) {
173        let theta = ErrorState::AttitudeX.index();
174        self.0
175            .fixed_view_mut::<3, 3>(theta, theta)
176            .copy_from(&block);
177    }
178
179    /// Replace `P_θβa`, the attitude's cross-covariance with the accelerometer bias, and its
180    /// transpose, leaving both diagonal blocks as they were.
181    pub(crate) fn set_attitude_accel_bias_block(&mut self, block: Matrix3<f32>) {
182        let (theta, beta) = (
183            ErrorState::AttitudeX.index(),
184            ErrorState::AccelBiasX.index(),
185        );
186        self.0.fixed_view_mut::<3, 3>(theta, beta).copy_from(&block);
187        self.0
188            .fixed_view_mut::<3, 3>(beta, theta)
189            .copy_from(&block.transpose());
190    }
191
192    /// [`reset_state`](Self::reset_state) along a direction of the attitude block rather
193    /// than one of its axes: the component `uᵀδθ` gets `variance` and loses its correlations,
194    /// and everything orthogonal to it is left as it was.
195    ///
196    /// Heading adoption needs it because heading is rotation about navigation down, which
197    /// is the body axis `u = R(q̂)ᵀe₃` and not `δθ_z` unless the vehicle is level — at 90°
198    /// of pitch, resetting `δθ_z` would reset a tilt. With `M = I − uuᵀ` the projection onto
199    /// the rest, the attitude rows become `M P_θ·`, the block `M P_θθ M + variance · uuᵀ`,
200    /// and `u = eᵢ` is [`reset_state`](Self::reset_state) exactly. `u` must be a unit vector.
201    pub(crate) fn reset_attitude_direction(&mut self, u: Vector3<f32>, variance: f32) {
202        let theta = ErrorState::AttitudeX.index();
203        let m = Matrix3::identity() - u * u.transpose();
204        let rows = m * self.0.fixed_rows::<3>(theta);
205        self.0.fixed_rows_mut::<3>(theta).copy_from(&rows);
206        let columns = self.0.fixed_columns::<3>(theta) * m;
207        self.0.fixed_columns_mut::<3>(theta).copy_from(&columns);
208        let block = self.0.fixed_view::<3, 3>(theta, theta) + u * u.transpose() * variance;
209        self.0
210            .fixed_view_mut::<3, 3>(theta, theta)
211            .copy_from(&block);
212        // (42): `M P M` rounds differently on either side of the diagonal.
213        enforce_symmetry(&mut self.0);
214    }
215
216    /// [`reset_state`](Self::reset_state) over several states, for the position and
217    /// velocity of [`Fusion::Reset`](crate::Fusion::Reset): three components on a first
218    /// adoption, the horizontal pair or the height alone on a recovery.
219    pub(crate) fn reset_block<const N: usize>(
220        &mut self,
221        states: [ErrorState; N],
222        variances: [f32; N],
223    ) {
224        for (state, variance) in states.iter().zip(variances) {
225            self.reset_state(*state, variance);
226        }
227    }
228
229    /// The whole matrix as rows, `p[row][column]` in the [`ErrorState`] ordering, for callers
230    /// that do their own algebra.
231    ///
232    /// Rows rather than `nalgebra`'s storage, which is an array of columns, because `P` is
233    /// symmetric only to its last bit: a caller indexing columns as rows would read the
234    /// transpose, and nothing would say so. The same trap is on the way into `nalgebra`, whose
235    /// `From<[[f32; 15]; 15]>` also reads columns: `SMatrix::from_fn(|i, j| p[i][j])` is the
236    /// conversion. A copy, 900 bytes on the caller's stack; [`get`](Self::get) reads one entry
237    /// without it.
238    pub fn to_rows(&self) -> [[f32; STATES]; STATES] {
239        core::array::from_fn(|row| core::array::from_fn(|column| self.0[(row, column)]))
240    }
241
242    /// The whole matrix, for the filter's own algebra.
243    pub(crate) const fn as_matrix(&self) -> &CovarianceMatrix {
244        &self.0
245    }
246
247    /// Whether every entry is finite.
248    ///
249    /// Two callers with one question, as [`State::is_finite`] has:
250    /// [`Eskf::initialize_from`](crate::Eskf::initialize_from) asks it of a seed the
251    /// application supplied, and [`Eskf::predict`](crate::Eskf::predict) asks it of what (22)
252    /// produced, since `F P Fᵀ + Q` overflows f32 from finite inputs for the same reason
253    /// (11)–(14) do.
254    pub(crate) fn is_finite(&self) -> bool {
255        self.0.iter().all(|entry| entry.is_finite())
256    }
257}
258
259/// How uncertain the attitude is about each navigation axis, in radians squared: tilt about
260/// north and east, heading about down.
261///
262/// The covariance cannot be read for these directly. Its attitude block is the covariance of
263/// `δθ`, a rotation vector in **body** axes (equations (2)–(4)), and (36) states the
264/// navigation-frame rotation it stands for as `R(q̂) δθ`. So the body x and y diagonal is tilt,
265/// and z heading, only while the vehicle is level: at 90° of pitch body x is vertical, and
266/// `P[δθ_x]` is the heading's variance. These are the diagonal of `R(q̂) P_θθ R(q̂)ᵀ`, which
267/// holds at any attitude, and which is what PX4 publishes too — `getTiltVariance` and
268/// `getYawVar` read the same components of its navigation-frame covariance
269/// (`src/modules/ekf2/EKF/ekf_helper.cpp:938-947` at `c4e4ef98e9`).
270///
271/// `heading` is exactly `H P Hᵀ` for the heading Jacobian of (36), whose row is the third of
272/// `R(q̂)`: a heading source and this struct agree on what heading uncertainty is.
273#[derive(Clone, Copy, Debug, Default, PartialEq)]
274pub struct AttitudeVariance {
275    /// About north: the tilt a roll reads while the vehicle faces north.
276    pub tilt_north: f32,
277    /// About east: the tilt a pitch reads while the vehicle faces north.
278    pub tilt_east: f32,
279    /// About down.
280    pub heading: f32,
281}
282
283impl AttitudeVariance {
284    /// Resolve `covariance`'s attitude block on the navigation axes of `attitude`.
285    ///
286    /// The attitude is an argument rather than read from a filter because the covariance
287    /// is: [`Eskf::predicted_validity`](crate::Eskf::predicted_validity) asks this of a
288    /// covariance projected forward, which the nominal attitude it was projected from
289    /// still describes.
290    pub(crate) fn of(attitude: &Attitude, covariance: &Covariance) -> Self {
291        let r = attitude.quaternion().to_rotation_matrix().into_inner();
292        let theta = ErrorState::AttitudeX.index();
293        let p_theta = covariance.as_matrix().fixed_view::<3, 3>(theta, theta);
294        let ned = r * p_theta * r.transpose();
295        Self {
296            tilt_north: ned[(0, 0)],
297            tilt_east: ned[(1, 1)],
298            heading: ned[(2, 2)],
299        }
300    }
301
302    /// The three variances about east, north and up, the axes ROS's ENU frame orders a
303    /// `nav_msgs/Odometry` orientation covariance in: the horizontal pair swapped, and the
304    /// sign of an axis, which a variance does not carry, dropped.
305    pub fn to_enu(self) -> [f32; 3] {
306        [self.tilt_east, self.tilt_north, self.heading]
307    }
308
309    /// The body-frame attitude block whose navigation-frame covariance is these three
310    /// variances and nothing correlated between them: `R(q̂)ᵀ diag(·) R(q̂)`, the inverse of
311    /// [`of`](Self::of). Equation (8).
312    ///
313    /// Symmetrized by (42), since the product is not exactly: rounded, `R(q̂)ᵀ D R(q̂)` differs
314    /// from its transpose in the last bit off the diagonal, and this block is committed at a
315    /// start with no propagation after it to repair that.
316    pub(crate) fn in_body(self, attitude: &Attitude) -> Matrix3<f32> {
317        let r = attitude.quaternion().to_rotation_matrix().into_inner();
318        let ned =
319            Matrix3::from_diagonal(&Vector3::new(self.tilt_north, self.tilt_east, self.heading));
320        let mut body = r.transpose() * ned * r;
321        enforce_symmetry(&mut body);
322        body
323    }
324}
325
326impl Default for Covariance {
327    fn default() -> Self {
328        Self::zero()
329    }
330}
331
332/// The barometric offset's share of the augmented covariance of equation (30′): `P_xb`, its
333/// cross-covariance with the error state in the [`ErrorState`] ordering, and `P_bb`, its own
334/// variance.
335///
336/// Beside [`Covariance`] rather than inside it, so that the 15 × 15 a caller reads stays the
337/// covariance of the navigation state. The offset `b` is the error in `α₀`, the barometric
338/// reference, and is never a component of [`State`]: its estimate is folded into `α₀` at
339/// every update, so all it keeps between them is this row.
340///
341/// All zero while there is no reference: nothing then correlates with it and nothing reads it.
342#[derive(Clone, Copy, Debug, Default, PartialEq)]
343pub(crate) struct Offset {
344    /// `P_xb`.
345    pub(crate) cross: SVector<f32, STATES>,
346    /// `P_bb`.
347    pub(crate) variance: f32,
348}
349
350impl Offset {
351    /// An offset uncorrelated with the error state, of variance `variance`: a reference
352    /// measured apart from the estimate, as a window at rest or a caller measures one.
353    pub(crate) fn independent(variance: f32) -> Self {
354        Self {
355            cross: SVector::zeros(),
356            variance,
357        }
358    }
359
360    /// The offset of a reference read from the estimate, `α̂₀ = α + p̂_D`: its error is
361    /// `−δp_D` plus the reading's noise, so `P_xb = −P[:, D]` and `P_bb = P_DD + R_m`.
362    /// Equation (30′).
363    pub(crate) fn from_estimate(covariance: &Covariance, noise_variance: f32) -> Self {
364        let down = ErrorState::PositionDown;
365        Self {
366            cross: -covariance.as_matrix().column(down.index()),
367            variance: covariance.variance(down) + noise_variance,
368        }
369    }
370
371    /// Drop the offset's correlation with one error-state component, as
372    /// [`Covariance::reset_state`] drops that component's correlations with the rest.
373    pub(crate) fn decorrelate(&mut self, state: ErrorState) {
374        self.cross[state.index()] = 0.0;
375    }
376
377    /// Drop the offset's correlation with the attitude component `uᵀδθ`, as
378    /// [`Covariance::reset_attitude_direction`] drops that component's correlations with the
379    /// rest.
380    pub(crate) fn decorrelate_attitude_direction(&mut self, u: Vector3<f32>) {
381        let theta = ErrorState::AttitudeX.index();
382        let m = Matrix3::identity() - u * u.transpose();
383        let cross = m * self.cross.fixed_rows::<3>(theta);
384        self.cross.fixed_rows_mut::<3>(theta).copy_from(&cross);
385    }
386
387    /// Whether every entry is finite.
388    pub(crate) fn is_finite(&self) -> bool {
389        self.variance.is_finite() && self.cross.iter().all(|entry| entry.is_finite())
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use core::f32::consts::FRAC_PI_2;
397    use nalgebra::UnitQuaternion;
398
399    fn attitude_of(roll: f32, pitch: f32, yaw: f32) -> Attitude {
400        Attitude::from_quaternion(UnitQuaternion::from_euler_angles(roll, pitch, yaw))
401    }
402
403    #[test]
404    fn the_array_is_rows_in_and_out() {
405        // Asymmetric, so an array read as columns anywhere reads the transpose and fails.
406        let mut rows = [[0.0; STATES]; STATES];
407        rows[ErrorState::PositionNorth.index()][ErrorState::GyroBiasZ.index()] = 1.0;
408        let covariance = Covariance::from_rows(rows);
409        assert_eq!(
410            covariance.get(ErrorState::PositionNorth, ErrorState::GyroBiasZ),
411            1.0
412        );
413        assert_eq!(
414            covariance.get(ErrorState::GyroBiasZ, ErrorState::PositionNorth),
415            0.0
416        );
417        assert_eq!(covariance.to_rows(), rows);
418    }
419
420    #[test]
421    fn enu_order_swaps_the_tilts_and_keeps_heading_last() {
422        let variance = AttitudeVariance {
423            tilt_north: 1.0,
424            tilt_east: 2.0,
425            heading: 3.0,
426        };
427        assert_eq!(variance.to_enu(), [2.0, 1.0, 3.0]);
428    }
429
430    /// Found by the adversarial suite: rounded, `R D Rᵀ` and `M P M` are not their own
431    /// transposes, and both are committed with nothing after them to repair it. Several
432    /// attitudes, because whether the last bit differs depends on the rotation.
433    #[test]
434    fn the_attitude_blocks_a_start_and_a_heading_reset_write_are_exactly_symmetric() {
435        let variance = AttitudeVariance {
436            tilt_north: 4.1e-4,
437            tilt_east: 3.7e-4,
438            heading: 0.29,
439        };
440        for (roll, pitch, yaw) in [(0.3, -0.2, 1.1), (0.01, 0.02, -2.9), (-0.7, 0.4, 0.5)] {
441            let attitude = attitude_of(roll, pitch, yaw);
442            let block = variance.in_body(&attitude);
443            assert_eq!(
444                block,
445                block.transpose(),
446                "in_body at {roll}, {pitch}, {yaw}"
447            );
448
449            let mut p = with_attitude_block(block);
450            let down = attitude
451                .quaternion()
452                .inverse_transform_vector(&Vector3::z());
453            p.reset_attitude_direction(down, 0.05);
454            let p = p.as_matrix();
455            assert_eq!(*p, p.transpose(), "reset at {roll}, {pitch}, {yaw}");
456        }
457    }
458
459    fn with_attitude_block(block: Matrix3<f32>) -> Covariance {
460        let mut p = CovarianceMatrix::identity();
461        let theta = ErrorState::AttitudeX.index();
462        p.fixed_view_mut::<3, 3>(theta, theta).copy_from(&block);
463        Covariance::from_matrix(p)
464    }
465
466    #[test]
467    fn level_and_facing_north_the_body_diagonal_is_tilt_and_heading() {
468        let p = with_attitude_block(Matrix3::from_diagonal(&Vector3::new(1.0, 2.0, 3.0)));
469        let variance = AttitudeVariance::of(&Attitude::default(), &p);
470        assert_eq!((variance.tilt_north, variance.tilt_east), (1.0, 2.0));
471        assert_eq!(variance.heading, 3.0);
472    }
473
474    #[test]
475    fn on_its_tail_body_x_is_the_heading_axis() {
476        // Pitched 90° nose-up, body x points up, so the variance about it is heading's.
477        // Mutating `of` to read the body diagonal reports it as tilt instead.
478        let p = with_attitude_block(Matrix3::from_diagonal(&Vector3::new(1.0, 2.0, 3.0)));
479        let variance = AttitudeVariance::of(&attitude_of(0.0, FRAC_PI_2, 0.0), &p);
480        assert!((variance.heading - 1.0).abs() < 1e-6, "{variance:?}");
481        assert!((variance.tilt_east - 2.0).abs() < 1e-6, "{variance:?}");
482        assert!((variance.tilt_north - 3.0).abs() < 1e-6, "{variance:?}");
483    }
484
485    #[test]
486    fn in_body_is_the_inverse_of_of() {
487        let attitude = attitude_of(0.4, 1.2, -2.0);
488        let ned = AttitudeVariance {
489            tilt_north: 1e-4,
490            tilt_east: 4e-4,
491            heading: 0.1,
492        };
493        let p = with_attitude_block(ned.in_body(&attitude));
494        let back = AttitudeVariance::of(&attitude, &p);
495        assert!((back.tilt_north - ned.tilt_north).abs() < 1e-7, "{back:?}");
496        assert!((back.tilt_east - ned.tilt_east).abs() < 1e-7, "{back:?}");
497        assert!((back.heading - ned.heading).abs() < 1e-6, "{back:?}");
498    }
499
500    #[test]
501    fn a_reset_along_a_body_axis_is_reset_state() {
502        let mut p = CovarianceMatrix::from_fn(|i, j| 0.01 * (1 + i.min(j)) as f32);
503        p.fill_diagonal(1.0);
504        let mut along = Covariance::from_matrix(p);
505        along.reset_attitude_direction(Vector3::z(), 0.25);
506        let mut axis = Covariance::from_matrix(p);
507        axis.reset_state(ErrorState::AttitudeZ, 0.25);
508        assert_eq!(along, axis);
509    }
510
511    #[test]
512    fn a_reset_along_a_direction_leaves_the_orthogonal_components_alone() {
513        // `u` between body y and z: the reset component gets the variance and nothing else,
514        // and the component orthogonal to `u` in that plane keeps its variance.
515        let u = Vector3::new(0.0, 0.6, 0.8);
516        let w = Vector3::new(0.0, 0.8, -0.6);
517        let mut p = CovarianceMatrix::from_fn(|i, j| 0.01 * (1 + i.min(j)) as f32);
518        p.fill_diagonal(1.0);
519        let theta = ErrorState::AttitudeX.index();
520        let before = p.fixed_view::<3, 3>(theta, theta).into_owned();
521        let mut covariance = Covariance::from_matrix(p);
522        covariance.reset_attitude_direction(u, 0.25);
523        let q = covariance.as_matrix();
524        let block = q.fixed_view::<3, 3>(theta, theta).into_owned();
525
526        assert!(((u.transpose() * block * u)[0] - 0.25).abs() < 1e-6);
527        assert!((u.transpose() * block * w)[0].abs() < 1e-6);
528        assert!(((w.transpose() * block * w)[0] - (w.transpose() * before * w)[0]).abs() < 1e-6);
529        for k in (0..STATES).filter(|k| !(theta..theta + 3).contains(k)) {
530            let cross = u.dot(&q.fixed_view::<3, 1>(theta, k).into_owned());
531            assert!(
532                cross.abs() < 1e-6,
533                "the reset component correlates with {k}: {cross}"
534            );
535        }
536    }
537}