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};
41
42use crate::frame::Wgs84Geodetic;
43
44const DEG_TO_RAD: f64 = std::f64::consts::PI / 180.0;
45const LOS_UNIT_TOLERANCE: f64 = 1.0e-3;
46
47/// A line-of-sight unit vector from the receiver toward a satellite, in ECEF.
48///
49/// The corresponding design-matrix row is `[-e_x, -e_y, -e_z, 1]`: the partial
50/// derivative of the predicted range with respect to the receiver position is
51/// `-e`, and the clock column is one (range increases one-for-one with the
52/// receiver clock bias expressed as a length).
53#[derive(Debug, Clone, Copy, PartialEq)]
54pub struct LineOfSight {
55    /// ECEF X component of the unit line-of-sight vector.
56    pub e_x: f64,
57    /// ECEF Y component of the unit line-of-sight vector.
58    pub e_y: f64,
59    /// ECEF Z component of the unit line-of-sight vector.
60    pub e_z: f64,
61}
62
63impl LineOfSight {
64    /// Construct a line-of-sight unit vector from ECEF components.
65    pub const fn new(e_x: f64, e_y: f64, e_z: f64) -> Self {
66        Self { e_x, e_y, e_z }
67    }
68
69    /// The design-matrix row `[-e_x, -e_y, -e_z, 1]` for this direction.
70    fn design_row(self) -> [f64; 4] {
71        [-self.e_x, -self.e_y, -self.e_z, 1.0]
72    }
73}
74
75/// Construct an ECEF line-of-sight unit vector from topocentric azimuth and
76/// elevation in degrees.
77///
78/// Azimuth is clockwise from geodetic north, elevation is positive above the
79/// local horizon, and the receiver latitude/longitude define the local ENU
80/// frame. The returned vector points from receiver to satellite in ECEF.
81pub fn line_of_sight_from_az_el_deg(
82    azimuth_deg: f64,
83    elevation_deg: f64,
84    receiver: Wgs84Geodetic,
85) -> Result<LineOfSight, DopError> {
86    validate_az_el_receiver(azimuth_deg, elevation_deg, receiver)?;
87    let az = azimuth_deg * DEG_TO_RAD;
88    let el = elevation_deg * DEG_TO_RAD;
89    let cos_el = el.cos();
90    let east = cos_el * az.sin();
91    let north = cos_el * az.cos();
92    let up = el.sin();
93
94    let r = ecef_to_enu_rotation(receiver.lat_rad, receiver.lon_rad);
95    let e_x = r[0][0] * east + r[1][0] * north + r[2][0] * up;
96    let e_y = r[0][1] * east + r[1][1] * north + r[2][1] * up;
97    let e_z = r[0][2] * east + r[1][2] * north + r[2][2] * up;
98    let los = LineOfSight::new(e_x, e_y, e_z);
99    validate_los(core::slice::from_ref(&los))?;
100    Ok(los)
101}
102
103/// The dilution-of-precision scalars for a geometry.
104///
105/// Each is dimensionless: the standard deviation of the solution component is
106/// the corresponding DOP times the (range) measurement standard deviation. The
107/// position split is in the local ENU frame at the receiver.
108///
109/// Produced by [`dop`] for a single receiver-clock state and by the positioning
110/// pipeline's multi-clock geometry path for a multi-system state; the field
111/// meanings below cover both.
112#[derive(Debug, Clone, Copy, PartialEq)]
113pub struct Dop {
114    /// Geometric DOP: the square root of the trace of the cofactor matrix over
115    /// every state - the three position coordinates and every clock (one for a
116    /// single-system solve, one per constellation for a multi-system solve).
117    pub gdop: f64,
118    /// Position DOP: `sqrt(qE + qN + qU)` over the ENU position block.
119    pub pdop: f64,
120    /// Horizontal DOP: `sqrt(qE + qN)`.
121    pub hdop: f64,
122    /// Vertical DOP: `sqrt(qU)`.
123    pub vdop: f64,
124    /// Time (clock) DOP: the square root of the reference clock's cofactor
125    /// variance (`Q[3][3]`). With several clocks this is the first (reference)
126    /// system's clock; the others enter `gdop` through the trace.
127    pub tdop: f64,
128}
129
130/// Why a geometry has no finite DOP.
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum DopError {
133    /// A boundary input was malformed before DOP could be evaluated.
134    InvalidInput {
135        /// Name of the malformed field.
136        field: &'static str,
137        /// Stable validation reason.
138        reason: &'static str,
139    },
140    /// Fewer line-of-sight directions than estimated parameters were supplied
141    /// (four for a single clock, `3 + n_clocks` for several), so the normal
142    /// matrix cannot be full rank.
143    TooFewSatellites,
144    /// The normal matrix `H^T W H` is singular or ill-conditioned: its
145    /// determinant is zero, or a variance diagonal is negative or non-finite.
146    Singular,
147}
148
149impl core::fmt::Display for DopError {
150    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
151        match self {
152            DopError::InvalidInput { field, reason } => {
153                write!(f, "invalid DOP input {field}: {reason}")
154            }
155            DopError::TooFewSatellites => {
156                write!(
157                    f,
158                    "fewer satellites than parameters: geometry is rank-deficient"
159                )
160            }
161            DopError::Singular => {
162                write!(f, "singular or ill-conditioned geometry: no finite DOP")
163            }
164        }
165    }
166}
167
168impl std::error::Error for DopError {}
169
170/// ECEF -> ENU rotation matrix at geodetic latitude/longitude (radians).
171fn ecef_to_enu_rotation(lat_rad: f64, lon_rad: f64) -> [[f64; 3]; 3] {
172    let sphi = lat_rad.sin();
173    let cphi = lat_rad.cos();
174    let slam = lon_rad.sin();
175    let clam = lon_rad.cos();
176    [
177        [-slam, clam, 0.0],
178        [-sphi * clam, -sphi * slam, cphi],
179        [cphi * clam, cphi * slam, sphi],
180    ]
181}
182
183/// Rotate the 3x3 position cofactor block: `Q_enu = R Q_xyz R^T`, formed as
184/// `(R Q) R^T` with plain left-to-right inner sums in a fixed order.
185#[allow(clippy::needless_range_loop)] // explicit indices keep the R Q R^T product order pinned
186fn rotate_pos_block(q: &[[f64; 4]; 4], r: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
187    let mut rq = [[0.0_f64; 3]; 3];
188    for i in 0..3 {
189        for j in 0..3 {
190            let mut s = 0.0_f64;
191            for k in 0..3 {
192                s += r[i][k] * q[k][j];
193            }
194            rq[i][j] = s;
195        }
196    }
197    let mut enu = [[0.0_f64; 3]; 3];
198    for i in 0..3 {
199        for j in 0..3 {
200            let mut s = 0.0_f64;
201            for k in 0..3 {
202                s += rq[i][k] * r[j][k];
203            }
204            enu[i][j] = s;
205        }
206    }
207    enu
208}
209
210/// Compute the DOP scalars from line-of-sight directions, diagonal weights, and
211/// the receiver geodetic position.
212///
213/// `los` and `weights` must have the same length, which must be at least four;
214/// `weights` are the non-negative diagonal of `W`. Returns
215/// [`DopError::TooFewSatellites`] for fewer than four directions and
216/// [`DopError::Singular`] for a singular or
217/// ill-conditioned geometry (see the module docs for the exact predicate).
218pub fn dop(los: &[LineOfSight], weights: &[f64], receiver: Wgs84Geodetic) -> Result<Dop, DopError> {
219    validate_dop_inputs(los, weights, receiver)?;
220    if los.len() < 4 {
221        return Err(DopError::TooFewSatellites);
222    }
223
224    let rows: Vec<[f64; 4]> = los.iter().map(|l| l.design_row()).collect();
225    let a = normal_matrix_4_weighted_column_outer(&rows, weights).map_err(map_linear_error)?;
226    let q = invert_4x4_cofactor(&a).ok_or(DopError::Singular)?;
227
228    let r = ecef_to_enu_rotation(receiver.lat_rad, receiver.lon_rad);
229    let enu = rotate_pos_block(&q, &r);
230
231    let qe = enu[0][0];
232    let qn = enu[1][1];
233    let qu = enu[2][2];
234    let qt = q[3][3];
235
236    // The DOP scalars take the square root of cofactor variances. A
237    // well-posed geometry yields a positive-definite Q with strictly positive
238    // variance diagonals; a rank-deficient or ill-conditioned geometry can
239    // leave a tiny nonzero determinant (so `inv4` succeeds) yet produce a
240    // negative or non-finite variance. Reject that here rather than returning a
241    // NaN-flagged DOP. The same deterministic predicate is applied by the
242    // reference recipe so both agree on the success/failure boundary.
243    let gdop_arg = q[0][0] + q[1][1] + q[2][2] + q[3][3];
244    let pdop_arg = qe + qn + qu;
245    let hdop_arg = qe + qn;
246    let vdop_arg = qu;
247    let tdop_arg = qt;
248    for arg in [gdop_arg, pdop_arg, hdop_arg, vdop_arg, tdop_arg] {
249        // `!(arg >= 0.0)` (not `arg < 0.0`) so a NaN variance is also rejected.
250        #[allow(clippy::neg_cmp_op_on_partial_ord)]
251        let nonpositive_or_nan = !(arg >= 0.0);
252        if nonpositive_or_nan || !arg.is_finite() {
253            return Err(DopError::Singular);
254        }
255    }
256
257    Ok(Dop {
258        gdop: gdop_arg.sqrt(),
259        pdop: pdop_arg.sqrt(),
260        hdop: hdop_arg.sqrt(),
261        vdop: vdop_arg.sqrt(),
262        tdop: tdop_arg.sqrt(),
263    })
264}
265
266fn validate_dop_inputs(
267    los: &[LineOfSight],
268    weights: &[f64],
269    receiver: Wgs84Geodetic,
270) -> Result<(), DopError> {
271    if los.len() != weights.len() {
272        return Err(invalid_input("weights", "length must match los"));
273    }
274    validate_los(los)?;
275    validate_weights(weights)?;
276    validate_receiver(receiver)
277}
278
279fn validate_los(los: &[LineOfSight]) -> Result<(), DopError> {
280    for line in los {
281        if !(line.e_x.is_finite() && line.e_y.is_finite() && line.e_z.is_finite()) {
282            return Err(invalid_input("los", "not finite"));
283        }
284        let norm = (line.e_x * line.e_x + line.e_y * line.e_y + line.e_z * line.e_z).sqrt();
285        if !norm.is_finite() {
286            return Err(invalid_input("los", "not finite"));
287        }
288        if (norm - 1.0).abs() > LOS_UNIT_TOLERANCE {
289            return Err(invalid_input("los", "not unit length"));
290        }
291    }
292    Ok(())
293}
294
295fn validate_weights(weights: &[f64]) -> Result<(), DopError> {
296    if weights.iter().any(|weight| !weight.is_finite()) {
297        return Err(invalid_input("weights", "not finite"));
298    }
299    if weights.iter().any(|&weight| weight < 0.0) {
300        return Err(invalid_input("weights", "negative"));
301    }
302    Ok(())
303}
304
305fn validate_receiver(receiver: Wgs84Geodetic) -> Result<(), DopError> {
306    if !(receiver.lat_rad.is_finite()
307        && receiver.lon_rad.is_finite()
308        && receiver.height_m.is_finite())
309    {
310        return Err(invalid_input("receiver", "not finite"));
311    }
312    if !(-core::f64::consts::FRAC_PI_2..=core::f64::consts::FRAC_PI_2).contains(&receiver.lat_rad) {
313        return Err(invalid_input("receiver.lat_rad", "out of range"));
314    }
315    if !(-core::f64::consts::PI..=core::f64::consts::PI).contains(&receiver.lon_rad) {
316        return Err(invalid_input("receiver.lon_rad", "out of range"));
317    }
318    Ok(())
319}
320
321fn validate_az_el_receiver(
322    azimuth_deg: f64,
323    elevation_deg: f64,
324    receiver: Wgs84Geodetic,
325) -> Result<(), DopError> {
326    if !azimuth_deg.is_finite() {
327        return Err(invalid_input("azimuth_deg", "not finite"));
328    }
329    if !elevation_deg.is_finite() {
330        return Err(invalid_input("elevation_deg", "not finite"));
331    }
332    if !(-90.0..=90.0).contains(&elevation_deg) {
333        return Err(invalid_input("elevation_deg", "out of range"));
334    }
335    validate_receiver(receiver)
336}
337
338fn invalid_input(field: &'static str, reason: &'static str) -> DopError {
339    DopError::InvalidInput { field, reason }
340}
341
342fn map_linear_error(error: LinearError) -> DopError {
343    match error {
344        LinearError::InvalidInput { field, reason } => invalid_input(field, reason),
345    }
346}
347
348// --- multi-system DOP (general (3 + n_systems) x (3 + n_systems)) -----------
349
350/// `R Q R^T` for a 3x3 position cofactor block, formed as `(R Q) R^T` with
351/// fixed-order inner sums (the multi-system counterpart of [`rotate_pos_block`]).
352#[allow(clippy::needless_range_loop)]
353fn rotate3(q: &[[f64; 3]; 3], r: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
354    let mut rq = [[0.0_f64; 3]; 3];
355    for i in 0..3 {
356        for j in 0..3 {
357            let mut s = 0.0_f64;
358            for k in 0..3 {
359                s += r[i][k] * q[k][j];
360            }
361            rq[i][j] = s;
362        }
363    }
364    let mut enu = [[0.0_f64; 3]; 3];
365    for i in 0..3 {
366        for j in 0..3 {
367            let mut s = 0.0_f64;
368            for k in 0..3 {
369                s += rq[i][k] * r[j][k];
370            }
371            enu[i][j] = s;
372        }
373    }
374    enu
375}
376
377/// Multi-system dilution of precision: like [`dop`] but with one receiver-clock
378/// column per GNSS rather than a single shared clock.
379///
380/// `clock_index[i]` is the clock column (`0..n_clocks`) for satellite `i` - its
381/// system's index in the solve's clock ordering. The design row is therefore
382/// `[-e_x, -e_y, -e_z, <one-hot over the n_clocks clock columns>]` and the
383/// cofactor matrix is `(3 + n_clocks) x (3 + n_clocks)`. `PDOP`/`HDOP`/`VDOP` are
384/// the position block (ENU-rotated, unambiguous); `GDOP` is the square root of
385/// the full trace (all clocks); `TDOP` is the reference-system clock
386/// (`Q[3][3]`). Returns [`DopError::Singular`] for a rank-deficient geometry.
387///
388/// This path uses a general symmetric inverse (see [`invert_symmetric_pd`]) and
389/// is a deterministic geometry diagnostic, not a 0-ULP parity target; the
390/// single-system [`dop`] retains the 0-ULP cofactor inverse.
391///
392/// Crate-internal: every `clock_index` must be in `0..n_clocks` (the solver
393/// constructs them from the used satellites' system ordering, so this always
394/// holds). It is not part of the public API because the index convention is
395/// meaningless without the solver's clock ordering.
396pub(crate) fn dop_multi(
397    los: &[LineOfSight],
398    clock_index: &[usize],
399    n_clocks: usize,
400    weights: &[f64],
401    receiver: Wgs84Geodetic,
402) -> Result<Dop, DopError> {
403    validate_dop_multi_inputs(los, clock_index, n_clocks, weights, receiver)?;
404    let p = 3 + n_clocks;
405    if los.len() < p {
406        return Err(DopError::TooFewSatellites);
407    }
408
409    let mut a = vec![vec![0.0_f64; p]; p];
410    for k in 0..los.len() {
411        let mut row = vec![0.0_f64; p];
412        row[0] = -los[k].e_x;
413        row[1] = -los[k].e_y;
414        row[2] = -los[k].e_z;
415        row[3 + clock_index[k]] = 1.0;
416        let w = weights[k];
417        #[allow(clippy::needless_range_loop)]
418        for i in 0..p {
419            for j in 0..p {
420                a[i][j] += row[i] * w * row[j];
421            }
422        }
423    }
424    let q = invert_symmetric_pd(&a).ok_or(DopError::Singular)?;
425
426    let r = ecef_to_enu_rotation(receiver.lat_rad, receiver.lon_rad);
427    let qpos = [
428        [q[0][0], q[0][1], q[0][2]],
429        [q[1][0], q[1][1], q[1][2]],
430        [q[2][0], q[2][1], q[2][2]],
431    ];
432    let enu = rotate3(&qpos, &r);
433
434    let qe = enu[0][0];
435    let qn = enu[1][1];
436    let qu = enu[2][2];
437    let qt = q[3][3];
438    let trace: f64 = (0..p).map(|i| q[i][i]).sum();
439
440    let gdop_arg = trace;
441    let pdop_arg = qe + qn + qu;
442    let hdop_arg = qe + qn;
443    let vdop_arg = qu;
444    let tdop_arg = qt;
445    for arg in [gdop_arg, pdop_arg, hdop_arg, vdop_arg, tdop_arg] {
446        #[allow(clippy::neg_cmp_op_on_partial_ord)]
447        let nonpositive_or_nan = !(arg >= 0.0);
448        if nonpositive_or_nan || !arg.is_finite() {
449            return Err(DopError::Singular);
450        }
451    }
452
453    Ok(Dop {
454        gdop: gdop_arg.sqrt(),
455        pdop: pdop_arg.sqrt(),
456        hdop: hdop_arg.sqrt(),
457        vdop: vdop_arg.sqrt(),
458        tdop: tdop_arg.sqrt(),
459    })
460}
461
462fn validate_dop_multi_inputs(
463    los: &[LineOfSight],
464    clock_index: &[usize],
465    n_clocks: usize,
466    weights: &[f64],
467    receiver: Wgs84Geodetic,
468) -> Result<(), DopError> {
469    if los.len() != weights.len() {
470        return Err(invalid_input("weights", "length must match los"));
471    }
472    if los.len() != clock_index.len() {
473        return Err(invalid_input("clock_index", "length must match los"));
474    }
475    if n_clocks == 0 {
476        return Err(invalid_input("n_clocks", "not positive"));
477    }
478    if clock_index.iter().any(|&idx| idx >= n_clocks) {
479        return Err(invalid_input("clock_index", "out of range"));
480    }
481    validate_los(los)?;
482    validate_weights(weights)?;
483    validate_receiver(receiver)
484}
485
486#[cfg(all(test, sidereon_repo_tests))]
487pub(crate) mod test_support {
488    //! Internal accessors so the parity test can assert 0 ULP on the
489    //! intermediates (normal matrix, cofactor matrix, ENU block) as well as the
490    //! final scalars, without making them part of the public API.
491    use super::*;
492
493    pub(crate) fn normal_matrix_for(los: &[LineOfSight], weights: &[f64]) -> [[f64; 4]; 4] {
494        let rows: Vec<[f64; 4]> = los.iter().map(|l| l.design_row()).collect();
495        normal_matrix_4_weighted_column_outer(&rows, weights).expect("valid DOP test inputs")
496    }
497
498    pub(crate) fn det4_for(a: &[[f64; 4]; 4]) -> f64 {
499        crate::astro::math::linear::det4_cofactor(a)
500    }
501
502    pub(crate) fn inv4_for(a: &[[f64; 4]; 4]) -> Option<[[f64; 4]; 4]> {
503        invert_4x4_cofactor(a)
504    }
505
506    pub(crate) fn enu_block_for(q: &[[f64; 4]; 4], lat_rad: f64, lon_rad: f64) -> [[f64; 3]; 3] {
507        let r = ecef_to_enu_rotation(lat_rad, lon_rad);
508        rotate_pos_block(q, &r)
509    }
510}
511
512#[cfg(all(test, sidereon_repo_tests))]
513mod tests;