use crate::astro::math::linear::{
invert_4x4_cofactor, invert_symmetric_pd, normal_matrix_4_weighted_column_outer, LinearError,
};
use crate::astro::math::mat3::{inline_rxr, inline_tr};
use crate::frame::Wgs84Geodetic;
use crate::id::GnssSystem;
use crate::validate;
const DEG_TO_RAD: f64 = std::f64::consts::PI / 180.0;
const LOS_UNIT_TOLERANCE: f64 = 1.0e-3;
const GEOCENTRIC_MIN_RADIUS_M: f64 = 1.0;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LineOfSight {
pub e_x: f64,
pub e_y: f64,
pub e_z: f64,
}
impl LineOfSight {
pub const fn new(e_x: f64, e_y: f64, e_z: f64) -> Self {
Self { e_x, e_y, e_z }
}
fn design_row(self) -> [f64; 4] {
[-self.e_x, -self.e_y, -self.e_z, 1.0]
}
}
pub fn line_of_sight_from_az_el_deg(
azimuth_deg: f64,
elevation_deg: f64,
receiver: Wgs84Geodetic,
) -> Result<LineOfSight, DopError> {
validate_az_el_receiver(azimuth_deg, elevation_deg, receiver)?;
let az = azimuth_deg * DEG_TO_RAD;
let el = elevation_deg * DEG_TO_RAD;
let cos_el = el.cos();
let east = cos_el * az.sin();
let north = cos_el * az.cos();
let up = el.sin();
let r = ecef_to_enu_rotation(receiver.lat_rad, receiver.lon_rad);
let e_x = r[0][0] * east + r[1][0] * north + r[2][0] * up;
let e_y = r[0][1] * east + r[1][1] * north + r[2][1] * up;
let e_z = r[0][2] * east + r[1][2] * north + r[2][2] * up;
let los = LineOfSight::new(e_x, e_y, e_z);
validate_los(core::slice::from_ref(&los))?;
Ok(los)
}
#[derive(Debug, Clone, PartialEq)]
pub struct Dop {
pub gdop: f64,
pub pdop: f64,
pub hdop: f64,
pub vdop: f64,
pub tdop: f64,
pub system_tdops: Vec<(GnssSystem, f64)>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct GeometryCofactor {
pub state: [[f64; 4]; 4],
pub position_ecef: [[f64; 3]; 3],
pub position_enu: [[f64; 3]; 3],
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PositionCovariance {
pub ecef_m2: [[f64; 3]; 3],
pub enu_m2: [[f64; 3]; 3],
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct HorizontalErrorEllipse {
pub confidence: f64,
pub chi_square_scale: f64,
pub semi_major_m: f64,
pub semi_minor_m: f64,
pub azimuth_rad: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ErrorEllipse2 {
pub confidence: f64,
pub chi_square_scale: f64,
pub semi_major: f64,
pub semi_minor: f64,
pub orientation_rad: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DopError {
InvalidInput {
field: &'static str,
reason: &'static str,
},
TooFewSatellites,
Singular,
}
impl core::fmt::Display for DopError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
DopError::InvalidInput { field, reason } => {
write!(f, "invalid DOP input {field}: {reason}")
}
DopError::TooFewSatellites => {
write!(
f,
"fewer satellites than parameters: geometry is rank-deficient"
)
}
DopError::Singular => {
write!(f, "singular or ill-conditioned geometry: no finite DOP")
}
}
}
}
impl std::error::Error for DopError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum EnuConvention {
#[default]
GeodeticNormal,
GeocentricRadial,
}
fn enu_rotation(
receiver: Wgs84Geodetic,
convention: EnuConvention,
) -> Result<[[f64; 3]; 3], DopError> {
match convention {
EnuConvention::GeodeticNormal => {
Ok(ecef_to_enu_rotation(receiver.lat_rad, receiver.lon_rad))
}
EnuConvention::GeocentricRadial => {
let ecef = crate::frame::geodetic_to_itrf(receiver)
.map_err(|_| invalid_input("receiver", "geocentric basis unavailable"))?;
let p = ecef.as_array();
let radius = (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt();
if radius <= GEOCENTRIC_MIN_RADIUS_M {
return Err(invalid_input(
"receiver",
"geocentric up undefined at zero radius",
));
}
let (north, east, up) = crate::frame::geocentric_neu_basis(p);
Ok([east, north, up])
}
}
}
fn ecef_to_enu_rotation(lat_rad: f64, lon_rad: f64) -> [[f64; 3]; 3] {
let sphi = lat_rad.sin();
let cphi = lat_rad.cos();
let slam = lon_rad.sin();
let clam = lon_rad.cos();
[
[-slam, clam, 0.0],
[-sphi * clam, -sphi * slam, cphi],
[cphi * clam, cphi * slam, sphi],
]
}
fn rotate_pos_block(q: &[[f64; 4]; 4], r: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
let qpos = [
[q[0][0], q[0][1], q[0][2]],
[q[1][0], q[1][1], q[1][2]],
[q[2][0], q[2][1], q[2][2]],
];
rotate3(&qpos, r)
}
pub fn dop(los: &[LineOfSight], weights: &[f64], receiver: Wgs84Geodetic) -> Result<Dop, DopError> {
dop_with_convention(los, weights, receiver, EnuConvention::GeodeticNormal)
}
pub fn dop_with_convention(
los: &[LineOfSight],
weights: &[f64],
receiver: Wgs84Geodetic,
convention: EnuConvention,
) -> Result<Dop, DopError> {
validate_dop_inputs(los, weights, receiver)?;
if los.len() < 4 {
return Err(DopError::TooFewSatellites);
}
let rows: Vec<[f64; 4]> = los.iter().map(|l| l.design_row()).collect();
let a = normal_matrix_4_weighted_column_outer(&rows, weights).map_err(map_linear_error)?;
let q = invert_4x4_cofactor(&a).ok_or(DopError::Singular)?;
let r = enu_rotation(receiver, convention)?;
let enu = rotate_pos_block(&q, &r);
let qe = enu[0][0];
let qn = enu[1][1];
let qu = enu[2][2];
let qt = q[3][3];
let gdop_arg = q[0][0] + q[1][1] + q[2][2] + q[3][3];
let pdop_arg = qe + qn + qu;
let hdop_arg = qe + qn;
let vdop_arg = qu;
let tdop_arg = qt;
for arg in [gdop_arg, pdop_arg, hdop_arg, vdop_arg, tdop_arg] {
#[allow(clippy::neg_cmp_op_on_partial_ord)]
let nonpositive_or_nan = !(arg >= 0.0);
if nonpositive_or_nan || !arg.is_finite() {
return Err(DopError::Singular);
}
}
Ok(Dop {
gdop: gdop_arg.sqrt(),
pdop: pdop_arg.sqrt(),
hdop: hdop_arg.sqrt(),
vdop: vdop_arg.sqrt(),
tdop: tdop_arg.sqrt(),
system_tdops: Vec::new(),
})
}
pub fn geometry_cofactor(
los: &[LineOfSight],
weights: &[f64],
receiver: Wgs84Geodetic,
) -> Result<GeometryCofactor, DopError> {
geometry_cofactor_with_convention(los, weights, receiver, EnuConvention::GeodeticNormal)
}
pub fn geometry_cofactor_with_convention(
los: &[LineOfSight],
weights: &[f64],
receiver: Wgs84Geodetic,
convention: EnuConvention,
) -> Result<GeometryCofactor, DopError> {
validate_dop_inputs(los, weights, receiver)?;
if los.len() < 4 {
return Err(DopError::TooFewSatellites);
}
let rows: Vec<[f64; 4]> = los.iter().map(|l| l.design_row()).collect();
let a = normal_matrix_4_weighted_column_outer(&rows, weights).map_err(map_linear_error)?;
let q = invert_4x4_cofactor(&a).ok_or(DopError::Singular)?;
validate_cofactor_variances(&q)?;
let r = enu_rotation(receiver, convention)?;
let enu = rotate_pos_block(&q, &r);
validate_matrix3(&enu, "position_enu")?;
Ok(GeometryCofactor {
state: q,
position_ecef: position_block(&q),
position_enu: enu,
})
}
pub fn position_covariance_from_geometry_m2(
los: &[LineOfSight],
weights: &[f64],
receiver: Wgs84Geodetic,
range_variance_scale_m2: f64,
) -> Result<PositionCovariance, DopError> {
validate_variance_scale(range_variance_scale_m2)?;
let cofactor = geometry_cofactor(los, weights, receiver)?;
Ok(PositionCovariance {
ecef_m2: scale_matrix3(cofactor.position_ecef, range_variance_scale_m2),
enu_m2: scale_matrix3(cofactor.position_enu, range_variance_scale_m2),
})
}
pub fn horizontal_error_ellipse(
covariance_enu_m2: [[f64; 3]; 3],
confidence: f64,
) -> Result<HorizontalErrorEllipse, DopError> {
validate_matrix3(&covariance_enu_m2, "covariance_enu_m2")?;
let en_block = [
[covariance_enu_m2[0][0], covariance_enu_m2[0][1]],
[covariance_enu_m2[1][0], covariance_enu_m2[1][1]],
];
let ellipse = error_ellipse_2x2(en_block, confidence).map_err(|err| match err {
DopError::InvalidInput {
field: "covariance",
reason,
} => invalid_input("covariance_enu_m2", reason),
other => other,
})?;
Ok(HorizontalErrorEllipse {
confidence: ellipse.confidence,
chi_square_scale: ellipse.chi_square_scale,
semi_major_m: ellipse.semi_major,
semi_minor_m: ellipse.semi_minor,
azimuth_rad: ellipse.orientation_rad,
})
}
pub fn error_ellipse_2x2(
covariance: [[f64; 2]; 2],
confidence: f64,
) -> Result<ErrorEllipse2, DopError> {
for row in &covariance {
validate::finite_slice(row, "covariance").map_err(map_validation_error)?;
}
validate_confidence(confidence)?;
let a = covariance[0][0];
let b = 0.5 * (covariance[0][1] + covariance[1][0]);
let c = covariance[1][1];
let half_delta = 0.5 * (a - c);
let center = 0.5 * (a + c);
let root = (half_delta * half_delta + b * b).sqrt();
let lambda_major = center + root;
let lambda_minor = center - root;
if !lambda_major.is_finite() || !lambda_minor.is_finite() || lambda_minor < -1.0e-12 {
return Err(invalid_input("covariance", "not positive semidefinite"));
}
let chi_square_scale = -2.0 * (1.0 - confidence).ln();
let semi_major = (lambda_major.max(0.0) * chi_square_scale).sqrt();
let semi_minor = (lambda_minor.max(0.0) * chi_square_scale).sqrt();
let orientation_rad = if root == 0.0 {
0.0
} else {
0.5 * (2.0 * b).atan2(a - c)
};
Ok(ErrorEllipse2 {
confidence,
chi_square_scale,
semi_major,
semi_minor,
orientation_rad,
})
}
pub fn error_ellipse_from_geometry(
los: &[LineOfSight],
weights: &[f64],
receiver: Wgs84Geodetic,
range_variance_scale_m2: f64,
confidence: f64,
) -> Result<HorizontalErrorEllipse, DopError> {
let covariance =
position_covariance_from_geometry_m2(los, weights, receiver, range_variance_scale_m2)?;
horizontal_error_ellipse(covariance.enu_m2, confidence)
}
fn validate_dop_inputs(
los: &[LineOfSight],
weights: &[f64],
receiver: Wgs84Geodetic,
) -> Result<(), DopError> {
if los.len() != weights.len() {
return Err(invalid_input("weights", "length must match los"));
}
validate_los(los)?;
validate_weights(weights)?;
validate_receiver(receiver)
}
fn validate_los(los: &[LineOfSight]) -> Result<(), DopError> {
for line in los {
if !(line.e_x.is_finite() && line.e_y.is_finite() && line.e_z.is_finite()) {
return Err(invalid_input("los", "not finite"));
}
let norm = (line.e_x * line.e_x + line.e_y * line.e_y + line.e_z * line.e_z).sqrt();
if !norm.is_finite() {
return Err(invalid_input("los", "not finite"));
}
if (norm - 1.0).abs() > LOS_UNIT_TOLERANCE {
return Err(invalid_input("los", "not unit length"));
}
}
Ok(())
}
fn validate_cofactor_variances(q: &[[f64; 4]; 4]) -> Result<(), DopError> {
for row in q {
validate::finite_slice(row, "cofactor").map_err(map_validation_error)?;
}
for (idx, row) in q.iter().enumerate() {
let variance = row[idx];
#[allow(clippy::neg_cmp_op_on_partial_ord)]
let negative_or_nan = !(variance >= 0.0);
if negative_or_nan || !variance.is_finite() {
return Err(DopError::Singular);
}
}
Ok(())
}
fn validate_variance_scale(value: f64) -> Result<(), DopError> {
if !value.is_finite() {
return Err(invalid_input("range_variance_scale_m2", "not finite"));
}
if value < 0.0 {
return Err(invalid_input("range_variance_scale_m2", "negative"));
}
Ok(())
}
fn validate_confidence(value: f64) -> Result<(), DopError> {
if !value.is_finite() {
return Err(invalid_input("confidence", "not finite"));
}
if !(0.0..1.0).contains(&value) {
return Err(invalid_input("confidence", "out of range"));
}
Ok(())
}
fn validate_matrix3(matrix: &[[f64; 3]; 3], field: &'static str) -> Result<(), DopError> {
for row in matrix {
validate::finite_slice(row, field).map_err(map_validation_error)?;
}
Ok(())
}
fn position_block(q: &[[f64; 4]; 4]) -> [[f64; 3]; 3] {
[
[q[0][0], q[0][1], q[0][2]],
[q[1][0], q[1][1], q[1][2]],
[q[2][0], q[2][1], q[2][2]],
]
}
fn scale_matrix3(matrix: [[f64; 3]; 3], scale: f64) -> [[f64; 3]; 3] {
[
[
matrix[0][0] * scale,
matrix[0][1] * scale,
matrix[0][2] * scale,
],
[
matrix[1][0] * scale,
matrix[1][1] * scale,
matrix[1][2] * scale,
],
[
matrix[2][0] * scale,
matrix[2][1] * scale,
matrix[2][2] * scale,
],
]
}
fn validate_weights(weights: &[f64]) -> Result<(), DopError> {
if weights.iter().any(|weight| !weight.is_finite()) {
return Err(invalid_input("weights", "not finite"));
}
if weights.iter().any(|&weight| weight < 0.0) {
return Err(invalid_input("weights", "negative"));
}
Ok(())
}
fn validate_receiver(receiver: Wgs84Geodetic) -> Result<(), DopError> {
if !(receiver.lat_rad.is_finite()
&& receiver.lon_rad.is_finite()
&& receiver.height_m.is_finite())
{
return Err(invalid_input("receiver", "not finite"));
}
if !(-core::f64::consts::FRAC_PI_2..=core::f64::consts::FRAC_PI_2).contains(&receiver.lat_rad) {
return Err(invalid_input("receiver.lat_rad", "out of range"));
}
if !(-core::f64::consts::PI..=core::f64::consts::PI).contains(&receiver.lon_rad) {
return Err(invalid_input("receiver.lon_rad", "out of range"));
}
Ok(())
}
fn validate_az_el_receiver(
azimuth_deg: f64,
elevation_deg: f64,
receiver: Wgs84Geodetic,
) -> Result<(), DopError> {
if !azimuth_deg.is_finite() {
return Err(invalid_input("azimuth_deg", "not finite"));
}
if !elevation_deg.is_finite() {
return Err(invalid_input("elevation_deg", "not finite"));
}
if !(-90.0..=90.0).contains(&elevation_deg) {
return Err(invalid_input("elevation_deg", "out of range"));
}
validate_receiver(receiver)
}
fn invalid_input(field: &'static str, reason: &'static str) -> DopError {
DopError::InvalidInput { field, reason }
}
fn map_linear_error(error: LinearError) -> DopError {
match error {
LinearError::InvalidInput { field, reason } => invalid_input(field, reason),
}
}
fn map_validation_error(error: validate::FieldError) -> DopError {
invalid_input(error.field(), error.reason())
}
fn rotate3(q: &[[f64; 3]; 3], r: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
inline_rxr(&inline_rxr(r, q), &inline_tr(r))
}
pub(crate) fn dop_multi(
los: &[LineOfSight],
clock_index: &[usize],
systems: &[GnssSystem],
n_clocks: usize,
weights: &[f64],
receiver: Wgs84Geodetic,
) -> Result<Dop, DopError> {
validate_dop_multi_inputs(los, clock_index, systems, n_clocks, weights, receiver)?;
let p = 3 + n_clocks;
if los.len() < p {
return Err(DopError::TooFewSatellites);
}
let mut a = vec![vec![0.0_f64; p]; p];
for k in 0..los.len() {
let mut row = vec![0.0_f64; p];
row[0] = -los[k].e_x;
row[1] = -los[k].e_y;
row[2] = -los[k].e_z;
row[3 + clock_index[k]] = 1.0;
let w = weights[k];
#[allow(clippy::needless_range_loop)]
for i in 0..p {
for j in 0..p {
a[i][j] += row[i] * w * row[j];
}
}
}
let q = invert_symmetric_pd(&a).ok_or(DopError::Singular)?;
let r = ecef_to_enu_rotation(receiver.lat_rad, receiver.lon_rad);
let qpos = [
[q[0][0], q[0][1], q[0][2]],
[q[1][0], q[1][1], q[1][2]],
[q[2][0], q[2][1], q[2][2]],
];
let enu = rotate3(&qpos, &r);
let qe = enu[0][0];
let qn = enu[1][1];
let qu = enu[2][2];
let qt = q[3][3];
let trace: f64 = (0..p).map(|i| q[i][i]).sum();
let system_tdop_args: Vec<f64> = (0..n_clocks).map(|i| q[3 + i][3 + i]).collect();
let gdop_arg = trace;
let pdop_arg = qe + qn + qu;
let hdop_arg = qe + qn;
let vdop_arg = qu;
let tdop_arg = qt;
for &arg in [gdop_arg, pdop_arg, hdop_arg, vdop_arg, tdop_arg]
.iter()
.chain(system_tdop_args.iter())
{
#[allow(clippy::neg_cmp_op_on_partial_ord)]
let nonpositive_or_nan = !(arg >= 0.0);
if nonpositive_or_nan || !arg.is_finite() {
return Err(DopError::Singular);
}
}
Ok(Dop {
gdop: gdop_arg.sqrt(),
pdop: pdop_arg.sqrt(),
hdop: hdop_arg.sqrt(),
vdop: vdop_arg.sqrt(),
tdop: tdop_arg.sqrt(),
system_tdops: system_tdop_args
.iter()
.enumerate()
.map(|(i, &v)| (systems[i], v.sqrt()))
.collect(),
})
}
fn validate_dop_multi_inputs(
los: &[LineOfSight],
clock_index: &[usize],
systems: &[GnssSystem],
n_clocks: usize,
weights: &[f64],
receiver: Wgs84Geodetic,
) -> Result<(), DopError> {
if los.len() != weights.len() {
return Err(invalid_input("weights", "length must match los"));
}
if los.len() != clock_index.len() {
return Err(invalid_input("clock_index", "length must match los"));
}
if n_clocks == 0 {
return Err(invalid_input("n_clocks", "not positive"));
}
if systems.len() != n_clocks {
return Err(invalid_input("systems", "length must match n_clocks"));
}
if clock_index.iter().any(|&idx| idx >= n_clocks) {
return Err(invalid_input("clock_index", "out of range"));
}
validate_los(los)?;
validate_weights(weights)?;
validate_receiver(receiver)
}
#[cfg(all(test, sidereon_repo_tests))]
pub(crate) mod test_support {
use super::*;
pub(crate) fn normal_matrix_for(los: &[LineOfSight], weights: &[f64]) -> [[f64; 4]; 4] {
let rows: Vec<[f64; 4]> = los.iter().map(|l| l.design_row()).collect();
normal_matrix_4_weighted_column_outer(&rows, weights).expect("valid DOP test inputs")
}
pub(crate) fn det4_for(a: &[[f64; 4]; 4]) -> f64 {
crate::astro::math::linear::det4_cofactor(a)
}
pub(crate) fn inv4_for(a: &[[f64; 4]; 4]) -> Option<[[f64; 4]; 4]> {
invert_4x4_cofactor(a)
}
pub(crate) fn enu_block_for(q: &[[f64; 4]; 4], lat_rad: f64, lon_rad: f64) -> [[f64; 3]; 3] {
let r = ecef_to_enu_rotation(lat_rad, lon_rad);
rotate_pos_block(q, &r)
}
}
#[cfg(test)]
mod public_api_tests {
use super::*;
fn receiver() -> Wgs84Geodetic {
Wgs84Geodetic::new(45.0_f64.to_radians(), -75.0_f64.to_radians(), 100.0)
.expect("valid receiver")
}
fn sample_geometry() -> (Vec<LineOfSight>, Vec<f64>, Wgs84Geodetic) {
let rx = receiver();
let az_el = [
(5.0, 25.0),
(80.0, 35.0),
(155.0, 55.0),
(235.0, 40.0),
(310.0, 65.0),
];
let los = az_el
.into_iter()
.map(|(az, el)| line_of_sight_from_az_el_deg(az, el, rx).expect("valid LOS"))
.collect::<Vec<_>>();
let weights = vec![1.0, 0.8, 1.4, 0.9, 1.1];
(los, weights, rx)
}
#[test]
fn geometry_cofactor_exposes_the_dop_position_block() {
let (los, weights, rx) = sample_geometry();
let d = dop(&los, &weights, rx).expect("DOP");
let q = geometry_cofactor(&los, &weights, rx).expect("cofactor");
let qe = q.position_enu[0][0];
let qn = q.position_enu[1][1];
let qu = q.position_enu[2][2];
assert_eq!(d.pdop.to_bits(), (qe + qn + qu).sqrt().to_bits());
assert_eq!(d.hdop.to_bits(), (qe + qn).sqrt().to_bits());
assert_eq!(d.vdop.to_bits(), qu.sqrt().to_bits());
assert_eq!(d.tdop.to_bits(), q.state[3][3].sqrt().to_bits());
}
#[test]
fn position_covariance_scales_the_raw_cofactor() {
let (los, weights, rx) = sample_geometry();
let q = geometry_cofactor(&los, &weights, rx).expect("cofactor");
let cov =
position_covariance_from_geometry_m2(&los, &weights, rx, 4.0).expect("covariance");
for i in 0..3 {
for j in 0..3 {
assert_eq!(
cov.ecef_m2[i][j].to_bits(),
(q.position_ecef[i][j] * 4.0).to_bits()
);
assert_eq!(
cov.enu_m2[i][j].to_bits(),
(q.position_enu[i][j] * 4.0).to_bits()
);
}
}
}
#[test]
fn horizontal_error_ellipse_uses_chi_square_two_dof_scale() {
let covariance = [[9.0, 2.0, 0.0], [2.0, 4.0, 0.0], [0.0, 0.0, 16.0]];
let ellipse = horizontal_error_ellipse(covariance, 0.95).expect("ellipse");
let expected_scale = -2.0 * (1.0_f64 - 0.95).ln();
assert_eq!(ellipse.chi_square_scale.to_bits(), expected_scale.to_bits());
assert!(ellipse.semi_major_m >= ellipse.semi_minor_m);
assert!(ellipse.semi_minor_m > 0.0);
assert!(ellipse.azimuth_rad.is_finite());
}
#[test]
fn error_ellipse_2x2_matches_numpy_eigh() {
let ellipse = error_ellipse_2x2([[9.0, 2.0], [2.0, 4.0]], 0.95).expect("ellipse");
assert!((ellipse.chi_square_scale - 5.99146454710798).abs() < 1e-12);
assert!((ellipse.semi_major - 7.6240780089041085).abs() < 1e-12);
assert!((ellipse.semi_minor - 4.445500379771495).abs() < 1e-12);
assert!((ellipse.orientation_rad - 0.3373704711117763).abs() < 1e-12);
}
#[test]
fn horizontal_error_ellipse_delegates_to_2x2_primitive() {
let cov3 = [[9.0, 2.0, 1.0], [2.0, 4.0, -3.0], [1.0, -3.0, 16.0]];
let wrapper = horizontal_error_ellipse(cov3, 0.95).expect("wrapper");
let primitive =
error_ellipse_2x2([[cov3[0][0], cov3[0][1]], [cov3[1][0], cov3[1][1]]], 0.95)
.expect("primitive");
assert_eq!(
wrapper.semi_major_m.to_bits(),
primitive.semi_major.to_bits()
);
assert_eq!(
wrapper.semi_minor_m.to_bits(),
primitive.semi_minor.to_bits()
);
assert_eq!(
wrapper.azimuth_rad.to_bits(),
primitive.orientation_rad.to_bits()
);
}
#[test]
fn geocentric_convention_changes_only_horizontal_vertical_split() {
let (los, weights, rx) = sample_geometry();
let geodetic = dop(&los, &weights, rx).expect("geodetic DOP");
let geocentric = dop_with_convention(&los, &weights, rx, EnuConvention::GeocentricRadial)
.expect("geocentric DOP");
assert_eq!(geodetic.gdop.to_bits(), geocentric.gdop.to_bits());
assert_eq!(geodetic.tdop.to_bits(), geocentric.tdop.to_bits());
assert!((geodetic.pdop - geocentric.pdop).abs() < 1e-9 * geodetic.pdop);
let hdop_rel = (geodetic.hdop - geocentric.hdop).abs() / geodetic.hdop;
assert!(hdop_rel > 0.0, "convention must change HDOP");
assert!(
hdop_rel < 1e-2,
"HDOP shift {hdop_rel} larger than expected"
);
assert_ne!(geodetic.vdop.to_bits(), geocentric.vdop.to_bits());
}
#[test]
fn geocentric_convention_rejects_zero_radius_receiver() {
let geocenter = Wgs84Geodetic::new(0.0, 0.0, -crate::astro::constants::earth::WGS84_A_M)
.expect("valid geodetic receiver");
let (los, weights, _) = sample_geometry();
let err = dop_with_convention(&los, &weights, geocenter, EnuConvention::GeocentricRadial)
.expect_err("zero-radius geocentric up must be rejected");
assert!(matches!(
err,
DopError::InvalidInput {
field: "receiver",
..
}
));
assert!(
dop_with_convention(&los, &weights, geocenter, EnuConvention::GeodeticNormal).is_ok()
);
}
#[test]
fn default_dop_equals_explicit_geodetic_convention_bit_for_bit() {
let (los, weights, rx) = sample_geometry();
let default = dop(&los, &weights, rx).expect("default");
let explicit =
dop_with_convention(&los, &weights, rx, EnuConvention::GeodeticNormal).expect("geo");
assert_eq!(default.hdop.to_bits(), explicit.hdop.to_bits());
assert_eq!(default.vdop.to_bits(), explicit.vdop.to_bits());
assert_eq!(default.pdop.to_bits(), explicit.pdop.to_bits());
}
}
#[cfg(all(test, sidereon_repo_tests))]
mod tests;