Skip to main content

sidereon_core/dop/
mod.rs

1//! Dilution of precision (DOP) from a satellite geometry.
2//!
3//! Dilution of precision summarises how the receiver-to-satellite geometry maps
4//! measurement noise into solution uncertainty. From a design (geometry) matrix
5//! `H` whose rows are the unit line-of-sight vectors plus a clock column, and a
6//! diagonal weight matrix `W`, the cofactor matrix is
7//!
8//! ```text
9//! Q = (H^T W H)^-1
10//! ```
11//!
12//! a 4x4 symmetric matrix ordered `[x, y, z, clock]` (the position block in
13//! ECEF metres, the clock state in the same length unit as the ranges). The DOP
14//! scalars are square roots of sums of diagonal cofactor entries. The
15//! horizontal/vertical split is taken after rotating the 3x3 position block into
16//! a local east-north-up (ENU) frame at the receiver's geodetic
17//! latitude/longitude.
18//!
19//! # ENU convention
20//!
21//! HDOP, VDOP, and PDOP are convention-dependent: they split the position
22//! cofactor along a local "up" axis, and two definitions of "up" appear in the
23//! literature. The default everywhere ([`dop`], [`geometry_cofactor`], and the
24//! covariance/ellipse helpers) is the **geodetic-ellipsoid-normal** ENU built
25//! from the receiver's geodetic latitude/longitude (the GNSS standard, matching
26//! RTKLIB's `xyz2enu`). The alternative is a **geocentric-radial** ENU whose up
27//! is the spherical radial direction `position / |position|`. The two "up"
28//! axes differ by up to ~0.19 degrees (the deflection of the ellipsoid normal
29//! from the radial), which moves the horizontal/vertical split by on the order
30//! of `1e-3` relative. [`EnuConvention`] and the `*_with_convention` entry
31//! points let a caller select the geocentric-radial variant; the default
32//! helpers keep the geodetic-normal convention and its 0-ULP goldens. See
33//! [`crate::frame::geocentric_up`] for the geocentric-vs-geodetic distinction.
34//!
35//! # Reproducibility
36//!
37//! The normal matrix `H^T W H` is accumulated by a plain left-to-right sum over
38//! the satellites, and the 4x4 inverse is an explicit cofactor (adjugate /
39//! determinant) expansion with a fixed term order rather than a LAPACK
40//! factorisation. That keeps the whole computation libm/arithmetic-bound and
41//! independently reproducible to the bit (it does not depend on a BLAS backend),
42//! unlike a general dense inverse routed through LAPACK. The ENU rotation uses
43//! `sin`/`cos` and the final scalars use `sqrt`; there is no fused multiply-add.
44//!
45//! # Failure mode
46//!
47//! A geometry with fewer than four independent line-of-sight directions, or one
48//! whose normal matrix is singular or ill-conditioned, has no finite DOP. Such
49//! geometries are reported as [`DopError::Singular`] rather than returning a
50//! NaN-flagged or clamped result. The predicate is deterministic: the
51//! determinant is exactly zero, or one of the variance diagonals that a DOP
52//! scalar takes the square root of is negative or non-finite.
53
54use crate::astro::math::linear::{
55    invert_4x4_cofactor, invert_symmetric_pd, normal_matrix_4_weighted_column_outer, LinearError,
56};
57use crate::astro::math::mat3::{inline_rxr, inline_tr};
58
59use crate::frame::Wgs84Geodetic;
60use crate::id::GnssSystem;
61use crate::integrity::{self, IntegrityError};
62use crate::validate;
63
64pub use crate::integrity::ErrorEllipse2;
65
66const DEG_TO_RAD: f64 = std::f64::consts::PI / 180.0;
67const LOS_UNIT_TOLERANCE: f64 = 1.0e-3;
68/// Minimum ECEF radius (metres) for which the geocentric-radial "up" direction
69/// is well defined. A receiver at or within this distance of the geocenter has
70/// no meaningful radial axis, so [`EnuConvention::GeocentricRadial`] rejects it.
71const GEOCENTRIC_MIN_RADIUS_M: f64 = 1.0;
72
73/// A line-of-sight unit vector from the receiver toward a satellite, in ECEF.
74///
75/// The corresponding design-matrix row is `[-e_x, -e_y, -e_z, 1]`: the partial
76/// derivative of the predicted range with respect to the receiver position is
77/// `-e`, and the clock column is one (range increases one-for-one with the
78/// receiver clock bias expressed as a length).
79#[derive(Debug, Clone, Copy, PartialEq)]
80pub struct LineOfSight {
81    /// ECEF X component of the unit line-of-sight vector.
82    pub e_x: f64,
83    /// ECEF Y component of the unit line-of-sight vector.
84    pub e_y: f64,
85    /// ECEF Z component of the unit line-of-sight vector.
86    pub e_z: f64,
87}
88
89impl LineOfSight {
90    /// Construct a line-of-sight unit vector from ECEF components.
91    pub const fn new(e_x: f64, e_y: f64, e_z: f64) -> Self {
92        Self { e_x, e_y, e_z }
93    }
94
95    /// The design-matrix row `[-e_x, -e_y, -e_z, 1]` for this direction.
96    fn design_row(self) -> [f64; 4] {
97        [-self.e_x, -self.e_y, -self.e_z, 1.0]
98    }
99}
100
101/// Construct an ECEF line-of-sight unit vector from topocentric azimuth and
102/// elevation in degrees.
103///
104/// Azimuth is clockwise from geodetic north, elevation is positive above the
105/// local horizon, and the receiver latitude/longitude define the local ENU
106/// frame. The returned vector points from receiver to satellite in ECEF.
107pub fn line_of_sight_from_az_el_deg(
108    azimuth_deg: f64,
109    elevation_deg: f64,
110    receiver: Wgs84Geodetic,
111) -> Result<LineOfSight, DopError> {
112    validate_az_el_receiver(azimuth_deg, elevation_deg, receiver)?;
113    let az = azimuth_deg * DEG_TO_RAD;
114    let el = elevation_deg * DEG_TO_RAD;
115    let cos_el = el.cos();
116    let east = cos_el * az.sin();
117    let north = cos_el * az.cos();
118    let up = el.sin();
119
120    let r = ecef_to_enu_rotation(receiver.lat_rad, receiver.lon_rad);
121    let e_x = r[0][0] * east + r[1][0] * north + r[2][0] * up;
122    let e_y = r[0][1] * east + r[1][1] * north + r[2][1] * up;
123    let e_z = r[0][2] * east + r[1][2] * north + r[2][2] * up;
124    let los = LineOfSight::new(e_x, e_y, e_z);
125    validate_los(core::slice::from_ref(&los))?;
126    Ok(los)
127}
128
129/// The dilution-of-precision scalars for a geometry.
130///
131/// For GNSS range rows each scalar is dimensionless: the standard deviation of
132/// the solution component is the corresponding DOP times the range measurement
133/// standard deviation. General design rows keep the same field meanings, but
134/// the units follow the row scaling. A timing row with position columns in
135/// seconds per metre yields position DOP in metres per second of timing noise.
136/// The position split is in the local ENU frame at the receiver for GNSS rows,
137/// or in the caller-supplied rotated frame for general rows.
138///
139/// Produced by [`dop`] for a single receiver-clock state and by the positioning
140/// pipeline's multi-clock geometry path for a multi-system state; the field
141/// meanings below cover both.
142#[derive(Debug, Clone, PartialEq)]
143pub struct Dop {
144    /// Geometric DOP: the square root of the trace of the cofactor matrix over
145    /// every state - the three position coordinates and every clock (one for a
146    /// single-system solve, one per constellation for a multi-system solve).
147    pub gdop: f64,
148    /// Position DOP: `sqrt(qE + qN + qU)` over the ENU position block.
149    pub pdop: f64,
150    /// Horizontal DOP: `sqrt(qE + qN)`.
151    pub hdop: f64,
152    /// Vertical DOP: `sqrt(qU)`.
153    pub vdop: f64,
154    /// Time (clock) DOP: the square root of the reference clock's cofactor
155    /// variance (`Q[3][3]`). With several clocks this is the first (reference)
156    /// system's clock; the others enter `gdop` through the trace.
157    pub tdop: f64,
158    /// Per-system time DOP: one entry per receiver-clock column, `(system,
159    /// sqrt(Q[3+i][3+i]))` for the constellation that owns clock column `i`.
160    /// Entry `0` is the reference clock and its value always equals
161    /// [`tdop`](Self::tdop).
162    ///
163    /// The multi-GNSS geometry path (`dop_multi`) is given the system that owns
164    /// each clock column and tags every entry, so this is exactly the tagged
165    /// `Vec<(GnssSystem, f64)>` shape the positioning layer's `system_tdops`
166    /// uses: a consumer can pair a `Dop` with an SPP solution without
167    /// re-tagging. The system-agnostic single-clock [`dop`] has no
168    /// constellation context, so it leaves this empty - read
169    /// [`tdop`](Self::tdop) for its lone clock.
170    pub system_tdops: Vec<(GnssSystem, f64)>,
171}
172
173/// Cofactor matrices from a single-clock GNSS design matrix.
174///
175/// `state` is `(H^T W H)^-1` for rows `[-e_x, -e_y, -e_z, 1]`, ordered
176/// `[x, y, z, clock]`. The two position blocks are the top-left 3x3 block in ECEF
177/// coordinates and the same block rotated into the local ENU frame at the
178/// receiver.
179#[derive(Debug, Clone, PartialEq)]
180pub struct GeometryCofactor {
181    /// Full 4x4 state cofactor matrix, ordered `[x, y, z, clock]`.
182    pub state: [[f64; 4]; 4],
183    /// Position cofactor block in ECEF coordinates.
184    pub position_ecef: [[f64; 3]; 3],
185    /// Position cofactor block in local ENU coordinates.
186    pub position_enu: [[f64; 3]; 3],
187}
188
189/// Cofactor matrices from a caller-supplied design matrix.
190///
191/// `state` is `(H^T W H)^-1` for the supplied rows. `position` is the leading
192/// position block embedded in a 3x3 matrix, and `position_rotated` is
193/// `R position R^T` using the rotation matrix passed by the caller.
194#[derive(Debug, Clone, PartialEq)]
195pub struct DesignGeometryCofactor {
196    /// Full state cofactor matrix in the caller's state-column order.
197    pub state: Vec<Vec<f64>>,
198    /// Leading position block embedded in three axes.
199    pub position: [[f64; 3]; 3],
200    /// Rotated position block used for HDOP and VDOP.
201    pub position_rotated: [[f64; 3]; 3],
202}
203
204/// Position covariance from a GNSS design matrix.
205///
206/// The matrices are in square metres when the supplied variance scale is in
207/// square metres. Use a variance scale of `1.0` to get the raw cofactor block.
208#[derive(Debug, Clone, Copy, PartialEq)]
209pub struct PositionCovariance {
210    /// Position covariance in ECEF coordinates, square metres.
211    pub ecef_m2: [[f64; 3]; 3],
212    /// Position covariance in local ENU coordinates, square metres.
213    pub enu_m2: [[f64; 3]; 3],
214}
215
216/// Horizontal confidence ellipse from an ENU position covariance.
217///
218/// `azimuth_rad` is the semi-major-axis direction measured counter-clockwise from
219/// local east toward local north. `confidence` is the probability used for the
220/// two-degree-of-freedom chi-square scale.
221#[derive(Debug, Clone, Copy, PartialEq)]
222pub struct HorizontalErrorEllipse {
223    /// Requested confidence probability in `(0, 1)`.
224    pub confidence: f64,
225    /// Two-dimensional chi-square scale applied to the covariance eigenvalues.
226    pub chi_square_scale: f64,
227    /// Semi-major axis length, metres.
228    pub semi_major_m: f64,
229    /// Semi-minor axis length, metres.
230    pub semi_minor_m: f64,
231    /// Semi-major-axis azimuth in radians, from east toward north.
232    pub azimuth_rad: f64,
233}
234
235/// Why a geometry has no finite DOP.
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub enum DopError {
238    /// A boundary input was malformed before DOP could be evaluated.
239    InvalidInput {
240        /// Name of the malformed field.
241        field: &'static str,
242        /// Stable validation reason.
243        reason: &'static str,
244    },
245    /// Fewer line-of-sight directions than estimated parameters were supplied
246    /// (four for a single clock, `3 + n_clocks` for several), so the normal
247    /// matrix cannot be full rank.
248    TooFewSatellites,
249    /// The normal matrix `H^T W H` is singular or ill-conditioned: its
250    /// determinant is zero, or a variance diagonal is negative or non-finite.
251    Singular,
252}
253
254impl core::fmt::Display for DopError {
255    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
256        match self {
257            DopError::InvalidInput { field, reason } => {
258                write!(f, "invalid DOP input {field}: {reason}")
259            }
260            DopError::TooFewSatellites => {
261                write!(
262                    f,
263                    "fewer satellites than parameters: geometry is rank-deficient"
264                )
265            }
266            DopError::Singular => {
267                write!(f, "singular or ill-conditioned geometry: no finite DOP")
268            }
269        }
270    }
271}
272
273impl std::error::Error for DopError {}
274
275/// Which local east-north-up frame the position cofactor is rotated into before
276/// the horizontal/vertical DOP split.
277///
278/// The two conventions differ only in the definition of local "up" and so in
279/// the HDOP/VDOP partition; GDOP, PDOP, and TDOP are unaffected by the choice
280/// (PDOP is the trace of the position block, which is rotation-invariant). See
281/// the module-level "ENU convention" section for the ~0.19 degree difference
282/// between the axes.
283#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
284pub enum EnuConvention {
285    /// Geodetic-ellipsoid-normal ENU built from the receiver's geodetic
286    /// latitude/longitude. The GNSS-standard default (RTKLIB `xyz2enu`).
287    #[default]
288    GeodeticNormal,
289    /// Geocentric-radial ENU whose up is the spherical radial direction
290    /// `position / |position|` (see [`crate::frame::geocentric_neu_basis`]).
291    GeocentricRadial,
292}
293
294/// ECEF -> ENU rotation rows `[east; north; up]` for the requested convention.
295///
296/// `GeodeticNormal` returns exactly [`ecef_to_enu_rotation`] (so the default
297/// DOP path is byte-for-byte unchanged); `GeocentricRadial` builds the rows from
298/// [`crate::frame::geocentric_neu_basis`] at the receiver's ECEF position.
299fn enu_rotation(
300    receiver: Wgs84Geodetic,
301    convention: EnuConvention,
302) -> Result<[[f64; 3]; 3], DopError> {
303    match convention {
304        EnuConvention::GeodeticNormal => {
305            Ok(ecef_to_enu_rotation(receiver.lat_rad, receiver.lon_rad))
306        }
307        EnuConvention::GeocentricRadial => {
308            let ecef = crate::frame::geodetic_to_itrf(receiver)
309                .map_err(|_| invalid_input("receiver", "geocentric basis unavailable"))?;
310            let p = ecef.as_array();
311            // Geocentric "up" is position / |position|, which is undefined at the
312            // geocenter. `geocentric_neu_basis` would silently fall back to +Z
313            // there and return an arbitrary frame; reject a zero/near-zero radius
314            // instead of accepting that fabricated orientation.
315            // `p` comes from a validated-finite ItrfPositionM, so `radius` is
316            // finite and non-negative; a plain threshold comparison suffices.
317            let radius = (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt();
318            if radius <= GEOCENTRIC_MIN_RADIUS_M {
319                return Err(invalid_input(
320                    "receiver",
321                    "geocentric up undefined at zero radius",
322                ));
323            }
324            let (north, east, up) = crate::frame::geocentric_neu_basis(p);
325            Ok([east, north, up])
326        }
327    }
328}
329
330/// ECEF -> ENU rotation matrix at geodetic latitude/longitude (radians).
331pub(crate) fn ecef_to_enu_rotation(lat_rad: f64, lon_rad: f64) -> [[f64; 3]; 3] {
332    let sphi = lat_rad.sin();
333    let cphi = lat_rad.cos();
334    let slam = lon_rad.sin();
335    let clam = lon_rad.cos();
336    [
337        [-slam, clam, 0.0],
338        [-sphi * clam, -sphi * slam, cphi],
339        [cphi * clam, cphi * slam, sphi],
340    ]
341}
342
343/// Rotate the 3x3 position cofactor block: `Q_enu = R Q_xyz R^T`, formed as
344/// `(R Q) R^T`. Reads the top-left 3x3 block of the 4x4 cofactor matrix and
345/// defers to [`rotate3`].
346fn rotate_pos_block(q: &[[f64; 4]; 4], r: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
347    let qpos = [
348        [q[0][0], q[0][1], q[0][2]],
349        [q[1][0], q[1][1], q[1][2]],
350        [q[2][0], q[2][1], q[2][2]],
351    ];
352    rotate3(&qpos, r)
353}
354
355/// Compute the DOP scalars from line-of-sight directions, diagonal weights, and
356/// the receiver geodetic position.
357///
358/// `los` and `weights` must have the same length, which must be at least four;
359/// `weights` are the non-negative diagonal of `W`. Returns
360/// [`DopError::TooFewSatellites`] for fewer than four directions and
361/// [`DopError::Singular`] for a singular or
362/// ill-conditioned geometry (see the module docs for the exact predicate).
363pub fn dop(los: &[LineOfSight], weights: &[f64], receiver: Wgs84Geodetic) -> Result<Dop, DopError> {
364    dop_with_convention(los, weights, receiver, EnuConvention::GeodeticNormal)
365}
366
367/// [`dop`] with an explicit [`EnuConvention`] for the horizontal/vertical split.
368///
369/// [`EnuConvention::GeodeticNormal`] is the default [`dop`] path (0-ULP
370/// goldens); [`EnuConvention::GeocentricRadial`] rotates the position block into
371/// the geocentric-radial ENU instead, changing only HDOP/VDOP (by ~`1e-3`
372/// relative; see the module "ENU convention" section). GDOP/PDOP/TDOP are
373/// identical between conventions.
374pub fn dop_with_convention(
375    los: &[LineOfSight],
376    weights: &[f64],
377    receiver: Wgs84Geodetic,
378    convention: EnuConvention,
379) -> Result<Dop, DopError> {
380    validate_dop_inputs(los, weights, receiver)?;
381    if los.len() < 4 {
382        return Err(DopError::TooFewSatellites);
383    }
384
385    let rows: Vec<[f64; 4]> = los.iter().map(|l| l.design_row()).collect();
386    let a = normal_matrix_4_weighted_column_outer(&rows, weights).map_err(map_linear_error)?;
387    let q = invert_4x4_cofactor(&a).ok_or(DopError::Singular)?;
388
389    let r = enu_rotation(receiver, convention)?;
390    let enu = rotate_pos_block(&q, &r);
391
392    let qe = enu[0][0];
393    let qn = enu[1][1];
394    let qu = enu[2][2];
395    let qt = q[3][3];
396
397    // The DOP scalars take the square root of cofactor variances. A
398    // well-posed geometry yields a positive-definite Q with strictly positive
399    // variance diagonals; a rank-deficient or ill-conditioned geometry can
400    // leave a tiny nonzero determinant (so `inv4` succeeds) yet produce a
401    // negative or non-finite variance. Reject that here rather than returning a
402    // NaN-flagged DOP. The same deterministic predicate is applied by the
403    // reference recipe so both agree on the success/failure boundary.
404    let gdop_arg = q[0][0] + q[1][1] + q[2][2] + q[3][3];
405    let pdop_arg = qe + qn + qu;
406    let hdop_arg = qe + qn;
407    let vdop_arg = qu;
408    let tdop_arg = qt;
409    for arg in [gdop_arg, pdop_arg, hdop_arg, vdop_arg, tdop_arg] {
410        // `!(arg >= 0.0)` (not `arg < 0.0`) so a NaN variance is also rejected.
411        #[allow(clippy::neg_cmp_op_on_partial_ord)]
412        let nonpositive_or_nan = !(arg >= 0.0);
413        if nonpositive_or_nan || !arg.is_finite() {
414            return Err(DopError::Singular);
415        }
416    }
417
418    Ok(Dop {
419        gdop: gdop_arg.sqrt(),
420        pdop: pdop_arg.sqrt(),
421        hdop: hdop_arg.sqrt(),
422        vdop: vdop_arg.sqrt(),
423        tdop: tdop_arg.sqrt(),
424        // A single shared clock has exactly one clock column, but this entry
425        // point carries no constellation identity, so the per-system vector is
426        // empty (read `tdop` for the lone clock). The multi-system `dop_multi`
427        // path, which does know each column's system, returns it tagged.
428        system_tdops: Vec::new(),
429    })
430}
431
432/// Compute the single-clock geometry cofactor matrix from line-of-sight rows.
433///
434/// This exposes the same cofactor matrix that [`dop`] uses internally:
435/// `Q = (H^T W H)^-1`, with `H` rows `[-e_x, -e_y, -e_z, 1]`. The inverse is the
436/// deterministic 4x4 cofactor expansion used by the 0-ULP DOP parity tests.
437pub fn geometry_cofactor(
438    los: &[LineOfSight],
439    weights: &[f64],
440    receiver: Wgs84Geodetic,
441) -> Result<GeometryCofactor, DopError> {
442    geometry_cofactor_with_convention(los, weights, receiver, EnuConvention::GeodeticNormal)
443}
444
445/// [`geometry_cofactor`] with an explicit [`EnuConvention`] for the `position_enu`
446/// block.
447///
448/// `position_ecef` and `state` are convention-independent; only `position_enu`
449/// changes. [`EnuConvention::GeodeticNormal`] is the default
450/// [`geometry_cofactor`] path.
451pub fn geometry_cofactor_with_convention(
452    los: &[LineOfSight],
453    weights: &[f64],
454    receiver: Wgs84Geodetic,
455    convention: EnuConvention,
456) -> Result<GeometryCofactor, DopError> {
457    validate_dop_inputs(los, weights, receiver)?;
458    if los.len() < 4 {
459        return Err(DopError::TooFewSatellites);
460    }
461
462    let rows: Vec<[f64; 4]> = los.iter().map(|l| l.design_row()).collect();
463    let a = normal_matrix_4_weighted_column_outer(&rows, weights).map_err(map_linear_error)?;
464    let q = invert_4x4_cofactor(&a).ok_or(DopError::Singular)?;
465    validate_cofactor_variances(&q)?;
466
467    let r = enu_rotation(receiver, convention)?;
468    let enu = rotate_pos_block(&q, &r);
469    validate_matrix3(&enu, "position_enu")?;
470    Ok(GeometryCofactor {
471        state: q,
472        position_ecef: position_block(&q),
473        position_enu: enu,
474    })
475}
476
477/// Compute DOP scalars from a caller-supplied design matrix.
478///
479/// Each row must have `position_dimension + 1` columns. The leading
480/// `position_dimension` columns are position partial derivatives. The final
481/// column is the time or clock state used for TDOP. `position_rotation` is a
482/// 3x3 row rotation applied before HDOP and VDOP are formed. Pass the identity
483/// matrix for a local Cartesian frame, or an ECEF-to-ENU rotation for Earth
484/// fixed rows.
485pub fn dop_from_design_rows(
486    rows: &[Vec<f64>],
487    weights: &[f64],
488    position_dimension: usize,
489    position_rotation: [[f64; 3]; 3],
490) -> Result<Dop, DopError> {
491    let cofactor =
492        geometry_cofactor_from_design_rows(rows, weights, position_dimension, position_rotation)?;
493    let time_col = position_dimension;
494    let q = &cofactor.state;
495    let rotated = cofactor.position_rotated;
496    let qe = rotated[0][0];
497    let qn = rotated[1][1];
498    let qu = rotated[2][2];
499    let qt = q[time_col][time_col];
500    let trace: f64 = (0..q.len()).map(|i| q[i][i]).sum();
501
502    let gdop_arg = trace;
503    let pdop_arg = qe + qn + qu;
504    let hdop_arg = qe + qn;
505    let vdop_arg = qu;
506    let tdop_arg = qt;
507    for arg in [gdop_arg, pdop_arg, hdop_arg, vdop_arg, tdop_arg] {
508        #[allow(clippy::neg_cmp_op_on_partial_ord)]
509        let negative_or_nan = !(arg >= 0.0);
510        if negative_or_nan || !arg.is_finite() {
511            return Err(DopError::Singular);
512        }
513    }
514
515    Ok(Dop {
516        gdop: gdop_arg.sqrt(),
517        pdop: pdop_arg.sqrt(),
518        hdop: hdop_arg.sqrt(),
519        vdop: vdop_arg.sqrt(),
520        tdop: tdop_arg.sqrt(),
521        system_tdops: Vec::new(),
522    })
523}
524
525/// Compute a cofactor matrix from caller-supplied design rows.
526///
527/// This is the general counterpart to [`geometry_cofactor`]. It uses the same
528/// weighted normal matrix and positive-definite inverse policy as the
529/// multi-clock DOP path, so rank-deficient layouts return
530/// [`DopError::Singular`].
531pub fn geometry_cofactor_from_design_rows(
532    rows: &[Vec<f64>],
533    weights: &[f64],
534    position_dimension: usize,
535    position_rotation: [[f64; 3]; 3],
536) -> Result<DesignGeometryCofactor, DopError> {
537    validate_design_rows(rows, weights, position_dimension, &position_rotation)?;
538    let p = position_dimension + 1;
539    if rows.len() < p {
540        return Err(DopError::TooFewSatellites);
541    }
542
543    let q = if p == 4 {
544        let fixed_rows: Vec<[f64; 4]> = rows
545            .iter()
546            .map(|row| [row[0], row[1], row[2], row[3]])
547            .collect();
548        let normal = normal_matrix_4_weighted_column_outer(&fixed_rows, weights)
549            .map_err(map_linear_error)?;
550        let fixed = invert_4x4_cofactor(&normal).ok_or(DopError::Singular)?;
551        fixed.iter().map(|row| row.to_vec()).collect()
552    } else {
553        let mut normal = vec![vec![0.0_f64; p]; p];
554        for (row, &weight) in rows.iter().zip(weights) {
555            for i in 0..p {
556                for j in 0..p {
557                    normal[i][j] += row[i] * weight * row[j];
558                }
559            }
560        }
561        invert_symmetric_pd(&normal).ok_or(DopError::Singular)?
562    };
563    validate_general_cofactor_variances(&q)?;
564
565    let mut position = [[0.0_f64; 3]; 3];
566    for i in 0..position_dimension {
567        for j in 0..position_dimension {
568            position[i][j] = q[i][j];
569        }
570    }
571    let position_rotated = rotate3(&position, &position_rotation);
572    validate_matrix3(&position_rotated, "position_rotated")?;
573
574    Ok(DesignGeometryCofactor {
575        state: q,
576        position,
577        position_rotated,
578    })
579}
580
581/// Position covariance from a single-clock GNSS design matrix.
582///
583/// `range_variance_scale_m2` multiplies the raw cofactor. For unit weights with a
584/// common pseudorange standard deviation, pass `sigma_m * sigma_m`. If `weights`
585/// already carry inverse variances, pass `1.0`.
586pub fn position_covariance_from_geometry_m2(
587    los: &[LineOfSight],
588    weights: &[f64],
589    receiver: Wgs84Geodetic,
590    range_variance_scale_m2: f64,
591) -> Result<PositionCovariance, DopError> {
592    validate_variance_scale(range_variance_scale_m2)?;
593    let cofactor = geometry_cofactor(los, weights, receiver)?;
594    Ok(PositionCovariance {
595        ecef_m2: scale_matrix3(cofactor.position_ecef, range_variance_scale_m2),
596        enu_m2: scale_matrix3(cofactor.position_enu, range_variance_scale_m2),
597    })
598}
599
600/// Rotate an ECEF position covariance into local ENU square metres.
601///
602/// The rotation is `R * covariance_ecef_m2 * R^T` using the same geodetic ENU
603/// rows as [`dop`] and [`geometry_cofactor`].
604pub fn rotate_covariance_ecef_to_enu_m2(
605    covariance_ecef_m2: [[f64; 3]; 3],
606    receiver: Wgs84Geodetic,
607) -> Result<[[f64; 3]; 3], DopError> {
608    validate_matrix3(&covariance_ecef_m2, "covariance_ecef_m2")?;
609    validate_receiver(receiver)?;
610    let r = ecef_to_enu_rotation(receiver.lat_rad, receiver.lon_rad);
611    Ok(rotate3(&covariance_ecef_m2, &r))
612}
613
614/// Horizontal confidence ellipse from a local ENU covariance matrix.
615///
616/// The 2D horizontal covariance is the east/north block of `covariance_enu_m2`.
617/// The scale is the two-degree-of-freedom chi-square quantile
618/// `-2 ln(1 - confidence)`.
619pub fn horizontal_error_ellipse(
620    covariance_enu_m2: [[f64; 3]; 3],
621    confidence: f64,
622) -> Result<HorizontalErrorEllipse, DopError> {
623    validate_matrix3(&covariance_enu_m2, "covariance_enu_m2")?;
624    let en_block = [
625        [covariance_enu_m2[0][0], covariance_enu_m2[0][1]],
626        [covariance_enu_m2[1][0], covariance_enu_m2[1][1]],
627    ];
628    let ellipse = error_ellipse_2x2(en_block, confidence).map_err(|err| match err {
629        // Re-label only the covariance field so the GNSS wrapper reports its own
630        // argument name; a confidence error keeps its field.
631        DopError::InvalidInput {
632            field: "covariance",
633            reason,
634        } => invalid_input("covariance_enu_m2", reason),
635        other => other,
636    })?;
637    Ok(HorizontalErrorEllipse {
638        confidence: ellipse.confidence,
639        chi_square_scale: ellipse.chi_square_scale,
640        semi_major_m: ellipse.semi_major,
641        semi_minor_m: ellipse.semi_minor,
642        azimuth_rad: ellipse.orientation_rad,
643    })
644}
645
646/// Confidence ellipse from an arbitrary 2x2 covariance block.
647///
648/// Domain-neutral primitive: the semi-axes are scaled by the two-degree-of-
649/// freedom chi-square quantile `-2 ln(1 - confidence)` applied to the
650/// eigenvalues of the symmetrized block. [`horizontal_error_ellipse`] is the
651/// GNSS-facing wrapper that feeds it the local east/north block, so both share
652/// this eigensolve (and its goldens). The eigenvalues come from the closed-form
653/// 2x2 symmetric solution `lambda = center +/- sqrt(half_delta^2 + b^2)`.
654pub fn error_ellipse_2x2(
655    covariance: [[f64; 2]; 2],
656    confidence: f64,
657) -> Result<ErrorEllipse2, DopError> {
658    integrity::error_ellipse_2x2(covariance, confidence).map_err(map_integrity_error)
659}
660
661/// One-sigma ellipse from an arbitrary 2x2 covariance block.
662///
663/// This is the same symmetric eigensolve as [`error_ellipse_2x2`], with a unit
664/// covariance scale instead of a confidence contour scale.
665pub fn error_ellipse_2x2_unit(covariance: [[f64; 2]; 2]) -> Result<ErrorEllipse2, DopError> {
666    integrity::error_ellipse_2x2_unit(covariance).map_err(map_integrity_error)
667}
668
669/// Horizontal confidence ellipse directly from line-of-sight rows and weights.
670pub fn error_ellipse_from_geometry(
671    los: &[LineOfSight],
672    weights: &[f64],
673    receiver: Wgs84Geodetic,
674    range_variance_scale_m2: f64,
675    confidence: f64,
676) -> Result<HorizontalErrorEllipse, DopError> {
677    let covariance =
678        position_covariance_from_geometry_m2(los, weights, receiver, range_variance_scale_m2)?;
679    horizontal_error_ellipse(covariance.enu_m2, confidence)
680}
681
682fn validate_dop_inputs(
683    los: &[LineOfSight],
684    weights: &[f64],
685    receiver: Wgs84Geodetic,
686) -> Result<(), DopError> {
687    if los.len() != weights.len() {
688        return Err(invalid_input("weights", "length must match los"));
689    }
690    validate_los(los)?;
691    validate_weights(weights)?;
692    validate_receiver(receiver)
693}
694
695fn validate_los(los: &[LineOfSight]) -> Result<(), DopError> {
696    for line in los {
697        if !(line.e_x.is_finite() && line.e_y.is_finite() && line.e_z.is_finite()) {
698            return Err(invalid_input("los", "not finite"));
699        }
700        let norm = (line.e_x * line.e_x + line.e_y * line.e_y + line.e_z * line.e_z).sqrt();
701        if !norm.is_finite() {
702            return Err(invalid_input("los", "not finite"));
703        }
704        if (norm - 1.0).abs() > LOS_UNIT_TOLERANCE {
705            return Err(invalid_input("los", "not unit length"));
706        }
707    }
708    Ok(())
709}
710
711fn validate_cofactor_variances(q: &[[f64; 4]; 4]) -> Result<(), DopError> {
712    for row in q {
713        validate::finite_slice(row, "cofactor").map_err(map_validation_error)?;
714    }
715    for (idx, row) in q.iter().enumerate() {
716        let variance = row[idx];
717        #[allow(clippy::neg_cmp_op_on_partial_ord)]
718        let negative_or_nan = !(variance >= 0.0);
719        if negative_or_nan || !variance.is_finite() {
720            return Err(DopError::Singular);
721        }
722    }
723    Ok(())
724}
725
726fn validate_variance_scale(value: f64) -> Result<(), DopError> {
727    if !value.is_finite() {
728        return Err(invalid_input("range_variance_scale_m2", "not finite"));
729    }
730    if value < 0.0 {
731        return Err(invalid_input("range_variance_scale_m2", "negative"));
732    }
733    Ok(())
734}
735
736fn validate_matrix3(matrix: &[[f64; 3]; 3], field: &'static str) -> Result<(), DopError> {
737    for row in matrix {
738        validate::finite_slice(row, field).map_err(map_validation_error)?;
739    }
740    Ok(())
741}
742
743fn validate_design_rows(
744    rows: &[Vec<f64>],
745    weights: &[f64],
746    position_dimension: usize,
747    position_rotation: &[[f64; 3]; 3],
748) -> Result<(), DopError> {
749    if !(2..=3).contains(&position_dimension) {
750        return Err(invalid_input("position_dimension", "must be 2 or 3"));
751    }
752    if rows.len() != weights.len() {
753        return Err(invalid_input("weights", "length must match rows"));
754    }
755    let p = position_dimension + 1;
756    for row in rows {
757        if row.len() != p {
758            return Err(invalid_input("rows", "length must match state dimension"));
759        }
760        validate::finite_slice(row, "rows").map_err(map_validation_error)?;
761    }
762    validate_weights(weights)?;
763    validate_matrix3(position_rotation, "position_rotation")
764}
765
766fn validate_general_cofactor_variances(q: &[Vec<f64>]) -> Result<(), DopError> {
767    for row in q {
768        validate::finite_slice(row, "cofactor").map_err(map_validation_error)?;
769    }
770    for (idx, row) in q.iter().enumerate() {
771        let variance = row[idx];
772        #[allow(clippy::neg_cmp_op_on_partial_ord)]
773        let negative_or_nan = !(variance >= 0.0);
774        if negative_or_nan || !variance.is_finite() {
775            return Err(DopError::Singular);
776        }
777    }
778    Ok(())
779}
780
781fn position_block(q: &[[f64; 4]; 4]) -> [[f64; 3]; 3] {
782    [
783        [q[0][0], q[0][1], q[0][2]],
784        [q[1][0], q[1][1], q[1][2]],
785        [q[2][0], q[2][1], q[2][2]],
786    ]
787}
788
789fn scale_matrix3(matrix: [[f64; 3]; 3], scale: f64) -> [[f64; 3]; 3] {
790    [
791        [
792            matrix[0][0] * scale,
793            matrix[0][1] * scale,
794            matrix[0][2] * scale,
795        ],
796        [
797            matrix[1][0] * scale,
798            matrix[1][1] * scale,
799            matrix[1][2] * scale,
800        ],
801        [
802            matrix[2][0] * scale,
803            matrix[2][1] * scale,
804            matrix[2][2] * scale,
805        ],
806    ]
807}
808
809fn validate_weights(weights: &[f64]) -> Result<(), DopError> {
810    if weights.iter().any(|weight| !weight.is_finite()) {
811        return Err(invalid_input("weights", "not finite"));
812    }
813    if weights.iter().any(|&weight| weight < 0.0) {
814        return Err(invalid_input("weights", "negative"));
815    }
816    Ok(())
817}
818
819fn validate_receiver(receiver: Wgs84Geodetic) -> Result<(), DopError> {
820    if !(receiver.lat_rad.is_finite()
821        && receiver.lon_rad.is_finite()
822        && receiver.height_m.is_finite())
823    {
824        return Err(invalid_input("receiver", "not finite"));
825    }
826    if !(-core::f64::consts::FRAC_PI_2..=core::f64::consts::FRAC_PI_2).contains(&receiver.lat_rad) {
827        return Err(invalid_input("receiver.lat_rad", "out of range"));
828    }
829    if !(-core::f64::consts::PI..=core::f64::consts::PI).contains(&receiver.lon_rad) {
830        return Err(invalid_input("receiver.lon_rad", "out of range"));
831    }
832    Ok(())
833}
834
835fn validate_az_el_receiver(
836    azimuth_deg: f64,
837    elevation_deg: f64,
838    receiver: Wgs84Geodetic,
839) -> Result<(), DopError> {
840    if !azimuth_deg.is_finite() {
841        return Err(invalid_input("azimuth_deg", "not finite"));
842    }
843    if !elevation_deg.is_finite() {
844        return Err(invalid_input("elevation_deg", "not finite"));
845    }
846    if !(-90.0..=90.0).contains(&elevation_deg) {
847        return Err(invalid_input("elevation_deg", "out of range"));
848    }
849    validate_receiver(receiver)
850}
851
852fn invalid_input(field: &'static str, reason: &'static str) -> DopError {
853    DopError::InvalidInput { field, reason }
854}
855
856fn map_linear_error(error: LinearError) -> DopError {
857    match error {
858        LinearError::InvalidInput { field, reason } => invalid_input(field, reason),
859    }
860}
861
862fn map_validation_error(error: validate::FieldError) -> DopError {
863    invalid_input(error.field(), error.reason())
864}
865
866fn map_integrity_error(error: IntegrityError) -> DopError {
867    match error {
868        IntegrityError::NonFinite => invalid_input("covariance", "not finite"),
869        IntegrityError::NotPositiveSemidefinite => {
870            invalid_input("covariance", "not positive semidefinite")
871        }
872        IntegrityError::InvalidProbability { reason } => invalid_input("confidence", reason),
873        IntegrityError::InvalidInput { field, reason } => invalid_input(field, reason),
874        IntegrityError::Singular => DopError::Singular,
875    }
876}
877
878// --- multi-system DOP (general (3 + n_systems) x (3 + n_systems)) -----------
879
880/// `R Q R^T` for a 3x3 position cofactor block, formed as `(R Q) R^T`. Both
881/// products use [`inline_rxr`]'s fixed left-to-right inner-sum order, so the
882/// result is bit-identical to the explicit double loop this replaced.
883fn rotate3(q: &[[f64; 3]; 3], r: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
884    inline_rxr(&inline_rxr(r, q), &inline_tr(r))
885}
886
887/// Multi-system dilution of precision: like [`dop`] but with one receiver-clock
888/// column per GNSS rather than a single shared clock.
889///
890/// `clock_index[i]` is the clock column (`0..n_clocks`) for satellite `i` - its
891/// system's index in the solve's clock ordering. `systems[c]` is the GNSS that
892/// owns clock column `c`, so `systems.len() == n_clocks` and the column ordering
893/// matches the caller's clock ordering. The design row is therefore
894/// `[-e_x, -e_y, -e_z, <one-hot over the n_clocks clock columns>]` and the
895/// cofactor matrix is `(3 + n_clocks) x (3 + n_clocks)`. `PDOP`/`HDOP`/`VDOP` are
896/// the position block (ENU-rotated, unambiguous); `GDOP` is the square root of
897/// the full trace (all clocks); `TDOP` is the reference-system clock
898/// (`Q[3][3]`). The per-system TDOPs (`sqrt(Q[3+c][3+c])` for every clock,
899/// tagged with `systems[c]`) are returned in [`Dop::system_tdops`], in
900/// clock-column order. Returns [`DopError::Singular`] for a rank-deficient
901/// geometry.
902///
903/// This path uses a general symmetric inverse (see [`invert_symmetric_pd`]) and
904/// is a deterministic geometry diagnostic, not a 0-ULP parity target; the
905/// single-system [`dop`] retains the 0-ULP cofactor inverse.
906///
907/// Crate-internal: every `clock_index` must be in `0..n_clocks` (the solver
908/// constructs them from the used satellites' system ordering, so this always
909/// holds). It is not part of the public API because the index convention is
910/// meaningless without the solver's clock ordering.
911pub(crate) fn dop_multi(
912    los: &[LineOfSight],
913    clock_index: &[usize],
914    systems: &[GnssSystem],
915    n_clocks: usize,
916    weights: &[f64],
917    receiver: Wgs84Geodetic,
918) -> Result<Dop, DopError> {
919    validate_dop_multi_inputs(los, clock_index, systems, n_clocks, weights, receiver)?;
920    let p = 3 + n_clocks;
921    if los.len() < p {
922        return Err(DopError::TooFewSatellites);
923    }
924
925    let mut a = vec![vec![0.0_f64; p]; p];
926    for k in 0..los.len() {
927        let mut row = vec![0.0_f64; p];
928        row[0] = -los[k].e_x;
929        row[1] = -los[k].e_y;
930        row[2] = -los[k].e_z;
931        row[3 + clock_index[k]] = 1.0;
932        let w = weights[k];
933        #[allow(clippy::needless_range_loop)]
934        for i in 0..p {
935            for j in 0..p {
936                a[i][j] += row[i] * w * row[j];
937            }
938        }
939    }
940    let q = invert_symmetric_pd(&a).ok_or(DopError::Singular)?;
941
942    let r = ecef_to_enu_rotation(receiver.lat_rad, receiver.lon_rad);
943    let qpos = [
944        [q[0][0], q[0][1], q[0][2]],
945        [q[1][0], q[1][1], q[1][2]],
946        [q[2][0], q[2][1], q[2][2]],
947    ];
948    let enu = rotate3(&qpos, &r);
949
950    let qe = enu[0][0];
951    let qn = enu[1][1];
952    let qu = enu[2][2];
953    let qt = q[3][3];
954    let trace: f64 = (0..p).map(|i| q[i][i]).sum();
955    // Per-clock-column variances `Q[3+i][3+i]`, in clock-column order. Column 0
956    // is the reference clock, so its variance is exactly `qt`.
957    let system_tdop_args: Vec<f64> = (0..n_clocks).map(|i| q[3 + i][3 + i]).collect();
958
959    let gdop_arg = trace;
960    let pdop_arg = qe + qn + qu;
961    let hdop_arg = qe + qn;
962    let vdop_arg = qu;
963    let tdop_arg = qt;
964    // Every variance a DOP scalar takes the square root of - including each
965    // per-system clock variance - must be finite and non-negative; a
966    // rank-deficient geometry can leave a negative or non-finite diagonal even
967    // when the full trace stays positive, so reject those here.
968    for &arg in [gdop_arg, pdop_arg, hdop_arg, vdop_arg, tdop_arg]
969        .iter()
970        .chain(system_tdop_args.iter())
971    {
972        #[allow(clippy::neg_cmp_op_on_partial_ord)]
973        let nonpositive_or_nan = !(arg >= 0.0);
974        if nonpositive_or_nan || !arg.is_finite() {
975            return Err(DopError::Singular);
976        }
977    }
978
979    Ok(Dop {
980        gdop: gdop_arg.sqrt(),
981        pdop: pdop_arg.sqrt(),
982        hdop: hdop_arg.sqrt(),
983        vdop: vdop_arg.sqrt(),
984        tdop: tdop_arg.sqrt(),
985        system_tdops: system_tdop_args
986            .iter()
987            .enumerate()
988            .map(|(i, &v)| (systems[i], v.sqrt()))
989            .collect(),
990    })
991}
992
993fn validate_dop_multi_inputs(
994    los: &[LineOfSight],
995    clock_index: &[usize],
996    systems: &[GnssSystem],
997    n_clocks: usize,
998    weights: &[f64],
999    receiver: Wgs84Geodetic,
1000) -> Result<(), DopError> {
1001    if los.len() != weights.len() {
1002        return Err(invalid_input("weights", "length must match los"));
1003    }
1004    if los.len() != clock_index.len() {
1005        return Err(invalid_input("clock_index", "length must match los"));
1006    }
1007    if n_clocks == 0 {
1008        return Err(invalid_input("n_clocks", "not positive"));
1009    }
1010    if systems.len() != n_clocks {
1011        return Err(invalid_input("systems", "length must match n_clocks"));
1012    }
1013    if clock_index.iter().any(|&idx| idx >= n_clocks) {
1014        return Err(invalid_input("clock_index", "out of range"));
1015    }
1016    validate_los(los)?;
1017    validate_weights(weights)?;
1018    validate_receiver(receiver)
1019}
1020
1021#[cfg(all(test, sidereon_repo_tests))]
1022pub(crate) mod test_support {
1023    //! Internal accessors so the parity test can assert 0 ULP on the
1024    //! intermediates (normal matrix, cofactor matrix, ENU block) as well as the
1025    //! final scalars, without making them part of the public API.
1026    use super::*;
1027
1028    pub(crate) fn normal_matrix_for(los: &[LineOfSight], weights: &[f64]) -> [[f64; 4]; 4] {
1029        let rows: Vec<[f64; 4]> = los.iter().map(|l| l.design_row()).collect();
1030        normal_matrix_4_weighted_column_outer(&rows, weights).expect("valid DOP test inputs")
1031    }
1032
1033    pub(crate) fn det4_for(a: &[[f64; 4]; 4]) -> f64 {
1034        crate::astro::math::linear::det4_cofactor(a)
1035    }
1036
1037    pub(crate) fn inv4_for(a: &[[f64; 4]; 4]) -> Option<[[f64; 4]; 4]> {
1038        invert_4x4_cofactor(a)
1039    }
1040
1041    pub(crate) fn enu_block_for(q: &[[f64; 4]; 4], lat_rad: f64, lon_rad: f64) -> [[f64; 3]; 3] {
1042        let r = ecef_to_enu_rotation(lat_rad, lon_rad);
1043        rotate_pos_block(q, &r)
1044    }
1045}
1046
1047#[cfg(test)]
1048mod public_api_tests {
1049    use super::*;
1050
1051    fn receiver() -> Wgs84Geodetic {
1052        Wgs84Geodetic::new(45.0_f64.to_radians(), -75.0_f64.to_radians(), 100.0)
1053            .expect("valid receiver")
1054    }
1055
1056    fn sample_geometry() -> (Vec<LineOfSight>, Vec<f64>, Wgs84Geodetic) {
1057        let rx = receiver();
1058        let az_el = [
1059            (5.0, 25.0),
1060            (80.0, 35.0),
1061            (155.0, 55.0),
1062            (235.0, 40.0),
1063            (310.0, 65.0),
1064        ];
1065        let los = az_el
1066            .into_iter()
1067            .map(|(az, el)| line_of_sight_from_az_el_deg(az, el, rx).expect("valid LOS"))
1068            .collect::<Vec<_>>();
1069        let weights = vec![1.0, 0.8, 1.4, 0.9, 1.1];
1070        (los, weights, rx)
1071    }
1072
1073    #[test]
1074    fn geometry_cofactor_exposes_the_dop_position_block() {
1075        let (los, weights, rx) = sample_geometry();
1076        let d = dop(&los, &weights, rx).expect("DOP");
1077        let q = geometry_cofactor(&los, &weights, rx).expect("cofactor");
1078
1079        let qe = q.position_enu[0][0];
1080        let qn = q.position_enu[1][1];
1081        let qu = q.position_enu[2][2];
1082        assert_eq!(d.pdop.to_bits(), (qe + qn + qu).sqrt().to_bits());
1083        assert_eq!(d.hdop.to_bits(), (qe + qn).sqrt().to_bits());
1084        assert_eq!(d.vdop.to_bits(), qu.sqrt().to_bits());
1085        assert_eq!(d.tdop.to_bits(), q.state[3][3].sqrt().to_bits());
1086    }
1087
1088    #[test]
1089    fn position_covariance_scales_the_raw_cofactor() {
1090        let (los, weights, rx) = sample_geometry();
1091        let q = geometry_cofactor(&los, &weights, rx).expect("cofactor");
1092        let cov =
1093            position_covariance_from_geometry_m2(&los, &weights, rx, 4.0).expect("covariance");
1094        for i in 0..3 {
1095            for j in 0..3 {
1096                assert_eq!(
1097                    cov.ecef_m2[i][j].to_bits(),
1098                    (q.position_ecef[i][j] * 4.0).to_bits()
1099                );
1100                assert_eq!(
1101                    cov.enu_m2[i][j].to_bits(),
1102                    (q.position_enu[i][j] * 4.0).to_bits()
1103                );
1104            }
1105        }
1106    }
1107
1108    #[test]
1109    fn horizontal_error_ellipse_uses_chi_square_two_dof_scale() {
1110        let covariance = [[9.0, 2.0, 0.0], [2.0, 4.0, 0.0], [0.0, 0.0, 16.0]];
1111        let ellipse = horizontal_error_ellipse(covariance, 0.95).expect("ellipse");
1112        let expected_scale = -2.0 * (1.0_f64 - 0.95).ln();
1113        assert_eq!(ellipse.chi_square_scale.to_bits(), expected_scale.to_bits());
1114        assert!(ellipse.semi_major_m >= ellipse.semi_minor_m);
1115        assert!(ellipse.semi_minor_m > 0.0);
1116        assert!(ellipse.azimuth_rad.is_finite());
1117    }
1118
1119    #[test]
1120    fn error_ellipse_2x2_matches_numpy_eigh() {
1121        // numpy.linalg.eigh of [[9, 2], [2, 4]] with the chi2(2) 0.95 scale.
1122        let ellipse = error_ellipse_2x2([[9.0, 2.0], [2.0, 4.0]], 0.95).expect("ellipse");
1123        assert!((ellipse.chi_square_scale - 5.99146454710798).abs() < 1e-12);
1124        assert!((ellipse.semi_major - 7.6240780089041085).abs() < 1e-12);
1125        assert!((ellipse.semi_minor - 4.445500379771495).abs() < 1e-12);
1126        assert!((ellipse.orientation_rad - 0.3373704711117763).abs() < 1e-12);
1127    }
1128
1129    #[test]
1130    fn horizontal_error_ellipse_delegates_to_2x2_primitive() {
1131        // The GNSS wrapper must be byte-identical to the 2x2 primitive on the
1132        // east/north block.
1133        let cov3 = [[9.0, 2.0, 1.0], [2.0, 4.0, -3.0], [1.0, -3.0, 16.0]];
1134        let wrapper = horizontal_error_ellipse(cov3, 0.95).expect("wrapper");
1135        let primitive =
1136            error_ellipse_2x2([[cov3[0][0], cov3[0][1]], [cov3[1][0], cov3[1][1]]], 0.95)
1137                .expect("primitive");
1138        assert_eq!(
1139            wrapper.semi_major_m.to_bits(),
1140            primitive.semi_major.to_bits()
1141        );
1142        assert_eq!(
1143            wrapper.semi_minor_m.to_bits(),
1144            primitive.semi_minor.to_bits()
1145        );
1146        assert_eq!(
1147            wrapper.azimuth_rad.to_bits(),
1148            primitive.orientation_rad.to_bits()
1149        );
1150    }
1151
1152    #[test]
1153    fn geocentric_convention_changes_only_horizontal_vertical_split() {
1154        let (los, weights, rx) = sample_geometry();
1155        let geodetic = dop(&los, &weights, rx).expect("geodetic DOP");
1156        let geocentric = dop_with_convention(&los, &weights, rx, EnuConvention::GeocentricRadial)
1157            .expect("geocentric DOP");
1158
1159        // GDOP and TDOP read the unrotated state cofactor, so they are
1160        // bit-identical across conventions.
1161        assert_eq!(geodetic.gdop.to_bits(), geocentric.gdop.to_bits());
1162        assert_eq!(geodetic.tdop.to_bits(), geocentric.tdop.to_bits());
1163
1164        // PDOP is the rotation-invariant position trace: equal to roundoff.
1165        assert!((geodetic.pdop - geocentric.pdop).abs() < 1e-9 * geodetic.pdop);
1166
1167        // The H/V split moves with the ~0.19 deg up-axis deflection: different,
1168        // but on the order of 1e-3 relative.
1169        let hdop_rel = (geodetic.hdop - geocentric.hdop).abs() / geodetic.hdop;
1170        assert!(hdop_rel > 0.0, "convention must change HDOP");
1171        assert!(
1172            hdop_rel < 1e-2,
1173            "HDOP shift {hdop_rel} larger than expected"
1174        );
1175        assert_ne!(geodetic.vdop.to_bits(), geocentric.vdop.to_bits());
1176    }
1177
1178    #[test]
1179    fn geocentric_convention_rejects_zero_radius_receiver() {
1180        // A receiver one equatorial radius below the equator/prime-meridian sits
1181        // at the geocenter (ECEF ~ origin), where geocentric "up" is undefined.
1182        // The geocentric-radial convention must reject it rather than silently
1183        // fall back to a +Z frame.
1184        let geocenter = Wgs84Geodetic::new(0.0, 0.0, -crate::astro::constants::earth::WGS84_A_M)
1185            .expect("valid geodetic receiver");
1186        let (los, weights, _) = sample_geometry();
1187        let err = dop_with_convention(&los, &weights, geocenter, EnuConvention::GeocentricRadial)
1188            .expect_err("zero-radius geocentric up must be rejected");
1189        assert!(matches!(
1190            err,
1191            DopError::InvalidInput {
1192                field: "receiver",
1193                ..
1194            }
1195        ));
1196
1197        // The geodetic-normal convention does not use the radial axis, so the
1198        // same receiver is accepted there.
1199        assert!(
1200            dop_with_convention(&los, &weights, geocenter, EnuConvention::GeodeticNormal).is_ok()
1201        );
1202    }
1203
1204    #[test]
1205    fn default_dop_equals_explicit_geodetic_convention_bit_for_bit() {
1206        let (los, weights, rx) = sample_geometry();
1207        let default = dop(&los, &weights, rx).expect("default");
1208        let explicit =
1209            dop_with_convention(&los, &weights, rx, EnuConvention::GeodeticNormal).expect("geo");
1210        assert_eq!(default.hdop.to_bits(), explicit.hdop.to_bits());
1211        assert_eq!(default.vdop.to_bits(), explicit.vdop.to_bits());
1212        assert_eq!(default.pdop.to_bits(), explicit.pdop.to_bits());
1213    }
1214}
1215
1216#[cfg(all(test, sidereon_repo_tests))]
1217mod tests;