1use crate::astro::frames::transforms::itrs_to_geodetic_compute;
9use std::f64::consts::PI;
10
11use crate::constants::{
12 C_M_S, DEGREES_PER_CIRCLE, DEGREES_PER_SEMICIRCLE, F_L1_HZ, J2000_JD, KM_TO_M,
13 MICROSECONDS_PER_SECOND, OBSERVABLE_TRANSMIT_TIME_ITERATIONS, OMEGA_E_DOT_RAD_S,
14 SECONDS_PER_DAY,
15};
16use crate::ephemeris::BroadcastEphemeris;
17use crate::estimation::recipe::SagnacRecipe;
18use crate::id::GnssSatelliteId;
19use crate::sp3::Sp3;
20use crate::spp::EphemerisSource;
21use crate::validate;
22use crate::Error;
23
24const FD_HALF_S: f64 = 0.5;
25
26#[derive(Debug, Clone, Copy, PartialEq)]
28pub struct ObservableState {
29 pub position_ecef_m: [f64; 3],
31 pub clock_s: Option<f64>,
33}
34
35pub trait ObservableEphemerisSource {
37 fn observable_state_at_j2000_s(
39 &self,
40 sat: GnssSatelliteId,
41 t_j2000_s: f64,
42 ) -> Result<ObservableState, ObservablesError>;
43}
44
45impl ObservableEphemerisSource for Sp3 {
46 fn observable_state_at_j2000_s(
47 &self,
48 sat: GnssSatelliteId,
49 t_j2000_s: f64,
50 ) -> Result<ObservableState, ObservablesError> {
51 let state = self
52 .position_at_j2000_seconds(sat, t_j2000_s)
53 .map_err(ObservablesError::Ephemeris)?;
54 Ok(ObservableState {
55 position_ecef_m: state.position.as_array(),
56 clock_s: state.clock_s,
57 })
58 }
59}
60
61impl ObservableEphemerisSource for BroadcastEphemeris {
62 fn observable_state_at_j2000_s(
63 &self,
64 sat: GnssSatelliteId,
65 t_j2000_s: f64,
66 ) -> Result<ObservableState, ObservablesError> {
67 let Some((position_ecef_m, clock_s)) =
68 EphemerisSource::position_clock_at_j2000_s(self, sat, t_j2000_s)
69 else {
70 return Err(ObservablesError::NoEphemeris);
71 };
72 Ok(ObservableState {
73 position_ecef_m,
74 clock_s: Some(clock_s),
75 })
76 }
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum ObservablesInputErrorKind {
82 NonFinite,
84 NotPositive,
86 Negative,
88 OutOfRange,
90 Missing,
92 FloatParse,
94 IntParse,
96 InvalidCivilDate,
98 InvalidCivilTime,
100}
101
102impl core::fmt::Display for ObservablesInputErrorKind {
103 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
104 let label = match self {
105 Self::NonFinite => "not finite",
106 Self::NotPositive => "not positive",
107 Self::Negative => "negative",
108 Self::OutOfRange => "out of range",
109 Self::Missing => "missing",
110 Self::FloatParse => "invalid float",
111 Self::IntParse => "invalid integer",
112 Self::InvalidCivilDate => "invalid civil date",
113 Self::InvalidCivilTime => "invalid civil time",
114 };
115 f.write_str(label)
116 }
117}
118
119impl From<&validate::FieldError> for ObservablesInputErrorKind {
120 fn from(error: &validate::FieldError) -> Self {
121 match error {
122 validate::FieldError::Missing { .. } => Self::Missing,
123 validate::FieldError::NonFinite { .. } => Self::NonFinite,
124 validate::FieldError::NotPositive { .. } => Self::NotPositive,
125 validate::FieldError::Negative { .. } => Self::Negative,
126 validate::FieldError::OutOfRange { .. } => Self::OutOfRange,
127 validate::FieldError::FloatParse { .. } => Self::FloatParse,
128 validate::FieldError::IntParse { .. } => Self::IntParse,
129 validate::FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
130 validate::FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
131 }
132 }
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
137pub enum ObservablesError {
138 InvalidInput {
141 field: &'static str,
143 kind: ObservablesInputErrorKind,
145 },
146 NoEphemeris,
148 Ephemeris(Error),
150}
151
152impl core::fmt::Display for ObservablesError {
153 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
154 match self {
155 Self::InvalidInput { field, kind } => {
156 write!(f, "invalid observable input {field}: {kind}")
157 }
158 Self::NoEphemeris => write!(f, "no ephemeris"),
159 Self::Ephemeris(err) => write!(f, "{err}"),
160 }
161 }
162}
163
164impl std::error::Error for ObservablesError {}
165
166#[derive(Debug, Clone, Copy, PartialEq)]
168pub struct PredictOptions {
169 pub carrier_hz: f64,
171 pub light_time: bool,
173 pub sagnac: bool,
175}
176
177impl Default for PredictOptions {
178 fn default() -> Self {
179 Self {
180 carrier_hz: F_L1_HZ,
181 light_time: true,
182 sagnac: true,
183 }
184 }
185}
186
187#[derive(Debug, Clone, Copy, PartialEq)]
189pub struct PredictedObservables {
190 pub geometric_range_m: f64,
192 pub range_rate_m_s: f64,
194 pub doppler_hz: f64,
196 pub sat_clock_s: Option<f64>,
198 pub elevation_deg: f64,
200 pub azimuth_deg: f64,
202 pub transmit_offset_us: i64,
204 pub transmit_time_j2000_s: f64,
206 pub los_unit: [f64; 3],
208 pub sat_pos_ecef_m: [f64; 3],
210 pub sat_velocity_m_s: [f64; 3],
212}
213
214pub fn j2000_seconds_from_split(jd_whole: f64, jd_fraction: f64) -> Result<f64, ObservablesError> {
216 validate::finite(jd_whole, "jd_whole").map_err(map_input_error)?;
217 validate::finite(jd_fraction, "jd_fraction").map_err(map_input_error)?;
218 let days_whole = jd_whole - J2000_JD;
219 validate::finite(
220 days_whole * SECONDS_PER_DAY + jd_fraction * SECONDS_PER_DAY,
221 "j2000_seconds",
222 )
223 .map_err(map_input_error)
224}
225
226pub fn predict(
228 source: &dyn ObservableEphemerisSource,
229 sat: GnssSatelliteId,
230 receiver_ecef_m: [f64; 3],
231 t_rx_j2000_s: f64,
232 options: PredictOptions,
233) -> Result<PredictedObservables, ObservablesError> {
234 validate_predict_inputs(receiver_ecef_m, t_rx_j2000_s, options)?;
235 let solved = solve_transmit_time(source, sat, receiver_ecef_m, t_rx_j2000_s, options)?;
236
237 let dx = solved.sat_rot_ecef_m[0] - receiver_ecef_m[0];
238 let dy = solved.sat_rot_ecef_m[1] - receiver_ecef_m[1];
239 let dz = solved.sat_rot_ecef_m[2] - receiver_ecef_m[2];
240 let range = geometric_range_m([dx, dy, dz])?;
241 let los = [dx / range, dy / range, dz / range];
242
243 let velocity = satellite_velocity(source, sat, solved.transmit_time_j2000_s)?;
244 let velocity_rot = sagnac_rotate(velocity, solved.tau_s, options.sagnac);
245 validate::finite_vec3(velocity_rot, "satellite velocity_m_s").map_err(map_input_error)?;
246 let range_rate = los[0] * velocity_rot[0] + los[1] * velocity_rot[1] + los[2] * velocity_rot[2];
247 validate::finite(range_rate, "range_rate_m_s").map_err(map_input_error)?;
248 let doppler_hz = -range_rate * options.carrier_hz / C_M_S;
249 validate::finite(doppler_hz, "doppler_hz").map_err(map_input_error)?;
250 let (elevation_deg, azimuth_deg) = topocentric(receiver_ecef_m, [dx, dy, dz], range)?;
251
252 Ok(PredictedObservables {
253 geometric_range_m: range,
254 range_rate_m_s: range_rate,
255 doppler_hz,
256 sat_clock_s: solved.state.clock_s,
257 elevation_deg,
258 azimuth_deg,
259 transmit_offset_us: solved.transmit_offset_us,
260 transmit_time_j2000_s: solved.transmit_time_j2000_s,
261 los_unit: los,
262 sat_pos_ecef_m: solved.sat_rot_ecef_m,
263 sat_velocity_m_s: velocity_rot,
264 })
265}
266
267#[derive(Debug, Clone, Copy)]
268struct SolvedTransmitTime {
269 tau_s: f64,
270 transmit_offset_us: i64,
271 transmit_time_j2000_s: f64,
272 state: ObservableState,
273 sat_rot_ecef_m: [f64; 3],
274}
275
276fn solve_transmit_time(
277 source: &dyn ObservableEphemerisSource,
278 sat: GnssSatelliteId,
279 receiver_ecef_m: [f64; 3],
280 t_rx_j2000_s: f64,
281 options: PredictOptions,
282) -> Result<SolvedTransmitTime, ObservablesError> {
283 if !options.light_time {
284 let state = validated_state_at_j2000_s(source, sat, t_rx_j2000_s)?;
285 let sat_rot = sagnac_rotate(state.position_ecef_m, 0.0, options.sagnac);
286 validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
287 return Ok(SolvedTransmitTime {
288 tau_s: 0.0,
289 transmit_offset_us: 0,
290 transmit_time_j2000_s: t_rx_j2000_s,
291 state,
292 sat_rot_ecef_m: sat_rot,
293 });
294 }
295
296 let mut tau = 0.0;
297 for iter in 0..OBSERVABLE_TRANSMIT_TIME_ITERATIONS {
298 let transmit_offset_us = microseconds_from_tau(tau);
299 let t_tx = t_rx_j2000_s - transmit_offset_us as f64 / MICROSECONDS_PER_SECOND;
300 let state = validated_state_at_j2000_s(source, sat, t_tx)?;
301 let sat_rot = sagnac_rotate(state.position_ecef_m, tau, options.sagnac);
302 validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
303 let dx = sat_rot[0] - receiver_ecef_m[0];
304 let dy = sat_rot[1] - receiver_ecef_m[1];
305 let dz = sat_rot[2] - receiver_ecef_m[2];
306 let range = geometric_range_m([dx, dy, dz])?;
307 let new_tau = range / C_M_S;
308
309 if iter + 1 == OBSERVABLE_TRANSMIT_TIME_ITERATIONS {
310 return finalize_transmit_time(source, sat, t_rx_j2000_s, new_tau, options.sagnac);
311 }
312
313 tau = new_tau;
314 }
315
316 unreachable!("fixed transmit-time loop always returns on its last iteration")
317}
318
319fn finalize_transmit_time(
320 source: &dyn ObservableEphemerisSource,
321 sat: GnssSatelliteId,
322 t_rx_j2000_s: f64,
323 tau: f64,
324 sagnac: bool,
325) -> Result<SolvedTransmitTime, ObservablesError> {
326 let transmit_offset_us = microseconds_from_tau(tau);
327 let t_tx = t_rx_j2000_s - transmit_offset_us as f64 / MICROSECONDS_PER_SECOND;
328 validate::finite(t_tx, "transmit_time_j2000_s").map_err(map_input_error)?;
329 let state = validated_state_at_j2000_s(source, sat, t_tx)?;
330 let sat_rot = sagnac_rotate(state.position_ecef_m, tau, sagnac);
331 validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
332 Ok(SolvedTransmitTime {
333 tau_s: tau,
334 transmit_offset_us,
335 transmit_time_j2000_s: t_tx,
336 state,
337 sat_rot_ecef_m: sat_rot,
338 })
339}
340
341fn microseconds_from_tau(tau_s: f64) -> i64 {
342 (tau_s * MICROSECONDS_PER_SECOND).round() as i64
343}
344
345fn satellite_velocity(
346 source: &dyn ObservableEphemerisSource,
347 sat: GnssSatelliteId,
348 t_tx_j2000_s: f64,
349) -> Result<[f64; 3], ObservablesError> {
350 let plus = validated_state_at_j2000_s(source, sat, t_tx_j2000_s + FD_HALF_S)?;
351 let minus = validated_state_at_j2000_s(source, sat, t_tx_j2000_s - FD_HALF_S)?;
352 let denom = 2.0 * FD_HALF_S;
353 let velocity = [
354 (plus.position_ecef_m[0] - minus.position_ecef_m[0]) / denom,
355 (plus.position_ecef_m[1] - minus.position_ecef_m[1]) / denom,
356 (plus.position_ecef_m[2] - minus.position_ecef_m[2]) / denom,
357 ];
358 validate::finite_vec3(velocity, "satellite velocity_m_s").map_err(map_input_error)
359}
360
361fn validate_predict_inputs(
362 receiver_ecef_m: [f64; 3],
363 t_rx_j2000_s: f64,
364 options: PredictOptions,
365) -> Result<(), ObservablesError> {
366 validate::finite_vec3(receiver_ecef_m, "receiver_ecef_m").map_err(map_input_error)?;
367 validate::finite(t_rx_j2000_s, "t_rx_j2000_s").map_err(map_input_error)?;
368 validate::finite_positive(options.carrier_hz, "options.carrier_hz").map_err(map_input_error)?;
369 Ok(())
370}
371
372fn validated_state_at_j2000_s(
373 source: &dyn ObservableEphemerisSource,
374 sat: GnssSatelliteId,
375 t_j2000_s: f64,
376) -> Result<ObservableState, ObservablesError> {
377 let state = source.observable_state_at_j2000_s(sat, t_j2000_s)?;
378 validate_observable_state(&state)?;
379 Ok(state)
380}
381
382fn validate_observable_state(state: &ObservableState) -> Result<(), ObservablesError> {
383 validate::finite_vec3(state.position_ecef_m, "observable state position_ecef_m")
384 .map_err(map_input_error)?;
385 if let Some(clock_s) = state.clock_s {
386 validate::finite(clock_s, "observable state clock_s").map_err(map_input_error)?;
387 }
388 Ok(())
389}
390
391fn geometric_range_m(delta_ecef_m: [f64; 3]) -> Result<f64, ObservablesError> {
392 let range = (delta_ecef_m[0] * delta_ecef_m[0]
393 + delta_ecef_m[1] * delta_ecef_m[1]
394 + delta_ecef_m[2] * delta_ecef_m[2])
395 .sqrt();
396 validate::finite_positive(range, "geometric_range_m").map_err(map_input_error)
397}
398
399fn map_input_error(error: validate::FieldError) -> ObservablesError {
400 ObservablesError::InvalidInput {
401 field: error.field(),
402 kind: ObservablesInputErrorKind::from(&error),
403 }
404}
405
406fn sagnac_rotate(pos: [f64; 3], tau_s: f64, apply: bool) -> [f64; 3] {
407 let sagnac = if apply {
408 SagnacRecipe::ClosedFormZRotation
409 } else {
410 SagnacRecipe::Off
411 };
412 crate::estimation::substrate::range::rotate_transmit_satellite(
413 sagnac,
414 pos,
415 tau_s,
416 OMEGA_E_DOT_RAD_S,
417 )
418}
419
420fn topocentric(
421 receiver_ecef_m: [f64; 3],
422 delta_ecef_m: [f64; 3],
423 range_m: f64,
424) -> Result<(f64, f64), ObservablesError> {
425 let (lat_deg, lon_deg, _height_km) = itrs_to_geodetic_compute(
426 receiver_ecef_m[0] / KM_TO_M,
427 receiver_ecef_m[1] / KM_TO_M,
428 receiver_ecef_m[2] / KM_TO_M,
429 )
430 .map_err(|_| ObservablesError::InvalidInput {
431 field: "receiver_ecef_m",
432 kind: ObservablesInputErrorKind::OutOfRange,
433 })?;
434 let lat = lat_deg * PI / DEGREES_PER_SEMICIRCLE;
436 let lon = lon_deg * PI / DEGREES_PER_SEMICIRCLE;
437
438 let sl = lat.sin();
439 let cl = lat.cos();
440 let so = lon.sin();
441 let co = lon.cos();
442
443 let dx = delta_ecef_m[0];
444 let dy = delta_ecef_m[1];
445 let dz = delta_ecef_m[2];
446
447 let e = -so * dx + co * dy;
448 let n = -sl * co * dx - sl * so * dy + cl * dz;
449 let u = cl * co * dx + cl * so * dy + sl * dz;
450
451 let mut azimuth_deg = e.atan2(n) * DEGREES_PER_SEMICIRCLE / PI;
453 if azimuth_deg < 0.0 {
454 azimuth_deg += DEGREES_PER_CIRCLE;
455 }
456 let elevation_deg = (u / range_m).asin() * DEGREES_PER_SEMICIRCLE / PI;
457
458 validate::finite(elevation_deg, "elevation_deg").map_err(map_input_error)?;
459 validate::finite(azimuth_deg, "azimuth_deg").map_err(map_input_error)?;
460 Ok((elevation_deg, azimuth_deg))
461}
462
463#[cfg(all(test, sidereon_repo_tests))]
464mod tests {
465 use super::*;
466 use crate::{GnssSatelliteId, GnssSystem};
467
468 #[derive(Debug, Clone, Copy)]
469 struct StaticSource {
470 state: ObservableState,
471 }
472
473 impl ObservableEphemerisSource for StaticSource {
474 fn observable_state_at_j2000_s(
475 &self,
476 _sat: GnssSatelliteId,
477 _t_j2000_s: f64,
478 ) -> Result<ObservableState, ObservablesError> {
479 Ok(self.state)
480 }
481 }
482
483 fn sp3_fixture() -> Sp3 {
484 let path = concat!(
485 env!("CARGO_MANIFEST_DIR"),
486 "/tests/fixtures/sp3/GRG0MGXFIN_20201760000_01D_15M_ORB.SP3"
487 );
488 let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("read SP3 fixture {path}: {e}"));
489 Sp3::parse(&bytes).expect("parse SP3 fixture")
490 }
491
492 fn static_source(position_ecef_m: [f64; 3]) -> StaticSource {
493 StaticSource {
494 state: ObservableState {
495 position_ecef_m,
496 clock_s: Some(0.0),
497 },
498 }
499 }
500
501 fn no_light_time_options() -> PredictOptions {
502 PredictOptions {
503 carrier_hz: F_L1_HZ,
504 light_time: false,
505 sagnac: true,
506 }
507 }
508
509 fn assert_invalid_observables_input(
510 err: ObservablesError,
511 field: &'static str,
512 kind: ObservablesInputErrorKind,
513 ) {
514 match err {
515 ObservablesError::InvalidInput {
516 field: got_field,
517 kind: got_kind,
518 } => {
519 assert_eq!(got_field, field);
520 assert_eq!(got_kind, kind);
521 }
522 other => panic!("expected InvalidInput({field}, {kind:?}), got {other:?}"),
523 }
524 }
525
526 #[test]
527 fn split_julian_to_j2000_seconds_matches_orbis_time() {
528 let t = j2000_seconds_from_split(2_459_024.5, 0.5).expect("valid split Julian date");
529 assert_eq!(t, 646_272_000.0);
530 }
531
532 #[test]
533 fn split_julian_to_j2000_seconds_rejects_non_finite_parts() {
534 for (jd_whole, jd_fraction, field) in [
535 (f64::NAN, 0.5, "jd_whole"),
536 (f64::INFINITY, 0.5, "jd_whole"),
537 (2_459_024.5, f64::NAN, "jd_fraction"),
538 (2_459_024.5, f64::NEG_INFINITY, "jd_fraction"),
539 ] {
540 let err = j2000_seconds_from_split(jd_whole, jd_fraction)
541 .expect_err("non-finite split Julian date part must fail");
542 assert_invalid_observables_input(err, field, ObservablesInputErrorKind::NonFinite);
543 }
544 }
545
546 #[test]
547 fn sp3_predict_reference_case() {
548 let sp3 = sp3_fixture();
549 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
550 let rx = [3_512_900.0, 780_500.0, 5_248_700.0];
551 let obs = predict(&sp3, sat, rx, 646_272_000.0, PredictOptions::default())
552 .expect("predict observables");
553
554 assert_eq!(obs.geometric_range_m.to_bits(), 0x4173cf438ba57358);
555 assert_eq!(obs.range_rate_m_s.to_bits(), 0x402d7dd36f6b8980);
556 assert_eq!(obs.doppler_hz.to_bits(), 0xc0535f534ba7c77d);
557 assert_eq!(obs.sat_clock_s.unwrap().to_bits(), 0x3ef04d2d8279460c);
558 assert_eq!(obs.elevation_deg.to_bits(), 0x4054590eed870f52);
559 assert_eq!(obs.azimuth_deg.to_bits(), 0x40645ff5a090a131);
560 assert_eq!(obs.transmit_offset_us, 69_288);
561 assert_eq!(obs.transmit_time_j2000_s.to_bits(), 0x41c342a9fff72192);
562 assert_eq!(
563 obs.los_unit.map(f64::to_bits),
564 [0x3fe4c70da9fa70dd, 0x3fc834429adb2bae, 0x3fe792a4f57fdcb1,]
565 );
566 assert_eq!(
567 obs.sat_pos_ecef_m.map(f64::to_bits),
568 [0x41703667d8c0eb8f, 0x4151f601b1d775f3, 0x4173992c0ec03dcd,]
569 );
570 assert_eq!(
571 obs.sat_velocity_m_s.map(f64::to_bits),
572 [0xc09c17d81e540ab6, 0x409a192982abbeb7, 0x40926013f2ae8000,]
573 );
574 }
575
576 #[test]
577 fn predict_rejects_invalid_entry_inputs() {
578 let source = static_source([20_200_000.0, 14_000_000.0, 21_700_000.0]);
579 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
580
581 let err = predict(
582 &source,
583 sat,
584 [f64::NAN, 0.0, 0.0],
585 646_272_000.0,
586 no_light_time_options(),
587 )
588 .expect_err("non-finite receiver position must fail");
589 assert_invalid_observables_input(
590 err,
591 "receiver_ecef_m",
592 ObservablesInputErrorKind::NonFinite,
593 );
594
595 let err = predict(
596 &source,
597 sat,
598 [0.0, 0.0, 0.0],
599 f64::INFINITY,
600 no_light_time_options(),
601 )
602 .expect_err("non-finite receive time must fail");
603 assert_invalid_observables_input(err, "t_rx_j2000_s", ObservablesInputErrorKind::NonFinite);
604
605 let mut options = no_light_time_options();
606 options.carrier_hz = 0.0;
607 let err = predict(&source, sat, [0.0, 0.0, 0.0], 646_272_000.0, options)
608 .expect_err("non-positive carrier must fail");
609 assert_invalid_observables_input(
610 err,
611 "options.carrier_hz",
612 ObservablesInputErrorKind::NotPositive,
613 );
614 }
615
616 #[test]
617 fn predict_rejects_invalid_source_state_and_zero_range() {
618 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
619
620 let source = static_source([f64::NAN, 14_000_000.0, 21_700_000.0]);
621 let err = predict(
622 &source,
623 sat,
624 [0.0, 0.0, 0.0],
625 646_272_000.0,
626 no_light_time_options(),
627 )
628 .expect_err("non-finite ephemeris position must fail");
629 assert_invalid_observables_input(
630 err,
631 "observable state position_ecef_m",
632 ObservablesInputErrorKind::NonFinite,
633 );
634
635 let source = static_source([1_000.0, 2_000.0, 3_000.0]);
636 let err = predict(
637 &source,
638 sat,
639 [1_000.0, 2_000.0, 3_000.0],
640 646_272_000.0,
641 no_light_time_options(),
642 )
643 .expect_err("zero geometric range must fail");
644 assert_invalid_observables_input(
645 err,
646 "geometric_range_m",
647 ObservablesInputErrorKind::NotPositive,
648 );
649 }
650
651 #[test]
652 fn topocentric_rejects_invalid_receiver_geodetic_conversion() {
653 let err = topocentric([f64::MAX, 0.0, 0.0], [1.0, 0.0, 0.0], 1.0)
654 .expect_err("invalid receiver geodetic conversion must fail");
655
656 assert_invalid_observables_input(
657 err,
658 "receiver_ecef_m",
659 ObservablesInputErrorKind::OutOfRange,
660 );
661 }
662}