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}