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//! # Reproducibility
20//!
21//! The normal matrix `H^T W H` is accumulated by a plain left-to-right sum over
22//! the satellites, and the 4x4 inverse is an explicit cofactor (adjugate /
23//! determinant) expansion with a fixed term order rather than a LAPACK
24//! factorisation. That keeps the whole computation libm/arithmetic-bound and
25//! independently reproducible to the bit (it does not depend on a BLAS backend),
26//! unlike a general dense inverse routed through LAPACK. The ENU rotation uses
27//! `sin`/`cos` and the final scalars use `sqrt`; there is no fused multiply-add.
28//!
29//! # Failure mode
30//!
31//! A geometry with fewer than four independent line-of-sight directions, or one
32//! whose normal matrix is singular or ill-conditioned, has no finite DOP. Such
33//! geometries are reported as [`DopError::Singular`] rather than returning a
34//! NaN-flagged or clamped result. The predicate is deterministic: the
35//! determinant is exactly zero, or one of the variance diagonals that a DOP
36//! scalar takes the square root of is negative or non-finite.
37
38use crate::astro::math::linear::{
39    invert_4x4_cofactor, invert_symmetric_pd, normal_matrix_4_weighted_column_outer, LinearError,
40};
41use crate::astro::math::mat3::{inline_rxr, inline_tr};
42
43use crate::frame::Wgs84Geodetic;
44use crate::id::GnssSystem;
45use crate::validate;
46
47const DEG_TO_RAD: f64 = std::f64::consts::PI / 180.0;
48const LOS_UNIT_TOLERANCE: f64 = 1.0e-3;
49
50/// A line-of-sight unit vector from the receiver toward a satellite, in ECEF.
51///
52/// The corresponding design-matrix row is `[-e_x, -e_y, -e_z, 1]`: the partial
53/// derivative of the predicted range with respect to the receiver position is
54/// `-e`, and the clock column is one (range increases one-for-one with the
55/// receiver clock bias expressed as a length).
56#[derive(Debug, Clone, Copy, PartialEq)]
57pub struct LineOfSight {
58    /// ECEF X component of the unit line-of-sight vector.
59    pub e_x: f64,
60    /// ECEF Y component of the unit line-of-sight vector.
61    pub e_y: f64,
62    /// ECEF Z component of the unit line-of-sight vector.
63    pub e_z: f64,
64}
65
66impl LineOfSight {
67    /// Construct a line-of-sight unit vector from ECEF components.
68    pub const fn new(e_x: f64, e_y: f64, e_z: f64) -> Self {
69        Self { e_x, e_y, e_z }
70    }
71
72    /// The design-matrix row `[-e_x, -e_y, -e_z, 1]` for this direction.
73    fn design_row(self) -> [f64; 4] {
74        [-self.e_x, -self.e_y, -self.e_z, 1.0]
75    }
76}
77
78/// Construct an ECEF line-of-sight unit vector from topocentric azimuth and
79/// elevation in degrees.
80///
81/// Azimuth is clockwise from geodetic north, elevation is positive above the
82/// local horizon, and the receiver latitude/longitude define the local ENU
83/// frame. The returned vector points from receiver to satellite in ECEF.
84pub fn line_of_sight_from_az_el_deg(
85    azimuth_deg: f64,
86    elevation_deg: f64,
87    receiver: Wgs84Geodetic,
88) -> Result<LineOfSight, DopError> {
89    validate_az_el_receiver(azimuth_deg, elevation_deg, receiver)?;
90    let az = azimuth_deg * DEG_TO_RAD;
91    let el = elevation_deg * DEG_TO_RAD;
92    let cos_el = el.cos();
93    let east = cos_el * az.sin();
94    let north = cos_el * az.cos();
95    let up = el.sin();
96
97    let r = ecef_to_enu_rotation(receiver.lat_rad, receiver.lon_rad);
98    let e_x = r[0][0] * east + r[1][0] * north + r[2][0] * up;
99    let e_y = r[0][1] * east + r[1][1] * north + r[2][1] * up;
100    let e_z = r[0][2] * east + r[1][2] * north + r[2][2] * up;
101    let los = LineOfSight::new(e_x, e_y, e_z);
102    validate_los(core::slice::from_ref(&los))?;
103    Ok(los)
104}
105
106/// The dilution-of-precision scalars for a geometry.
107///
108/// Each is dimensionless: the standard deviation of the solution component is
109/// the corresponding DOP times the (range) measurement standard deviation. The
110/// position split is in the local ENU frame at the receiver.
111///
112/// Produced by [`dop`] for a single receiver-clock state and by the positioning
113/// pipeline's multi-clock geometry path for a multi-system state; the field
114/// meanings below cover both.
115#[derive(Debug, Clone, PartialEq)]
116pub struct Dop {
117    /// Geometric DOP: the square root of the trace of the cofactor matrix over
118    /// every state - the three position coordinates and every clock (one for a
119    /// single-system solve, one per constellation for a multi-system solve).
120    pub gdop: f64,
121    /// Position DOP: `sqrt(qE + qN + qU)` over the ENU position block.
122    pub pdop: f64,
123    /// Horizontal DOP: `sqrt(qE + qN)`.
124    pub hdop: f64,
125    /// Vertical DOP: `sqrt(qU)`.
126    pub vdop: f64,
127    /// Time (clock) DOP: the square root of the reference clock's cofactor
128    /// variance (`Q[3][3]`). With several clocks this is the first (reference)
129    /// system's clock; the others enter `gdop` through the trace.
130    pub tdop: f64,
131    /// Per-system time DOP: one entry per receiver-clock column, `(system,
132    /// sqrt(Q[3+i][3+i]))` for the constellation that owns clock column `i`.
133    /// Entry `0` is the reference clock and its value always equals
134    /// [`tdop`](Self::tdop).
135    ///
136    /// The multi-GNSS geometry path (`dop_multi`) is given the system that owns
137    /// each clock column and tags every entry, so this is exactly the tagged
138    /// `Vec<(GnssSystem, f64)>` shape the positioning layer's `system_tdops`
139    /// uses: a consumer can pair a `Dop` with an SPP solution without
140    /// re-tagging. The system-agnostic single-clock [`dop`] has no
141    /// constellation context, so it leaves this empty - read
142    /// [`tdop`](Self::tdop) for its lone clock.
143    pub system_tdops: Vec<(GnssSystem, f64)>,
144}
145
146/// Cofactor matrices from a single-clock GNSS design matrix.
147///
148/// `state` is `(H^T W H)^-1` for rows `[-e_x, -e_y, -e_z, 1]`, ordered
149/// `[x, y, z, clock]`. The two position blocks are the top-left 3x3 block in ECEF
150/// coordinates and the same block rotated into the local ENU frame at the
151/// receiver.
152#[derive(Debug, Clone, PartialEq)]
153pub struct GeometryCofactor {
154    /// Full 4x4 state cofactor matrix, ordered `[x, y, z, clock]`.
155    pub state: [[f64; 4]; 4],
156    /// Position cofactor block in ECEF coordinates.
157    pub position_ecef: [[f64; 3]; 3],
158    /// Position cofactor block in local ENU coordinates.
159    pub position_enu: [[f64; 3]; 3],
160}
161
162/// Position covariance from a GNSS design matrix.
163///
164/// The matrices are in square metres when the supplied variance scale is in
165/// square metres. Use a variance scale of `1.0` to get the raw cofactor block.
166#[derive(Debug, Clone, Copy, PartialEq)]
167pub struct PositionCovariance {
168    /// Position covariance in ECEF coordinates, square metres.
169    pub ecef_m2: [[f64; 3]; 3],
170    /// Position covariance in local ENU coordinates, square metres.
171    pub enu_m2: [[f64; 3]; 3],
172}
173
174/// Horizontal confidence ellipse from an ENU position covariance.
175///
176/// `azimuth_rad` is the semi-major-axis direction measured counter-clockwise from
177/// local east toward local north. `confidence` is the probability used for the
178/// two-degree-of-freedom chi-square scale.
179#[derive(Debug, Clone, Copy, PartialEq)]
180pub struct HorizontalErrorEllipse {
181    /// Requested confidence probability in `(0, 1)`.
182    pub confidence: f64,
183    /// Two-dimensional chi-square scale applied to the covariance eigenvalues.
184    pub chi_square_scale: f64,
185    /// Semi-major axis length, metres.
186    pub semi_major_m: f64,
187    /// Semi-minor axis length, metres.
188    pub semi_minor_m: f64,
189    /// Semi-major-axis azimuth in radians, from east toward north.
190    pub azimuth_rad: f64,
191}
192
193/// Why a geometry has no finite DOP.
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub enum DopError {
196    /// A boundary input was malformed before DOP could be evaluated.
197    InvalidInput {
198        /// Name of the malformed field.
199        field: &'static str,
200        /// Stable validation reason.
201        reason: &'static str,
202    },
203    /// Fewer line-of-sight directions than estimated parameters were supplied
204    /// (four for a single clock, `3 + n_clocks` for several), so the normal
205    /// matrix cannot be full rank.
206    TooFewSatellites,
207    /// The normal matrix `H^T W H` is singular or ill-conditioned: its
208    /// determinant is zero, or a variance diagonal is negative or non-finite.
209    Singular,
210}
211
212impl core::fmt::Display for DopError {
213    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
214        match self {
215            DopError::InvalidInput { field, reason } => {
216                write!(f, "invalid DOP input {field}: {reason}")
217            }
218            DopError::TooFewSatellites => {
219                write!(
220                    f,
221                    "fewer satellites than parameters: geometry is rank-deficient"
222                )
223            }
224            DopError::Singular => {
225                write!(f, "singular or ill-conditioned geometry: no finite DOP")
226            }
227        }
228    }
229}
230
231impl std::error::Error for DopError {}
232
233/// ECEF -> ENU rotation matrix at geodetic latitude/longitude (radians).
234fn ecef_to_enu_rotation(lat_rad: f64, lon_rad: f64) -> [[f64; 3]; 3] {
235    let sphi = lat_rad.sin();
236    let cphi = lat_rad.cos();
237    let slam = lon_rad.sin();
238    let clam = lon_rad.cos();
239    [
240        [-slam, clam, 0.0],
241        [-sphi * clam, -sphi * slam, cphi],
242        [cphi * clam, cphi * slam, sphi],
243    ]
244}
245
246/// Rotate the 3x3 position cofactor block: `Q_enu = R Q_xyz R^T`, formed as
247/// `(R Q) R^T`. Reads the top-left 3x3 block of the 4x4 cofactor matrix and
248/// defers to [`rotate3`].
249fn rotate_pos_block(q: &[[f64; 4]; 4], r: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
250    let qpos = [
251        [q[0][0], q[0][1], q[0][2]],
252        [q[1][0], q[1][1], q[1][2]],
253        [q[2][0], q[2][1], q[2][2]],
254    ];
255    rotate3(&qpos, r)
256}
257
258/// Compute the DOP scalars from line-of-sight directions, diagonal weights, and
259/// the receiver geodetic position.
260///
261/// `los` and `weights` must have the same length, which must be at least four;
262/// `weights` are the non-negative diagonal of `W`. Returns
263/// [`DopError::TooFewSatellites`] for fewer than four directions and
264/// [`DopError::Singular`] for a singular or
265/// ill-conditioned geometry (see the module docs for the exact predicate).
266pub fn dop(los: &[LineOfSight], weights: &[f64], receiver: Wgs84Geodetic) -> Result<Dop, DopError> {
267    validate_dop_inputs(los, weights, receiver)?;
268    if los.len() < 4 {
269        return Err(DopError::TooFewSatellites);
270    }
271
272    let rows: Vec<[f64; 4]> = los.iter().map(|l| l.design_row()).collect();
273    let a = normal_matrix_4_weighted_column_outer(&rows, weights).map_err(map_linear_error)?;
274    let q = invert_4x4_cofactor(&a).ok_or(DopError::Singular)?;
275
276    let r = ecef_to_enu_rotation(receiver.lat_rad, receiver.lon_rad);
277    let enu = rotate_pos_block(&q, &r);
278
279    let qe = enu[0][0];
280    let qn = enu[1][1];
281    let qu = enu[2][2];
282    let qt = q[3][3];
283
284    // The DOP scalars take the square root of cofactor variances. A
285    // well-posed geometry yields a positive-definite Q with strictly positive
286    // variance diagonals; a rank-deficient or ill-conditioned geometry can
287    // leave a tiny nonzero determinant (so `inv4` succeeds) yet produce a
288    // negative or non-finite variance. Reject that here rather than returning a
289    // NaN-flagged DOP. The same deterministic predicate is applied by the
290    // reference recipe so both agree on the success/failure boundary.
291    let gdop_arg = q[0][0] + q[1][1] + q[2][2] + q[3][3];
292    let pdop_arg = qe + qn + qu;
293    let hdop_arg = qe + qn;
294    let vdop_arg = qu;
295    let tdop_arg = qt;
296    for arg in [gdop_arg, pdop_arg, hdop_arg, vdop_arg, tdop_arg] {
297        // `!(arg >= 0.0)` (not `arg < 0.0`) so a NaN variance is also rejected.
298        #[allow(clippy::neg_cmp_op_on_partial_ord)]
299        let nonpositive_or_nan = !(arg >= 0.0);
300        if nonpositive_or_nan || !arg.is_finite() {
301            return Err(DopError::Singular);
302        }
303    }
304
305    Ok(Dop {
306        gdop: gdop_arg.sqrt(),
307        pdop: pdop_arg.sqrt(),
308        hdop: hdop_arg.sqrt(),
309        vdop: vdop_arg.sqrt(),
310        tdop: tdop_arg.sqrt(),
311        // A single shared clock has exactly one clock column, but this entry
312        // point carries no constellation identity, so the per-system vector is
313        // empty (read `tdop` for the lone clock). The multi-system `dop_multi`
314        // path, which does know each column's system, returns it tagged.
315        system_tdops: Vec::new(),
316    })
317}
318
319/// Compute the single-clock geometry cofactor matrix from line-of-sight rows.
320///
321/// This exposes the same cofactor matrix that [`dop`] uses internally:
322/// `Q = (H^T W H)^-1`, with `H` rows `[-e_x, -e_y, -e_z, 1]`. The inverse is the
323/// deterministic 4x4 cofactor expansion used by the 0-ULP DOP parity tests.
324pub fn geometry_cofactor(
325    los: &[LineOfSight],
326    weights: &[f64],
327    receiver: Wgs84Geodetic,
328) -> Result<GeometryCofactor, DopError> {
329    validate_dop_inputs(los, weights, receiver)?;
330    if los.len() < 4 {
331        return Err(DopError::TooFewSatellites);
332    }
333
334    let rows: Vec<[f64; 4]> = los.iter().map(|l| l.design_row()).collect();
335    let a = normal_matrix_4_weighted_column_outer(&rows, weights).map_err(map_linear_error)?;
336    let q = invert_4x4_cofactor(&a).ok_or(DopError::Singular)?;
337    validate_cofactor_variances(&q)?;
338
339    let r = ecef_to_enu_rotation(receiver.lat_rad, receiver.lon_rad);
340    let enu = rotate_pos_block(&q, &r);
341    validate_matrix3(&enu, "position_enu")?;
342    Ok(GeometryCofactor {
343        state: q,
344        position_ecef: position_block(&q),
345        position_enu: enu,
346    })
347}
348
349/// Position covariance from a single-clock GNSS design matrix.
350///
351/// `range_variance_scale_m2` multiplies the raw cofactor. For unit weights with a
352/// common pseudorange standard deviation, pass `sigma_m * sigma_m`. If `weights`
353/// already carry inverse variances, pass `1.0`.
354pub fn position_covariance_from_geometry_m2(
355    los: &[LineOfSight],
356    weights: &[f64],
357    receiver: Wgs84Geodetic,
358    range_variance_scale_m2: f64,
359) -> Result<PositionCovariance, DopError> {
360    validate_variance_scale(range_variance_scale_m2)?;
361    let cofactor = geometry_cofactor(los, weights, receiver)?;
362    Ok(PositionCovariance {
363        ecef_m2: scale_matrix3(cofactor.position_ecef, range_variance_scale_m2),
364        enu_m2: scale_matrix3(cofactor.position_enu, range_variance_scale_m2),
365    })
366}
367
368/// Horizontal confidence ellipse from a local ENU covariance matrix.
369///
370/// The 2D horizontal covariance is the east/north block of `covariance_enu_m2`.
371/// The scale is the two-degree-of-freedom chi-square quantile
372/// `-2 ln(1 - confidence)`.
373pub fn horizontal_error_ellipse(
374    covariance_enu_m2: [[f64; 3]; 3],
375    confidence: f64,
376) -> Result<HorizontalErrorEllipse, DopError> {
377    validate_matrix3(&covariance_enu_m2, "covariance_enu_m2")?;
378    validate_confidence(confidence)?;
379
380    let a = covariance_enu_m2[0][0];
381    let b = 0.5 * (covariance_enu_m2[0][1] + covariance_enu_m2[1][0]);
382    let c = covariance_enu_m2[1][1];
383    let half_delta = 0.5 * (a - c);
384    let center = 0.5 * (a + c);
385    let root = (half_delta * half_delta + b * b).sqrt();
386    let lambda_major = center + root;
387    let lambda_minor = center - root;
388    if !lambda_major.is_finite() || !lambda_minor.is_finite() || lambda_minor < -1.0e-12 {
389        return Err(invalid_input(
390            "covariance_enu_m2",
391            "not positive semidefinite",
392        ));
393    }
394
395    let chi_square_scale = -2.0 * (1.0 - confidence).ln();
396    let semi_major_m = (lambda_major.max(0.0) * chi_square_scale).sqrt();
397    let semi_minor_m = (lambda_minor.max(0.0) * chi_square_scale).sqrt();
398    let azimuth_rad = if root == 0.0 {
399        0.0
400    } else {
401        0.5 * (2.0 * b).atan2(a - c)
402    };
403    Ok(HorizontalErrorEllipse {
404        confidence,
405        chi_square_scale,
406        semi_major_m,
407        semi_minor_m,
408        azimuth_rad,
409    })
410}
411
412/// Horizontal confidence ellipse directly from line-of-sight rows and weights.
413pub fn error_ellipse_from_geometry(
414    los: &[LineOfSight],
415    weights: &[f64],
416    receiver: Wgs84Geodetic,
417    range_variance_scale_m2: f64,
418    confidence: f64,
419) -> Result<HorizontalErrorEllipse, DopError> {
420    let covariance =
421        position_covariance_from_geometry_m2(los, weights, receiver, range_variance_scale_m2)?;
422    horizontal_error_ellipse(covariance.enu_m2, confidence)
423}
424
425fn validate_dop_inputs(
426    los: &[LineOfSight],
427    weights: &[f64],
428    receiver: Wgs84Geodetic,
429) -> Result<(), DopError> {
430    if los.len() != weights.len() {
431        return Err(invalid_input("weights", "length must match los"));
432    }
433    validate_los(los)?;
434    validate_weights(weights)?;
435    validate_receiver(receiver)
436}
437
438fn validate_los(los: &[LineOfSight]) -> Result<(), DopError> {
439    for line in los {
440        if !(line.e_x.is_finite() && line.e_y.is_finite() && line.e_z.is_finite()) {
441            return Err(invalid_input("los", "not finite"));
442        }
443        let norm = (line.e_x * line.e_x + line.e_y * line.e_y + line.e_z * line.e_z).sqrt();
444        if !norm.is_finite() {
445            return Err(invalid_input("los", "not finite"));
446        }
447        if (norm - 1.0).abs() > LOS_UNIT_TOLERANCE {
448            return Err(invalid_input("los", "not unit length"));
449        }
450    }
451    Ok(())
452}
453
454fn validate_cofactor_variances(q: &[[f64; 4]; 4]) -> Result<(), DopError> {
455    for row in q {
456        validate::finite_slice(row, "cofactor").map_err(map_validation_error)?;
457    }
458    for (idx, row) in q.iter().enumerate() {
459        let variance = row[idx];
460        #[allow(clippy::neg_cmp_op_on_partial_ord)]
461        let negative_or_nan = !(variance >= 0.0);
462        if negative_or_nan || !variance.is_finite() {
463            return Err(DopError::Singular);
464        }
465    }
466    Ok(())
467}
468
469fn validate_variance_scale(value: f64) -> Result<(), DopError> {
470    if !value.is_finite() {
471        return Err(invalid_input("range_variance_scale_m2", "not finite"));
472    }
473    if value < 0.0 {
474        return Err(invalid_input("range_variance_scale_m2", "negative"));
475    }
476    Ok(())
477}
478
479fn validate_confidence(value: f64) -> Result<(), DopError> {
480    if !value.is_finite() {
481        return Err(invalid_input("confidence", "not finite"));
482    }
483    if !(0.0..1.0).contains(&value) {
484        return Err(invalid_input("confidence", "out of range"));
485    }
486    Ok(())
487}
488
489fn validate_matrix3(matrix: &[[f64; 3]; 3], field: &'static str) -> Result<(), DopError> {
490    for row in matrix {
491        validate::finite_slice(row, field).map_err(map_validation_error)?;
492    }
493    Ok(())
494}
495
496fn position_block(q: &[[f64; 4]; 4]) -> [[f64; 3]; 3] {
497    [
498        [q[0][0], q[0][1], q[0][2]],
499        [q[1][0], q[1][1], q[1][2]],
500        [q[2][0], q[2][1], q[2][2]],
501    ]
502}
503
504fn scale_matrix3(matrix: [[f64; 3]; 3], scale: f64) -> [[f64; 3]; 3] {
505    [
506        [
507            matrix[0][0] * scale,
508            matrix[0][1] * scale,
509            matrix[0][2] * scale,
510        ],
511        [
512            matrix[1][0] * scale,
513            matrix[1][1] * scale,
514            matrix[1][2] * scale,
515        ],
516        [
517            matrix[2][0] * scale,
518            matrix[2][1] * scale,
519            matrix[2][2] * scale,
520        ],
521    ]
522}
523
524fn validate_weights(weights: &[f64]) -> Result<(), DopError> {
525    if weights.iter().any(|weight| !weight.is_finite()) {
526        return Err(invalid_input("weights", "not finite"));
527    }
528    if weights.iter().any(|&weight| weight < 0.0) {
529        return Err(invalid_input("weights", "negative"));
530    }
531    Ok(())
532}
533
534fn validate_receiver(receiver: Wgs84Geodetic) -> Result<(), DopError> {
535    if !(receiver.lat_rad.is_finite()
536        && receiver.lon_rad.is_finite()
537        && receiver.height_m.is_finite())
538    {
539        return Err(invalid_input("receiver", "not finite"));
540    }
541    if !(-core::f64::consts::FRAC_PI_2..=core::f64::consts::FRAC_PI_2).contains(&receiver.lat_rad) {
542        return Err(invalid_input("receiver.lat_rad", "out of range"));
543    }
544    if !(-core::f64::consts::PI..=core::f64::consts::PI).contains(&receiver.lon_rad) {
545        return Err(invalid_input("receiver.lon_rad", "out of range"));
546    }
547    Ok(())
548}
549
550fn validate_az_el_receiver(
551    azimuth_deg: f64,
552    elevation_deg: f64,
553    receiver: Wgs84Geodetic,
554) -> Result<(), DopError> {
555    if !azimuth_deg.is_finite() {
556        return Err(invalid_input("azimuth_deg", "not finite"));
557    }
558    if !elevation_deg.is_finite() {
559        return Err(invalid_input("elevation_deg", "not finite"));
560    }
561    if !(-90.0..=90.0).contains(&elevation_deg) {
562        return Err(invalid_input("elevation_deg", "out of range"));
563    }
564    validate_receiver(receiver)
565}
566
567fn invalid_input(field: &'static str, reason: &'static str) -> DopError {
568    DopError::InvalidInput { field, reason }
569}
570
571fn map_linear_error(error: LinearError) -> DopError {
572    match error {
573        LinearError::InvalidInput { field, reason } => invalid_input(field, reason),
574    }
575}
576
577fn map_validation_error(error: validate::FieldError) -> DopError {
578    invalid_input(error.field(), error.reason())
579}
580
581// --- multi-system DOP (general (3 + n_systems) x (3 + n_systems)) -----------
582
583/// `R Q R^T` for a 3x3 position cofactor block, formed as `(R Q) R^T`. Both
584/// products use [`inline_rxr`]'s fixed left-to-right inner-sum order, so the
585/// result is bit-identical to the explicit double loop this replaced.
586fn rotate3(q: &[[f64; 3]; 3], r: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
587    inline_rxr(&inline_rxr(r, q), &inline_tr(r))
588}
589
590/// Multi-system dilution of precision: like [`dop`] but with one receiver-clock
591/// column per GNSS rather than a single shared clock.
592///
593/// `clock_index[i]` is the clock column (`0..n_clocks`) for satellite `i` - its
594/// system's index in the solve's clock ordering. `systems[c]` is the GNSS that
595/// owns clock column `c`, so `systems.len() == n_clocks` and the column ordering
596/// matches the caller's clock ordering. The design row is therefore
597/// `[-e_x, -e_y, -e_z, <one-hot over the n_clocks clock columns>]` and the
598/// cofactor matrix is `(3 + n_clocks) x (3 + n_clocks)`. `PDOP`/`HDOP`/`VDOP` are
599/// the position block (ENU-rotated, unambiguous); `GDOP` is the square root of
600/// the full trace (all clocks); `TDOP` is the reference-system clock
601/// (`Q[3][3]`). The per-system TDOPs (`sqrt(Q[3+c][3+c])` for every clock,
602/// tagged with `systems[c]`) are returned in [`Dop::system_tdops`], in
603/// clock-column order. Returns [`DopError::Singular`] for a rank-deficient
604/// geometry.
605///
606/// This path uses a general symmetric inverse (see [`invert_symmetric_pd`]) and
607/// is a deterministic geometry diagnostic, not a 0-ULP parity target; the
608/// single-system [`dop`] retains the 0-ULP cofactor inverse.
609///
610/// Crate-internal: every `clock_index` must be in `0..n_clocks` (the solver
611/// constructs them from the used satellites' system ordering, so this always
612/// holds). It is not part of the public API because the index convention is
613/// meaningless without the solver's clock ordering.
614pub(crate) fn dop_multi(
615    los: &[LineOfSight],
616    clock_index: &[usize],
617    systems: &[GnssSystem],
618    n_clocks: usize,
619    weights: &[f64],
620    receiver: Wgs84Geodetic,
621) -> Result<Dop, DopError> {
622    validate_dop_multi_inputs(los, clock_index, systems, n_clocks, weights, receiver)?;
623    let p = 3 + n_clocks;
624    if los.len() < p {
625        return Err(DopError::TooFewSatellites);
626    }
627
628    let mut a = vec![vec![0.0_f64; p]; p];
629    for k in 0..los.len() {
630        let mut row = vec![0.0_f64; p];
631        row[0] = -los[k].e_x;
632        row[1] = -los[k].e_y;
633        row[2] = -los[k].e_z;
634        row[3 + clock_index[k]] = 1.0;
635        let w = weights[k];
636        #[allow(clippy::needless_range_loop)]
637        for i in 0..p {
638            for j in 0..p {
639                a[i][j] += row[i] * w * row[j];
640            }
641        }
642    }
643    let q = invert_symmetric_pd(&a).ok_or(DopError::Singular)?;
644
645    let r = ecef_to_enu_rotation(receiver.lat_rad, receiver.lon_rad);
646    let qpos = [
647        [q[0][0], q[0][1], q[0][2]],
648        [q[1][0], q[1][1], q[1][2]],
649        [q[2][0], q[2][1], q[2][2]],
650    ];
651    let enu = rotate3(&qpos, &r);
652
653    let qe = enu[0][0];
654    let qn = enu[1][1];
655    let qu = enu[2][2];
656    let qt = q[3][3];
657    let trace: f64 = (0..p).map(|i| q[i][i]).sum();
658    // Per-clock-column variances `Q[3+i][3+i]`, in clock-column order. Column 0
659    // is the reference clock, so its variance is exactly `qt`.
660    let system_tdop_args: Vec<f64> = (0..n_clocks).map(|i| q[3 + i][3 + i]).collect();
661
662    let gdop_arg = trace;
663    let pdop_arg = qe + qn + qu;
664    let hdop_arg = qe + qn;
665    let vdop_arg = qu;
666    let tdop_arg = qt;
667    // Every variance a DOP scalar takes the square root of - including each
668    // per-system clock variance - must be finite and non-negative; a
669    // rank-deficient geometry can leave a negative or non-finite diagonal even
670    // when the full trace stays positive, so reject those here.
671    for &arg in [gdop_arg, pdop_arg, hdop_arg, vdop_arg, tdop_arg]
672        .iter()
673        .chain(system_tdop_args.iter())
674    {
675        #[allow(clippy::neg_cmp_op_on_partial_ord)]
676        let nonpositive_or_nan = !(arg >= 0.0);
677        if nonpositive_or_nan || !arg.is_finite() {
678            return Err(DopError::Singular);
679        }
680    }
681
682    Ok(Dop {
683        gdop: gdop_arg.sqrt(),
684        pdop: pdop_arg.sqrt(),
685        hdop: hdop_arg.sqrt(),
686        vdop: vdop_arg.sqrt(),
687        tdop: tdop_arg.sqrt(),
688        system_tdops: system_tdop_args
689            .iter()
690            .enumerate()
691            .map(|(i, &v)| (systems[i], v.sqrt()))
692            .collect(),
693    })
694}
695
696fn validate_dop_multi_inputs(
697    los: &[LineOfSight],
698    clock_index: &[usize],
699    systems: &[GnssSystem],
700    n_clocks: usize,
701    weights: &[f64],
702    receiver: Wgs84Geodetic,
703) -> Result<(), DopError> {
704    if los.len() != weights.len() {
705        return Err(invalid_input("weights", "length must match los"));
706    }
707    if los.len() != clock_index.len() {
708        return Err(invalid_input("clock_index", "length must match los"));
709    }
710    if n_clocks == 0 {
711        return Err(invalid_input("n_clocks", "not positive"));
712    }
713    if systems.len() != n_clocks {
714        return Err(invalid_input("systems", "length must match n_clocks"));
715    }
716    if clock_index.iter().any(|&idx| idx >= n_clocks) {
717        return Err(invalid_input("clock_index", "out of range"));
718    }
719    validate_los(los)?;
720    validate_weights(weights)?;
721    validate_receiver(receiver)
722}
723
724#[cfg(all(test, sidereon_repo_tests))]
725pub(crate) mod test_support {
726    //! Internal accessors so the parity test can assert 0 ULP on the
727    //! intermediates (normal matrix, cofactor matrix, ENU block) as well as the
728    //! final scalars, without making them part of the public API.
729    use super::*;
730
731    pub(crate) fn normal_matrix_for(los: &[LineOfSight], weights: &[f64]) -> [[f64; 4]; 4] {
732        let rows: Vec<[f64; 4]> = los.iter().map(|l| l.design_row()).collect();
733        normal_matrix_4_weighted_column_outer(&rows, weights).expect("valid DOP test inputs")
734    }
735
736    pub(crate) fn det4_for(a: &[[f64; 4]; 4]) -> f64 {
737        crate::astro::math::linear::det4_cofactor(a)
738    }
739
740    pub(crate) fn inv4_for(a: &[[f64; 4]; 4]) -> Option<[[f64; 4]; 4]> {
741        invert_4x4_cofactor(a)
742    }
743
744    pub(crate) fn enu_block_for(q: &[[f64; 4]; 4], lat_rad: f64, lon_rad: f64) -> [[f64; 3]; 3] {
745        let r = ecef_to_enu_rotation(lat_rad, lon_rad);
746        rotate_pos_block(q, &r)
747    }
748}
749
750#[cfg(test)]
751mod public_api_tests {
752    use super::*;
753
754    fn receiver() -> Wgs84Geodetic {
755        Wgs84Geodetic::new(45.0_f64.to_radians(), -75.0_f64.to_radians(), 100.0)
756            .expect("valid receiver")
757    }
758
759    fn sample_geometry() -> (Vec<LineOfSight>, Vec<f64>, Wgs84Geodetic) {
760        let rx = receiver();
761        let az_el = [
762            (5.0, 25.0),
763            (80.0, 35.0),
764            (155.0, 55.0),
765            (235.0, 40.0),
766            (310.0, 65.0),
767        ];
768        let los = az_el
769            .into_iter()
770            .map(|(az, el)| line_of_sight_from_az_el_deg(az, el, rx).expect("valid LOS"))
771            .collect::<Vec<_>>();
772        let weights = vec![1.0, 0.8, 1.4, 0.9, 1.1];
773        (los, weights, rx)
774    }
775
776    #[test]
777    fn geometry_cofactor_exposes_the_dop_position_block() {
778        let (los, weights, rx) = sample_geometry();
779        let d = dop(&los, &weights, rx).expect("DOP");
780        let q = geometry_cofactor(&los, &weights, rx).expect("cofactor");
781
782        let qe = q.position_enu[0][0];
783        let qn = q.position_enu[1][1];
784        let qu = q.position_enu[2][2];
785        assert_eq!(d.pdop.to_bits(), (qe + qn + qu).sqrt().to_bits());
786        assert_eq!(d.hdop.to_bits(), (qe + qn).sqrt().to_bits());
787        assert_eq!(d.vdop.to_bits(), qu.sqrt().to_bits());
788        assert_eq!(d.tdop.to_bits(), q.state[3][3].sqrt().to_bits());
789    }
790
791    #[test]
792    fn position_covariance_scales_the_raw_cofactor() {
793        let (los, weights, rx) = sample_geometry();
794        let q = geometry_cofactor(&los, &weights, rx).expect("cofactor");
795        let cov =
796            position_covariance_from_geometry_m2(&los, &weights, rx, 4.0).expect("covariance");
797        for i in 0..3 {
798            for j in 0..3 {
799                assert_eq!(
800                    cov.ecef_m2[i][j].to_bits(),
801                    (q.position_ecef[i][j] * 4.0).to_bits()
802                );
803                assert_eq!(
804                    cov.enu_m2[i][j].to_bits(),
805                    (q.position_enu[i][j] * 4.0).to_bits()
806                );
807            }
808        }
809    }
810
811    #[test]
812    fn horizontal_error_ellipse_uses_chi_square_two_dof_scale() {
813        let covariance = [[9.0, 2.0, 0.0], [2.0, 4.0, 0.0], [0.0, 0.0, 16.0]];
814        let ellipse = horizontal_error_ellipse(covariance, 0.95).expect("ellipse");
815        let expected_scale = -2.0 * (1.0_f64 - 0.95).ln();
816        assert_eq!(ellipse.chi_square_scale.to_bits(), expected_scale.to_bits());
817        assert!(ellipse.semi_major_m >= ellipse.semi_minor_m);
818        assert!(ellipse.semi_minor_m > 0.0);
819        assert!(ellipse.azimuth_rad.is_finite());
820    }
821}
822
823#[cfg(all(test, sidereon_repo_tests))]
824mod tests;