use crate::astro::math::linear::{
invert_4x4_cofactor, invert_symmetric_pd, normal_matrix_4_weighted_column_outer, LinearError,
};
use crate::frame::Wgs84Geodetic;
const DEG_TO_RAD: f64 = std::f64::consts::PI / 180.0;
const LOS_UNIT_TOLERANCE: f64 = 1.0e-3;
#[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, Copy, PartialEq)]
pub struct Dop {
pub gdop: f64,
pub pdop: f64,
pub hdop: f64,
pub vdop: f64,
pub tdop: 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 {}
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],
]
}
#[allow(clippy::needless_range_loop)] fn rotate_pos_block(q: &[[f64; 4]; 4], r: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
let mut rq = [[0.0_f64; 3]; 3];
for i in 0..3 {
for j in 0..3 {
let mut s = 0.0_f64;
for k in 0..3 {
s += r[i][k] * q[k][j];
}
rq[i][j] = s;
}
}
let mut enu = [[0.0_f64; 3]; 3];
for i in 0..3 {
for j in 0..3 {
let mut s = 0.0_f64;
for k in 0..3 {
s += rq[i][k] * r[j][k];
}
enu[i][j] = s;
}
}
enu
}
pub fn dop(los: &[LineOfSight], weights: &[f64], receiver: Wgs84Geodetic) -> 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 = ecef_to_enu_rotation(receiver.lat_rad, receiver.lon_rad);
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(),
})
}
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_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),
}
}
#[allow(clippy::needless_range_loop)]
fn rotate3(q: &[[f64; 3]; 3], r: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
let mut rq = [[0.0_f64; 3]; 3];
for i in 0..3 {
for j in 0..3 {
let mut s = 0.0_f64;
for k in 0..3 {
s += r[i][k] * q[k][j];
}
rq[i][j] = s;
}
}
let mut enu = [[0.0_f64; 3]; 3];
for i in 0..3 {
for j in 0..3 {
let mut s = 0.0_f64;
for k in 0..3 {
s += rq[i][k] * r[j][k];
}
enu[i][j] = s;
}
}
enu
}
pub(crate) fn dop_multi(
los: &[LineOfSight],
clock_index: &[usize],
n_clocks: usize,
weights: &[f64],
receiver: Wgs84Geodetic,
) -> Result<Dop, DopError> {
validate_dop_multi_inputs(los, clock_index, 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 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] {
#[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(),
})
}
fn validate_dop_multi_inputs(
los: &[LineOfSight],
clock_index: &[usize],
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 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(all(test, sidereon_repo_tests))]
mod tests;