1use 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#[derive(Debug, Clone, Copy, PartialEq)]
54pub struct LineOfSight {
55 pub e_x: f64,
57 pub e_y: f64,
59 pub e_z: f64,
61}
62
63impl LineOfSight {
64 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 fn design_row(self) -> [f64; 4] {
71 [-self.e_x, -self.e_y, -self.e_z, 1.0]
72 }
73}
74
75pub 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#[derive(Debug, Clone, Copy, PartialEq)]
113pub struct Dop {
114 pub gdop: f64,
118 pub pdop: f64,
120 pub hdop: f64,
122 pub vdop: f64,
124 pub tdop: f64,
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum DopError {
133 InvalidInput {
135 field: &'static str,
137 reason: &'static str,
139 },
140 TooFewSatellites,
144 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
170fn 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#[allow(clippy::needless_range_loop)] fn 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
210pub 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 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 #[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#[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
377pub(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 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;