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