1use std::collections::BTreeSet;
10
11use crate::astro::math::linear::{
12 dot4, invert_4x4_cofactor, mat4_vec4, normal_matrix_4_unweighted_row_outer,
13};
14use crate::astro::math::vec3;
15
16use crate::constants::{C_M_S, F_L1_HZ};
17use crate::id::GnssSatelliteId;
18use crate::observables::{predict, ObservableEphemerisSource, PredictOptions};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum VelocityObservable {
23 RangeRate,
25 Doppler,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq)]
32pub struct VelocityObservation {
33 pub satellite_id: GnssSatelliteId,
35 pub value: f64,
38 pub carrier_hz: f64,
40 pub sat_clock_drift_s_s: f64,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq)]
46pub struct VelocitySolveOptions {
47 pub observable: VelocityObservable,
49 pub light_time: bool,
51 pub sagnac: bool,
53}
54
55impl Default for VelocitySolveOptions {
56 fn default() -> Self {
57 Self {
58 observable: VelocityObservable::RangeRate,
59 light_time: true,
60 sagnac: true,
61 }
62 }
63}
64
65#[derive(Debug, Clone, PartialEq)]
67#[non_exhaustive]
68pub struct VelocitySolution {
69 pub velocity_m_s: [f64; 3],
71 pub speed_m_s: f64,
73 pub clock_drift_s_s: f64,
75 pub state_covariance: [[f64; 4]; 4],
82 pub residuals_m_s: Vec<(GnssSatelliteId, f64)>,
84 pub used_sats: Vec<GnssSatelliteId>,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum VelocityError {
92 NoObservations,
94 TooFewSatellites { used: usize, required: usize },
96 SingularGeometry,
98 DuplicateObservation { satellite_id: GnssSatelliteId },
100 InvalidCarrier { satellite_id: GnssSatelliteId },
102 InvalidInput {
104 field: &'static str,
105 reason: &'static str,
106 },
107 InvalidObservation { satellite_id: GnssSatelliteId },
109 InvalidReceiverState,
111}
112
113impl core::fmt::Display for VelocityError {
114 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
115 match self {
116 Self::NoObservations => write!(f, "no observations"),
117 Self::TooFewSatellites { used, required } => {
118 write!(f, "too few satellites: {used}, required {required}")
119 }
120 Self::SingularGeometry => write!(f, "singular geometry"),
121 Self::DuplicateObservation { satellite_id } => {
122 write!(f, "duplicate observation for {satellite_id}")
123 }
124 Self::InvalidCarrier { satellite_id } => {
125 write!(f, "invalid carrier for {satellite_id}")
126 }
127 Self::InvalidInput { field, reason } => {
128 write!(f, "invalid velocity input {field}: {reason}")
129 }
130 Self::InvalidObservation { satellite_id } => {
131 write!(f, "invalid observation for {satellite_id}")
132 }
133 Self::InvalidReceiverState => write!(f, "invalid receiver state"),
134 }
135 }
136}
137
138impl std::error::Error for VelocityError {}
139
140#[derive(Debug, Clone, Copy)]
141struct Row {
142 sat: GnssSatelliteId,
143 h: [f64; 4],
144 y: f64,
145}
146
147pub fn doppler_to_range_rate(doppler_hz: f64, carrier_hz: f64) -> Result<f64, VelocityError> {
149 let doppler_hz = velocity_finite(doppler_hz, "doppler_hz")?;
150 let carrier_hz = velocity_positive(carrier_hz, "carrier_hz")?;
151 velocity_finite_output(-doppler_hz * C_M_S / carrier_hz, "range_rate_m_s")
152}
153
154pub fn range_rate_to_doppler(range_rate_m_s: f64, carrier_hz: f64) -> Result<f64, VelocityError> {
156 let range_rate_m_s = velocity_finite(range_rate_m_s, "range_rate_m_s")?;
157 let carrier_hz = velocity_positive(carrier_hz, "carrier_hz")?;
158 velocity_finite_output(-range_rate_m_s * carrier_hz / C_M_S, "doppler_hz")
159}
160
161pub fn solve(
163 source: &dyn ObservableEphemerisSource,
164 observations: &[VelocityObservation],
165 receiver_ecef_m: [f64; 3],
166 t_rx_j2000_s: f64,
167 options: VelocitySolveOptions,
168) -> Result<VelocitySolution, VelocityError> {
169 if observations.is_empty() {
170 return Err(VelocityError::NoObservations);
171 }
172
173 validate_receiver_state(receiver_ecef_m, t_rx_j2000_s)?;
174 ensure_no_duplicates(observations)?;
175 validate_observations(observations)?;
176 let rows = build_rows(source, observations, receiver_ecef_m, t_rx_j2000_s, options)?;
177 if rows.len() < 4 {
178 return Err(VelocityError::TooFewSatellites {
179 used: rows.len(),
180 required: 4,
181 });
182 }
183
184 let (x, normal_inverse) = solve_normal_equations(&rows)?;
185 assemble_solution(x, normal_inverse, &rows)
186}
187
188fn validate_receiver_state(
189 receiver_ecef_m: [f64; 3],
190 t_rx_j2000_s: f64,
191) -> Result<(), VelocityError> {
192 if receiver_ecef_m.iter().all(|value| value.is_finite()) && t_rx_j2000_s.is_finite() {
193 Ok(())
194 } else {
195 Err(VelocityError::InvalidReceiverState)
196 }
197}
198
199fn ensure_no_duplicates(observations: &[VelocityObservation]) -> Result<(), VelocityError> {
200 let mut seen = BTreeSet::new();
201 for obs in observations {
202 if !seen.insert(obs.satellite_id) {
203 return Err(VelocityError::DuplicateObservation {
204 satellite_id: obs.satellite_id,
205 });
206 }
207 }
208 Ok(())
209}
210
211fn validate_observations(observations: &[VelocityObservation]) -> Result<(), VelocityError> {
212 for obs in observations {
213 if !(obs.value.is_finite() && obs.sat_clock_drift_s_s.is_finite()) {
214 return Err(VelocityError::InvalidObservation {
215 satellite_id: obs.satellite_id,
216 });
217 }
218 }
219 Ok(())
220}
221
222fn velocity_finite(x: f64, field: &'static str) -> Result<f64, VelocityError> {
223 if x.is_finite() {
224 Ok(x)
225 } else {
226 Err(VelocityError::InvalidInput {
227 field,
228 reason: "not finite",
229 })
230 }
231}
232
233fn velocity_positive(x: f64, field: &'static str) -> Result<f64, VelocityError> {
234 let x = velocity_finite(x, field)?;
235 if x > 0.0 {
236 Ok(x)
237 } else {
238 Err(VelocityError::InvalidInput {
239 field,
240 reason: "not positive",
241 })
242 }
243}
244
245fn velocity_finite_output(value: f64, field: &'static str) -> Result<f64, VelocityError> {
246 if value.is_finite() {
247 Ok(value)
248 } else {
249 Err(VelocityError::InvalidInput {
250 field,
251 reason: "out of range",
252 })
253 }
254}
255
256fn build_rows(
257 source: &dyn ObservableEphemerisSource,
258 observations: &[VelocityObservation],
259 receiver_ecef_m: [f64; 3],
260 t_rx_j2000_s: f64,
261 options: VelocitySolveOptions,
262) -> Result<Vec<Row>, VelocityError> {
263 let predict_options = PredictOptions {
264 carrier_hz: F_L1_HZ,
265 light_time: options.light_time,
266 sagnac: options.sagnac,
267 };
268 let mut rows = Vec::with_capacity(observations.len());
269
270 for obs in observations {
271 let rho_dot_m_s = match options.observable {
272 VelocityObservable::RangeRate => obs.value,
273 VelocityObservable::Doppler => {
274 if !(obs.carrier_hz.is_finite() && obs.carrier_hz > 0.0) {
275 return Err(VelocityError::InvalidCarrier {
276 satellite_id: obs.satellite_id,
277 });
278 }
279 doppler_to_range_rate(obs.value, obs.carrier_hz).map_err(|error| match error {
280 VelocityError::InvalidInput {
281 field: "carrier_hz",
282 ..
283 } => VelocityError::InvalidCarrier {
284 satellite_id: obs.satellite_id,
285 },
286 _ => VelocityError::InvalidObservation {
287 satellite_id: obs.satellite_id,
288 },
289 })?
290 }
291 };
292
293 let Ok(predicted) = predict(
294 source,
295 obs.satellite_id,
296 receiver_ecef_m,
297 t_rx_j2000_s,
298 predict_options,
299 ) else {
300 continue;
301 };
302
303 let [ex, ey, ez] = predicted.los_unit;
304 let y = rho_dot_m_s - predicted.range_rate_m_s + C_M_S * obs.sat_clock_drift_s_s;
305 if ![ex, ey, ez, predicted.range_rate_m_s, y]
306 .iter()
307 .all(|value| value.is_finite())
308 {
309 return Err(VelocityError::InvalidInput {
310 field: "velocity row",
311 reason: "out of range",
312 });
313 }
314 rows.push(Row {
315 sat: obs.satellite_id,
316 h: [-ex, -ey, -ez, 1.0],
317 y,
318 });
319 }
320
321 Ok(rows)
322}
323
324#[allow(clippy::needless_range_loop)] fn solve_normal_equations(rows: &[Row]) -> Result<([f64; 4], [[f64; 4]; 4]), VelocityError> {
326 let mut aty = [0.0_f64; 4];
327
328 for row in rows {
329 for i in 0..4 {
330 aty[i] += row.h[i] * row.y;
331 }
332 }
333 let row_h: Vec<[f64; 4]> = rows.iter().map(|row| row.h).collect();
334 let ata = normal_matrix_4_unweighted_row_outer(&row_h);
335
336 let inv = invert_4x4_cofactor(&ata).ok_or(VelocityError::SingularGeometry)?;
337 let solution = mat4_vec4(&inv, &aty);
338 if solution.iter().all(|value| value.is_finite()) {
339 Ok((solution, inv))
340 } else {
341 Err(VelocityError::InvalidInput {
342 field: "velocity solution",
343 reason: "out of range",
344 })
345 }
346}
347
348fn assemble_solution(
349 x: [f64; 4],
350 normal_inverse: [[f64; 4]; 4],
351 rows: &[Row],
352) -> Result<VelocitySolution, VelocityError> {
353 let velocity_m_s = [x[0], x[1], x[2]];
354 let speed_m_s = vec3::norm3(velocity_m_s);
355 let clock_drift_s_s = x[3] / C_M_S;
356 let state_covariance = velocity_state_covariance(normal_inverse);
357 let residuals_m_s: Vec<_> = rows
358 .iter()
359 .map(|row| (row.sat, row.y - hx(&row.h, &x)))
360 .collect();
361 if !velocity_m_s.iter().all(|value| value.is_finite())
362 || !speed_m_s.is_finite()
363 || !clock_drift_s_s.is_finite()
364 || !state_covariance
365 .iter()
366 .flatten()
367 .all(|value| value.is_finite())
368 || !residuals_m_s
369 .iter()
370 .all(|(_, residual)| residual.is_finite())
371 {
372 return Err(VelocityError::InvalidInput {
373 field: "velocity solution",
374 reason: "out of range",
375 });
376 }
377 let used_sats = rows.iter().map(|row| row.sat).collect();
378 Ok(VelocitySolution {
379 velocity_m_s,
380 speed_m_s,
381 clock_drift_s_s,
382 state_covariance,
383 residuals_m_s,
384 used_sats,
385 })
386}
387
388fn velocity_state_covariance(normal_inverse: [[f64; 4]; 4]) -> [[f64; 4]; 4] {
389 let scale = [1.0, 1.0, 1.0, 1.0 / C_M_S];
390 let mut out = [[0.0_f64; 4]; 4];
391 for i in 0..4 {
392 for j in 0..4 {
393 out[i][j] = normal_inverse[i][j] * scale[i] * scale[j];
394 }
395 }
396 out
397}
398
399fn hx(h: &[f64; 4], x: &[f64; 4]) -> f64 {
400 dot4(h, x)
401}
402
403#[cfg(all(test, sidereon_repo_tests))]
404mod tests {
405 use super::*;
406 use crate::ephemeris::Sp3;
407 use crate::observables::{
408 j2000_seconds_from_split, predict, ObservableState, ObservablesError,
409 };
410 use crate::{GnssSatelliteId, GnssSystem};
411
412 const T_RX_J2000_S: f64 = 646_272_000.0;
413 const RECEIVER: [f64; 3] = [4_500_000.0, 500_000.0, 4_500_000.0];
414 const V_TRUE: [f64; 3] = [12.0, -7.0, 3.0];
415 const DRIFT_TRUE: f64 = 1.0e-9;
416
417 fn sp3_fixture() -> Sp3 {
418 let path = concat!(
419 env!("CARGO_MANIFEST_DIR"),
420 "/tests/fixtures/sp3/GRG0MGXFIN_20201760000_01D_15M_ORB.SP3"
421 );
422 let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("read SP3 fixture {path}: {e}"));
423 Sp3::parse(&bytes).expect("parse SP3 fixture")
424 }
425
426 fn visible_gps(sp3: &Sp3) -> Vec<GnssSatelliteId> {
427 let planning = PredictOptions {
428 light_time: false,
429 ..PredictOptions::default()
430 };
431 sp3.satellites()
432 .iter()
433 .copied()
434 .filter(|sat| sat.system == GnssSystem::Gps)
435 .filter(|sat| {
436 predict(sp3, *sat, RECEIVER, T_RX_J2000_S, planning)
437 .map(|obs| obs.elevation_deg >= 5.0)
438 .unwrap_or(false)
439 })
440 .collect()
441 }
442
443 fn synth_range_rate(sp3: &Sp3, sat: GnssSatelliteId, v_true: [f64; 3], drift: f64) -> f64 {
444 let obs = predict(sp3, sat, RECEIVER, T_RX_J2000_S, PredictOptions::default())
445 .expect("predict synthetic observation");
446 let e_dot_vtrue =
447 obs.los_unit[0] * v_true[0] + obs.los_unit[1] * v_true[1] + obs.los_unit[2] * v_true[2];
448 obs.range_rate_m_s - e_dot_vtrue + C_M_S * drift
449 }
450
451 fn synth_observations(sp3: &Sp3, sats: &[GnssSatelliteId]) -> Vec<VelocityObservation> {
452 sats.iter()
453 .map(|&sat| VelocityObservation {
454 satellite_id: sat,
455 value: synth_range_rate(sp3, sat, V_TRUE, DRIFT_TRUE),
456 carrier_hz: F_L1_HZ,
457 sat_clock_drift_s_s: 0.0,
458 })
459 .collect()
460 }
461
462 #[derive(Debug, Clone, Copy)]
463 struct StaticVelocitySource {
464 state: ObservableState,
465 }
466
467 impl ObservableEphemerisSource for StaticVelocitySource {
468 fn observable_state_at_j2000_s(
469 &self,
470 _sat: GnssSatelliteId,
471 _t_j2000_s: f64,
472 ) -> Result<ObservableState, ObservablesError> {
473 Ok(self.state)
474 }
475 }
476
477 fn static_velocity_source(position_ecef_m: [f64; 3]) -> StaticVelocitySource {
478 StaticVelocitySource {
479 state: ObservableState {
480 position_ecef_m,
481 clock_s: Some(0.0),
482 },
483 }
484 }
485
486 #[test]
487 fn split_epoch_constant_matches_orbis_velocity_fixture() {
488 assert_eq!(
489 j2000_seconds_from_split(2_459_024.5, 0.5).expect("valid split Julian date"),
490 T_RX_J2000_S
491 );
492 }
493
494 #[test]
495 fn range_rate_solve_has_frozen_bits_golden() {
496 let sp3 = sp3_fixture();
497 let sats = visible_gps(&sp3);
498 assert!(sats.len() >= 4);
499 let observations = synth_observations(&sp3, &sats);
500
501 let solution = solve(
502 &sp3,
503 &observations,
504 RECEIVER,
505 T_RX_J2000_S,
506 VelocitySolveOptions::default(),
507 )
508 .expect("solve velocity");
509
510 assert_eq!(
511 solution.velocity_m_s.map(f64::to_bits),
512 [0x4028000000000000, 0xc01c000000000016, 0x4007ffffffffff00]
513 );
514 assert_eq!(solution.speed_m_s.to_bits(), 0x402c6ce322982a37);
515 assert_eq!(solution.clock_drift_s_s.to_bits(), 0x3e112e0be826d2ee);
516 assert_eq!(
517 solution
518 .used_sats
519 .iter()
520 .map(ToString::to_string)
521 .collect::<Vec<_>>(),
522 ["G07", "G08", "G10", "G16", "G18", "G20", "G21", "G26", "G27"]
523 );
524 assert_eq!(
525 solution
526 .residuals_m_s
527 .iter()
528 .map(|(_, residual)| residual.to_bits())
529 .collect::<Vec<_>>(),
530 [
531 0xbd01000000000000,
532 0xbd24000000000000,
533 0x3cfc000000000000,
534 0xbd16000000000000,
535 0xbd1a800000000000,
536 0x3cf0000000000000,
537 0xbd14000000000000,
538 0x3d31800000000000,
539 0x3d18000000000000,
540 ]
541 );
542 }
543
544 #[test]
545 fn doppler_path_has_frozen_bits_with_per_sat_carriers() {
546 let sp3 = sp3_fixture();
547 let sats = visible_gps(&sp3);
548 let range_rate_observations = synth_observations(&sp3, &sats);
549 let doppler_observations: Vec<_> = range_rate_observations
550 .iter()
551 .enumerate()
552 .map(|(idx, obs)| {
553 let k = (idx % 14) as i8 - 7;
554 let carrier_hz =
555 crate::frequencies::rinex_band_frequency_hz(GnssSystem::Glonass, '1', Some(k))
556 .expect("canonical GLONASS G1 channel carrier exists");
557 VelocityObservation {
558 value: range_rate_to_doppler(obs.value, carrier_hz)
559 .expect("valid range-rate conversion"),
560 carrier_hz,
561 ..*obs
562 }
563 })
564 .collect();
565
566 let range_rate = solve(
567 &sp3,
568 &range_rate_observations,
569 RECEIVER,
570 T_RX_J2000_S,
571 VelocitySolveOptions::default(),
572 )
573 .expect("range-rate solve");
574 let doppler = solve(
575 &sp3,
576 &doppler_observations,
577 RECEIVER,
578 T_RX_J2000_S,
579 VelocitySolveOptions {
580 observable: VelocityObservable::Doppler,
581 ..VelocitySolveOptions::default()
582 },
583 )
584 .expect("doppler solve");
585
586 assert_eq!(
587 range_rate.velocity_m_s.map(f64::to_bits),
588 [0x4028000000000000, 0xc01c000000000016, 0x4007ffffffffff00]
589 );
590 assert_eq!(
591 doppler.velocity_m_s.map(f64::to_bits),
592 [0x402800000000000c, 0xc01c00000000000f, 0x4007ffffffffff60]
593 );
594 assert_eq!(doppler.speed_m_s.to_bits(), 0x402c6ce322982a44);
595 assert_eq!(doppler.clock_drift_s_s.to_bits(), 0x3e112e0be826d4b8);
596 assert_eq!(
597 doppler
598 .residuals_m_s
599 .iter()
600 .map(|(_, residual)| residual.to_bits())
601 .collect::<Vec<_>>(),
602 [
603 0x3d24c00000000000,
604 0xbd2b000000000000,
605 0xbd00000000000000,
606 0xbd00000000000000,
607 0xbd0b000000000000,
608 0x3d06000000000000,
609 0x0000000000000000,
610 0x3d40c00000000000,
611 0x3d22000000000000,
612 ]
613 );
614 }
615
616 #[test]
617 fn validates_core_error_cases() {
618 let sp3 = sp3_fixture();
619 let sats = visible_gps(&sp3);
620 let mut observations = synth_observations(&sp3, &sats);
621 let first = observations[0].satellite_id;
622
623 assert_eq!(
624 solve(
625 &sp3,
626 &[],
627 RECEIVER,
628 T_RX_J2000_S,
629 VelocitySolveOptions::default()
630 ),
631 Err(VelocityError::NoObservations)
632 );
633
634 assert_eq!(
635 solve(
636 &sp3,
637 &observations[..3],
638 RECEIVER,
639 T_RX_J2000_S,
640 VelocitySolveOptions::default()
641 ),
642 Err(VelocityError::TooFewSatellites {
643 used: 3,
644 required: 4
645 })
646 );
647
648 observations[1].satellite_id = first;
649 assert_eq!(
650 solve(
651 &sp3,
652 &observations,
653 RECEIVER,
654 T_RX_J2000_S,
655 VelocitySolveOptions::default()
656 ),
657 Err(VelocityError::DuplicateObservation {
658 satellite_id: first
659 })
660 );
661
662 let invalid_carrier = [VelocityObservation {
663 satellite_id: first,
664 value: 1.0,
665 carrier_hz: -1.0,
666 sat_clock_drift_s_s: 0.0,
667 }];
668 assert_eq!(
669 solve(
670 &sp3,
671 &invalid_carrier,
672 RECEIVER,
673 T_RX_J2000_S,
674 VelocitySolveOptions {
675 observable: VelocityObservable::Doppler,
676 ..VelocitySolveOptions::default()
677 }
678 ),
679 Err(VelocityError::InvalidCarrier {
680 satellite_id: first
681 })
682 );
683 }
684
685 #[test]
686 fn rejects_non_finite_velocity_inputs() {
687 let sp3 = sp3_fixture();
688 let sats = visible_gps(&sp3);
689 let mut observations = synth_observations(&sp3, &sats);
690 let first = observations[0].satellite_id;
691
692 observations[0].value = f64::NAN;
693 assert_eq!(
694 solve(
695 &sp3,
696 &observations,
697 RECEIVER,
698 T_RX_J2000_S,
699 VelocitySolveOptions::default()
700 ),
701 Err(VelocityError::InvalidObservation {
702 satellite_id: first
703 })
704 );
705
706 observations[0].value = 0.0;
707 observations[0].sat_clock_drift_s_s = f64::NAN;
708 assert_eq!(
709 solve(
710 &sp3,
711 &observations,
712 RECEIVER,
713 T_RX_J2000_S,
714 VelocitySolveOptions::default()
715 ),
716 Err(VelocityError::InvalidObservation {
717 satellite_id: first
718 })
719 );
720
721 observations[0].sat_clock_drift_s_s = 0.0;
722 let mut bad_receiver = RECEIVER;
723 bad_receiver[0] = f64::NAN;
724 assert_eq!(
725 solve(
726 &sp3,
727 &observations,
728 bad_receiver,
729 T_RX_J2000_S,
730 VelocitySolveOptions::default()
731 ),
732 Err(VelocityError::InvalidReceiverState)
733 );
734
735 assert_eq!(
736 solve(
737 &sp3,
738 &observations,
739 RECEIVER,
740 f64::NAN,
741 VelocitySolveOptions::default()
742 ),
743 Err(VelocityError::InvalidReceiverState)
744 );
745 }
746
747 #[test]
748 fn conversion_helpers_reject_invalid_domains() {
749 assert_eq!(
750 doppler_to_range_rate(f64::NAN, F_L1_HZ),
751 Err(VelocityError::InvalidInput {
752 field: "doppler_hz",
753 reason: "not finite"
754 })
755 );
756 assert_eq!(
757 range_rate_to_doppler(f64::INFINITY, F_L1_HZ),
758 Err(VelocityError::InvalidInput {
759 field: "range_rate_m_s",
760 reason: "not finite"
761 })
762 );
763
764 for carrier_hz in [f64::NAN, f64::INFINITY] {
765 assert_eq!(
766 doppler_to_range_rate(1.0, carrier_hz),
767 Err(VelocityError::InvalidInput {
768 field: "carrier_hz",
769 reason: "not finite"
770 })
771 );
772 assert_eq!(
773 range_rate_to_doppler(1.0, carrier_hz),
774 Err(VelocityError::InvalidInput {
775 field: "carrier_hz",
776 reason: "not finite"
777 })
778 );
779 }
780
781 for carrier_hz in [0.0, -1.0] {
782 assert_eq!(
783 doppler_to_range_rate(1.0, carrier_hz),
784 Err(VelocityError::InvalidInput {
785 field: "carrier_hz",
786 reason: "not positive"
787 })
788 );
789 assert_eq!(
790 range_rate_to_doppler(1.0, carrier_hz),
791 Err(VelocityError::InvalidInput {
792 field: "carrier_hz",
793 reason: "not positive"
794 })
795 );
796 }
797 }
798
799 #[test]
800 fn solve_rejects_non_finite_internal_rows() {
801 let source = static_velocity_source([20_200_000.0, 14_000_000.0, 21_700_000.0]);
802 let observations: Vec<_> = (1..=4)
803 .map(|prn| VelocityObservation {
804 satellite_id: GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid sat"),
805 value: 0.0,
806 carrier_hz: F_L1_HZ,
807 sat_clock_drift_s_s: f64::MAX,
808 })
809 .collect();
810
811 assert_eq!(
812 solve(
813 &source,
814 &observations,
815 [0.0, 0.0, 0.0],
816 646_272_000.0,
817 VelocitySolveOptions {
818 light_time: false,
819 sagnac: false,
820 ..VelocitySolveOptions::default()
821 }
822 ),
823 Err(VelocityError::InvalidInput {
824 field: "velocity row",
825 reason: "out of range",
826 })
827 );
828 }
829}