1use crate::astro::angles::{phase_angle, AngleError};
20use crate::astro::bodies::sun_moon::{sun_moon_ecef, sun_moon_eci_at, SunMoonError};
21use crate::astro::constants::astro::GM_SUN_KM3_S2;
22use crate::astro::constants::earth::OMEGA_E_DOT_RAD_S;
23use crate::astro::constants::physics::SPEED_OF_LIGHT_M_S;
24use crate::astro::constants::time::{J2000_JD, SECONDS_PER_DAY};
25use crate::astro::constants::units::M_PER_KM;
26use crate::astro::frames::nutation::{
27 build_skyfield_nutation_matrix, skyfield_iau2000a_radians, skyfield_mean_obliquity_radians,
28};
29use crate::astro::frames::transforms::{
30 gcrs_to_itrs_matrix, gcrs_to_itrs_matrix_with_polar_motion, gcrs_to_true_of_date_matrix,
31 geodetic_to_itrs, greenwich_apparent_sidereal_time_radians, itrs_to_gcrs_compute,
32 itrs_to_gcrs_compute_with_polar_motion, itrs_to_gcrs_matrix,
33 itrs_to_gcrs_matrix_with_polar_motion, itrs_to_topocentric, mat3_vec3_mul_unchecked,
34 FrameTransformError, GeodeticStationKm, PolarMotion,
35};
36use crate::astro::math::vec3::{add3, dot3, norm3, scale3, sub3, unit3};
37use crate::astro::passes::UtcInstant;
38use crate::astro::spk::{Spk, SpkError, SpkState};
39use crate::astro::time::scales::TimeScales;
40
41const C_KM_S: f64 = SPEED_OF_LIGHT_M_S / M_PER_KM;
42const LIGHT_TIME_TOLERANCE_S: f64 = 1.0e-9;
43const LIGHT_TIME_REFINEMENTS: usize = 3;
44const SPK_FRAME_J2000: i32 = 1;
45const NAIF_SSB: i32 = 0;
46const NAIF_SUN: i32 = 10;
47const NAIF_MOON: i32 = 301;
48const NAIF_EARTH: i32 = 399;
49const SOLAR_DEFLECTION_DENOM_FLOOR: f64 = 1.0e-6;
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
53pub enum BodyObservationError {
54 #[error("Sun/Moon ephemeris failed: {0}")]
57 Ephemeris(#[from] SunMoonError),
58 #[error("topocentric reduction failed: {0}")]
60 FrameTransform(#[from] FrameTransformError),
61 #[error("phase-angle geometry failed: {0}")]
63 Angle(#[from] AngleError),
64}
65
66#[derive(Debug, Clone, Copy, PartialEq)]
68pub struct BodyAzEl {
69 pub azimuth_deg: f64,
71 pub elevation_deg: f64,
73 pub range_km: f64,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq)]
79pub struct MoonIllumination {
80 pub illuminated_fraction: f64,
82 pub phase_angle_deg: f64,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq)]
88pub struct Equatorial {
89 pub right_ascension_deg: f64,
91 pub right_ascension_hours: f64,
93 pub declination_deg: f64,
95 pub distance_km: f64,
97}
98
99#[derive(Debug, Clone, Copy, PartialEq)]
101pub struct Horizontal {
102 pub azimuth_deg: f64,
104 pub elevation_deg: f64,
106 pub range_km: f64,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq)]
112pub struct Ecliptic {
113 pub longitude_deg: f64,
115 pub latitude_deg: f64,
117 pub distance_km: f64,
119}
120
121#[derive(Debug, Clone, Copy, PartialEq)]
123pub struct Observation {
124 pub astrometric: Equatorial,
129 pub apparent_icrs: Equatorial,
131 pub apparent: Equatorial,
133 pub horizontal: Horizontal,
135 pub hour_angle_deg: f64,
137 pub hour_angle_hours: f64,
139 pub ecliptic: Ecliptic,
141 pub reduced: bool,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq)]
147pub struct Refraction {
148 pub pressure_mbar: f64,
150 pub temperature_c: f64,
152}
153
154#[derive(Debug, Clone, Copy, PartialEq)]
156pub struct ObserveOptions {
157 pub polar_motion: Option<PolarMotion>,
159 pub refraction: Option<Refraction>,
161 pub deflection: bool,
163 pub aberration: bool,
165}
166
167impl Default for ObserveOptions {
168 fn default() -> Self {
169 Self {
170 polar_motion: None,
171 refraction: None,
172 deflection: true,
173 aberration: true,
174 }
175 }
176}
177
178#[derive(Debug, Clone, Copy)]
180pub enum Target<'a> {
181 Spk { kernel: &'a Spk, naif_id: i32 },
183 Sun,
185 Moon,
187 BarycentricState {
189 kernel: &'a Spk,
190 position_km: [f64; 3],
191 velocity_km_s: [f64; 3],
192 },
193}
194
195#[derive(Debug, Clone, thiserror::Error)]
197pub enum ObserveError {
198 #[error("SPK state failed: {0}")]
200 Spk(#[from] SpkError),
201 #[error("frame transform failed: {0}")]
203 FrameTransform(#[from] FrameTransformError),
204 #[error("Sun/Moon ephemeris failed: {0}")]
206 SunMoon(#[from] SunMoonError),
207 #[error("angle geometry failed: {0}")]
209 Angle(#[from] AngleError),
210 #[error("unsupported SPK reference frame {frame}")]
212 UnsupportedSpkFrame { frame: i32 },
213 #[error("non-finite observation input or intermediate")]
215 NonFinite,
216 #[error("degenerate observation geometry")]
218 DegenerateGeometry,
219}
220
221pub fn observe(
223 station: &GeodeticStationKm,
224 time: UtcInstant,
225 target: Target<'_>,
226 options: ObserveOptions,
227) -> Result<Observation, ObserveError> {
228 observe_with_time_scales(station, &time.time_scales(), target, options)
229}
230
231pub fn observe_spk_body(
233 station: &GeodeticStationKm,
234 time: UtcInstant,
235 kernel: &Spk,
236 naif_id: i32,
237) -> Result<Observation, ObserveError> {
238 observe(
239 station,
240 time,
241 Target::Spk { kernel, naif_id },
242 ObserveOptions::default(),
243 )
244}
245
246pub(crate) fn apparent_geocentric_spk_true_of_date_m(
249 target_naif: i32,
250 ts: &TimeScales,
251 kernel: &Spk,
252) -> Result<[f64; 3], ObserveError> {
253 let et = (ts.jd_tdb - J2000_JD) * SECONDS_PER_DAY;
254 ensure_finite(et)?;
255
256 let earth = spk_state_j2000(kernel, NAIF_EARTH, NAIF_SSB, et)?;
257 let v_earth_km_s = spk_velocity_j2000(kernel, NAIF_EARTH, NAIF_SSB, et, earth)?;
258 let light_time = solve_spk_light_time(kernel, target_naif, et, earth.position_km)?;
259 let u_astro = unit_checked(light_time.rho_vec_km)?;
260 let u_deflected = if target_naif == NAIF_SUN {
261 u_astro
262 } else {
263 apply_solar_deflection(
264 kernel,
265 light_time.et_emit,
266 earth.position_km,
267 light_time.target_position_km,
268 u_astro,
269 )?
270 };
271 let u_app = apply_aberration(u_deflected, v_earth_km_s)?;
272
273 let tod = gcrs_to_true_of_date_matrix(ts)?;
274 let true_km = mat3_vec3_mul_unchecked(&tod, &scale3(u_app, light_time.distance_km));
275 validate_vec3(true_km)?;
276 Ok(scale_km_to_m(true_km))
277}
278
279pub(crate) fn apparent_geocentric_analytic_true_of_date_m(
282 target_naif: i32,
283 ts: &TimeScales,
284) -> Result<[f64; 3], ObserveError> {
285 let eci = sun_moon_eci_at(ts)?;
286 let mean_m = match target_naif {
287 NAIF_SUN => eci.sun,
288 NAIF_MOON => eci.moon,
289 _ => return Err(ObserveError::DegenerateGeometry),
290 };
291 let nutation = nutation_matrix(ts)?;
292 let true_m = mat3_vec3_mul_unchecked(&nutation, &mean_m);
293 validate_vec3(true_m)?;
294 Ok(true_m)
295}
296
297pub fn observe_with_time_scales(
299 station: &GeodeticStationKm,
300 ts: &TimeScales,
301 target: Target<'_>,
302 options: ObserveOptions,
303) -> Result<Observation, ObserveError> {
304 match target {
305 Target::Sun => observe_reduced_sun_moon(station, ts, ReducedBody::Sun, options),
306 Target::Moon => observe_reduced_sun_moon(station, ts, ReducedBody::Moon, options),
307 Target::Spk { kernel, naif_id } => {
308 observe_full_chain(station, ts, FullTarget::Spk { kernel, naif_id }, options)
309 }
310 Target::BarycentricState {
311 kernel,
312 position_km,
313 velocity_km_s,
314 } => observe_full_chain(
315 station,
316 ts,
317 FullTarget::BarycentricState {
318 kernel,
319 position_km,
320 velocity_km_s,
321 },
322 options,
323 ),
324 }
325}
326
327pub fn sun_az_el(
334 station: &GeodeticStationKm,
335 time: UtcInstant,
336) -> Result<BodyAzEl, BodyObservationError> {
337 let sun_ecef_m = sun_moon_ecef(&time.time_scales())?.sun;
338 body_az_el(station, sun_ecef_m)
339}
340
341pub fn moon_az_el(
348 station: &GeodeticStationKm,
349 time: UtcInstant,
350) -> Result<BodyAzEl, BodyObservationError> {
351 let moon_ecef_m = sun_moon_ecef(&time.time_scales())?.moon;
352 body_az_el(station, moon_ecef_m)
353}
354
355fn body_az_el(
356 station: &GeodeticStationKm,
357 body_ecef_m: [f64; 3],
358) -> Result<BodyAzEl, BodyObservationError> {
359 let body_ecef_km = [
360 body_ecef_m[0] / M_PER_KM,
361 body_ecef_m[1] / M_PER_KM,
362 body_ecef_m[2] / M_PER_KM,
363 ];
364 let (azimuth_deg, elevation_deg, range_km) = itrs_to_topocentric(body_ecef_km, station)?;
365 Ok(BodyAzEl {
366 azimuth_deg,
367 elevation_deg,
368 range_km,
369 })
370}
371
372pub fn moon_illumination(
382 station: &GeodeticStationKm,
383 time: UtcInstant,
384) -> Result<MoonIllumination, BodyObservationError> {
385 let sun_moon = sun_moon_ecef(&time.time_scales())?;
386 let sun_km = scale_m_to_km(sun_moon.sun);
387 let moon_km = scale_m_to_km(sun_moon.moon);
388 let (stn_x, stn_y, stn_z) = geodetic_to_itrs(
389 station.latitude_deg,
390 station.longitude_deg,
391 station.altitude_km,
392 )?;
393 let observer_km = [stn_x, stn_y, stn_z];
394
395 let phase_angle_deg = phase_angle(moon_km, sun_km, observer_km)?;
396 let illuminated_fraction = (1.0 + phase_angle_deg.to_radians().cos()) / 2.0;
397 Ok(MoonIllumination {
398 illuminated_fraction,
399 phase_angle_deg,
400 })
401}
402
403fn scale_m_to_km(v: [f64; 3]) -> [f64; 3] {
404 [v[0] / M_PER_KM, v[1] / M_PER_KM, v[2] / M_PER_KM]
405}
406
407fn scale_km_to_m(v: [f64; 3]) -> [f64; 3] {
408 [v[0] * M_PER_KM, v[1] * M_PER_KM, v[2] * M_PER_KM]
409}
410
411#[derive(Debug, Clone, Copy)]
412enum FullTarget<'a> {
413 Spk {
414 kernel: &'a Spk,
415 naif_id: i32,
416 },
417 BarycentricState {
418 kernel: &'a Spk,
419 position_km: [f64; 3],
420 velocity_km_s: [f64; 3],
421 },
422}
423
424impl<'a> FullTarget<'a> {
425 fn kernel(self) -> &'a Spk {
426 match self {
427 FullTarget::Spk { kernel, .. } | FullTarget::BarycentricState { kernel, .. } => kernel,
428 }
429 }
430
431 fn is_spk_sun(self) -> bool {
432 matches!(
433 self,
434 FullTarget::Spk {
435 naif_id: NAIF_SUN,
436 ..
437 }
438 )
439 }
440}
441
442#[derive(Debug, Clone, Copy)]
443enum ReducedBody {
444 Sun,
445 Moon,
446}
447
448#[derive(Debug, Clone, Copy)]
449struct ObserverBarycentric {
450 r_geo_km: [f64; 3],
451 r_bary_km: [f64; 3],
452 v_bary_km_s: [f64; 3],
453}
454
455#[derive(Debug, Clone, Copy)]
456struct LightTimeSolution {
457 target_position_km: [f64; 3],
458 rho_vec_km: [f64; 3],
459 distance_km: f64,
460 et_emit: f64,
461 #[cfg(test)]
462 residual_s: f64,
463 #[cfg(test)]
464 iterations: usize,
465}
466
467fn observe_full_chain(
468 station: &GeodeticStationKm,
469 ts: &TimeScales,
470 target: FullTarget<'_>,
471 options: ObserveOptions,
472) -> Result<Observation, ObserveError> {
473 validate_refraction(options.refraction)?;
474 let et = (ts.jd_tdb - J2000_JD) * SECONDS_PER_DAY;
475 ensure_finite(et)?;
476
477 let observer = observer_barycentric(station, ts, target.kernel(), et, options.polar_motion)?;
478 let light_time = solve_light_time(target, et, observer.r_bary_km)?;
479 let u_astro = unit_checked(light_time.rho_vec_km)?;
480 let astrometric = equatorial_from_unit(u_astro, light_time.distance_km);
481
482 let u_deflected = if options.deflection && !target.is_spk_sun() {
483 apply_solar_deflection(
484 target.kernel(),
485 light_time.et_emit,
486 observer.r_bary_km,
487 light_time.target_position_km,
488 u_astro,
489 )?
490 } else {
491 u_astro
492 };
493 let u_app = if options.aberration {
494 apply_aberration(u_deflected, observer.v_bary_km_s)?
495 } else {
496 u_deflected
497 };
498 let apparent_icrs = equatorial_from_unit(u_app, light_time.distance_km);
499
500 let tod = gcrs_to_true_of_date_matrix(ts)?;
501 let u_tod = unit_checked(mat3_vec3_mul_unchecked(&tod, &u_app))?;
502 let apparent = equatorial_from_unit(u_tod, light_time.distance_km);
503 let horizontal = apparent_horizontal(
504 station,
505 ts,
506 observer.r_geo_km,
507 u_app,
508 light_time.distance_km,
509 options,
510 )?;
511 let (hour_angle_deg, hour_angle_hours) = hour_angle(station, ts, apparent)?;
512 let ecliptic = ecliptic_from_true_equatorial(ts, u_tod, light_time.distance_km)?;
513
514 Ok(Observation {
515 astrometric,
516 apparent_icrs,
517 apparent,
518 horizontal,
519 hour_angle_deg,
520 hour_angle_hours,
521 ecliptic,
522 reduced: false,
523 })
524}
525
526fn observe_reduced_sun_moon(
527 station: &GeodeticStationKm,
528 ts: &TimeScales,
529 body: ReducedBody,
530 options: ObserveOptions,
531) -> Result<Observation, ObserveError> {
532 validate_refraction(options.refraction)?;
533
534 let eci = sun_moon_eci_at(ts)?;
535 let mean_m = match body {
536 ReducedBody::Sun => eci.sun,
537 ReducedBody::Moon => eci.moon,
538 };
539 let mean_km = scale_m_to_km(mean_m);
540 let astrometric = equatorial_from_vector(mean_km)?;
541 let apparent_icrs = astrometric;
542
543 let nutation = nutation_matrix(ts)?;
544 let true_m = mat3_vec3_mul_unchecked(&nutation, &mean_m);
545 let true_km = scale_m_to_km(true_m);
546 let u_tod = unit_checked(true_km)?;
547 let apparent = equatorial_from_unit(u_tod, norm_checked(true_km)?);
548
549 let ecef = sun_moon_ecef(ts)?;
550 let body_ecef_km = match body {
551 ReducedBody::Sun => scale_m_to_km(ecef.sun),
552 ReducedBody::Moon => scale_m_to_km(ecef.moon),
553 };
554 let (azimuth_deg, elevation_deg, range_km) = itrs_to_topocentric(body_ecef_km, station)?;
555 let horizontal = Horizontal {
556 azimuth_deg,
557 elevation_deg: apply_refraction(elevation_deg, options.refraction)?,
558 range_km,
559 };
560 let (hour_angle_deg, hour_angle_hours) = hour_angle(station, ts, apparent)?;
561 let ecliptic = ecliptic_from_true_equatorial(ts, u_tod, apparent.distance_km)?;
562
563 Ok(Observation {
564 astrometric,
565 apparent_icrs,
566 apparent,
567 horizontal,
568 hour_angle_deg,
569 hour_angle_hours,
570 ecliptic,
571 reduced: true,
572 })
573}
574
575fn observer_barycentric(
576 station: &GeodeticStationKm,
577 ts: &TimeScales,
578 kernel: &Spk,
579 et: f64,
580 polar_motion: Option<PolarMotion>,
581) -> Result<ObserverBarycentric, ObserveError> {
582 let (x, y, z) = geodetic_to_itrs(
583 station.latitude_deg,
584 station.longitude_deg,
585 station.altitude_km,
586 )?;
587 let r_itrs = [x, y, z];
588 let r_geo_tuple = match polar_motion {
589 Some(pole) => itrs_to_gcrs_compute_with_polar_motion(x, y, z, ts, pole)?,
590 None => itrs_to_gcrs_compute(x, y, z, ts)?,
591 };
592 let r_geo_km = [r_geo_tuple.0, r_geo_tuple.1, r_geo_tuple.2];
593
594 let v_itrs = [
595 -OMEGA_E_DOT_RAD_S * r_itrs[1],
596 OMEGA_E_DOT_RAD_S * r_itrs[0],
597 0.0,
598 ];
599 let itrs_to_gcrs = match polar_motion {
600 Some(pole) => itrs_to_gcrs_matrix_with_polar_motion(ts, pole)?,
601 None => itrs_to_gcrs_matrix(ts)?,
602 };
603 let v_geo_km_s = mat3_vec3_mul_unchecked(&itrs_to_gcrs, &v_itrs);
604 validate_vec3(v_geo_km_s)?;
605
606 let earth = spk_state_j2000(kernel, NAIF_EARTH, NAIF_SSB, et)?;
607 let v_earth_km_s = spk_velocity_j2000(kernel, NAIF_EARTH, NAIF_SSB, et, earth)?;
608 Ok(ObserverBarycentric {
609 r_geo_km,
610 r_bary_km: add3(earth.position_km, r_geo_km),
611 v_bary_km_s: add3(v_earth_km_s, v_geo_km_s),
612 })
613}
614
615fn solve_light_time(
616 target: FullTarget<'_>,
617 et: f64,
618 r_obs_bary_km: [f64; 3],
619) -> Result<LightTimeSolution, ObserveError> {
620 match target {
621 FullTarget::Spk { kernel, naif_id } => {
622 solve_spk_light_time(kernel, naif_id, et, r_obs_bary_km)
623 }
624 FullTarget::BarycentricState {
625 position_km,
626 velocity_km_s,
627 ..
628 } => solve_supplied_state_light_time(position_km, velocity_km_s, et, r_obs_bary_km),
629 }
630}
631
632fn solve_spk_light_time(
633 kernel: &Spk,
634 naif_id: i32,
635 et: f64,
636 r_obs_bary_km: [f64; 3],
637) -> Result<LightTimeSolution, ObserveError> {
638 let initial = spk_state_j2000(kernel, naif_id, NAIF_SSB, et)?.position_km;
639 let mut tau_s = norm_checked(sub3(initial, r_obs_bary_km))? / C_KM_S;
640 let mut target_position_km = initial;
641 let mut rho_vec_km = sub3(target_position_km, r_obs_bary_km);
642 let mut et_emit = et - tau_s;
643 #[cfg(test)]
644 let mut residual_s = f64::INFINITY;
645 #[cfg(test)]
646 let mut iterations = 0_usize;
647
648 for _ in 1..=LIGHT_TIME_REFINEMENTS {
649 et_emit = et - tau_s;
650 target_position_km = spk_state_j2000(kernel, naif_id, NAIF_SSB, et_emit)?.position_km;
651 rho_vec_km = sub3(target_position_km, r_obs_bary_km);
652 let next_tau_s = norm_checked(rho_vec_km)? / C_KM_S;
653 #[cfg(test)]
654 {
655 residual_s = (next_tau_s - tau_s).abs();
656 iterations += 1;
657 }
658 let converged = (next_tau_s - tau_s).abs() < LIGHT_TIME_TOLERANCE_S;
659 tau_s = next_tau_s;
660 if converged {
661 break;
662 }
663 }
664
665 Ok(LightTimeSolution {
666 target_position_km,
667 rho_vec_km,
668 distance_km: norm_checked(rho_vec_km)?,
669 et_emit,
670 #[cfg(test)]
671 residual_s,
672 #[cfg(test)]
673 iterations,
674 })
675}
676
677fn solve_supplied_state_light_time(
678 position_km: [f64; 3],
679 velocity_km_s: [f64; 3],
680 et: f64,
681 r_obs_bary_km: [f64; 3],
682) -> Result<LightTimeSolution, ObserveError> {
683 validate_vec3(position_km)?;
684 validate_vec3(velocity_km_s)?;
685
686 let mut tau_s = norm_checked(sub3(position_km, r_obs_bary_km))? / C_KM_S;
687 let mut target_position_km = position_km;
688 let mut rho_vec_km = sub3(target_position_km, r_obs_bary_km);
689 let mut et_emit = et - tau_s;
690 #[cfg(test)]
691 let mut residual_s = f64::INFINITY;
692 #[cfg(test)]
693 let mut iterations = 0_usize;
694
695 for _ in 1..=LIGHT_TIME_REFINEMENTS {
696 et_emit = et - tau_s;
697 target_position_km = sub3(position_km, scale3(velocity_km_s, tau_s));
698 rho_vec_km = sub3(target_position_km, r_obs_bary_km);
699 let next_tau_s = norm_checked(rho_vec_km)? / C_KM_S;
700 #[cfg(test)]
701 {
702 residual_s = (next_tau_s - tau_s).abs();
703 iterations += 1;
704 }
705 let converged = (next_tau_s - tau_s).abs() < LIGHT_TIME_TOLERANCE_S;
706 tau_s = next_tau_s;
707 if converged {
708 break;
709 }
710 }
711
712 Ok(LightTimeSolution {
713 target_position_km,
714 rho_vec_km,
715 distance_km: norm_checked(rho_vec_km)?,
716 et_emit,
717 #[cfg(test)]
718 residual_s,
719 #[cfg(test)]
720 iterations,
721 })
722}
723
724fn spk_state_j2000(
725 kernel: &Spk,
726 target: i32,
727 center: i32,
728 et: f64,
729) -> Result<SpkState, ObserveError> {
730 let state = kernel.spk_state(target, center, et)?;
731 if state.frame != SPK_FRAME_J2000 {
732 return Err(ObserveError::UnsupportedSpkFrame { frame: state.frame });
733 }
734 validate_vec3(state.position_km)?;
735 if let Some(velocity) = state.velocity_km_s {
736 validate_vec3(velocity)?;
737 }
738 Ok(state)
739}
740
741fn spk_velocity_j2000(
742 kernel: &Spk,
743 target: i32,
744 center: i32,
745 et: f64,
746 state: SpkState,
747) -> Result<[f64; 3], ObserveError> {
748 if let Some(velocity) = state.velocity_km_s {
749 return Ok(velocity);
750 }
751
752 let dt = 1.0;
753 let before = spk_state_j2000(kernel, target, center, et - dt);
754 let after = spk_state_j2000(kernel, target, center, et + dt);
755 let velocity = match (before, after) {
756 (Ok(before), Ok(after)) => scale3(sub3(after.position_km, before.position_km), 0.5 / dt),
757 (Ok(before), Err(_)) => scale3(sub3(state.position_km, before.position_km), 1.0 / dt),
758 (Err(_), Ok(after)) => scale3(sub3(after.position_km, state.position_km), 1.0 / dt),
759 (Err(error), Err(_)) => return Err(error),
760 };
761 validate_vec3(velocity)?;
762 Ok(velocity)
763}
764
765fn apply_solar_deflection(
766 kernel: &Spk,
767 et_emit: f64,
768 r_obs_bary_km: [f64; 3],
769 r_tgt_bary_km: [f64; 3],
770 p: [f64; 3],
771) -> Result<[f64; 3], ObserveError> {
772 let r_sun_bary_km = spk_state_j2000(kernel, NAIF_SUN, NAIF_SSB, et_emit)?.position_km;
773 let obs_minus_sun = sub3(r_obs_bary_km, r_sun_bary_km);
774 let target_minus_sun = sub3(r_tgt_bary_km, r_sun_bary_km);
775 let e = unit_checked(obs_minus_sun)?;
776 let q = unit_checked(target_minus_sun)?;
777 let d_sun_km = norm_checked(obs_minus_sun)?;
778 let g1 = 2.0 * GM_SUN_KM3_S2 / (C_KM_S * C_KM_S * d_sun_km);
779 let denom = (1.0 + dot3(q, e)).max(SOLAR_DEFLECTION_DENOM_FLOOR);
780 let correction = scale3(
781 sub3(scale3(e, dot3(p, q)), scale3(q, dot3(e, p))),
782 g1 / denom,
783 );
784 unit_checked(add3(p, correction))
785}
786
787fn apply_aberration(p: [f64; 3], v_obs_bary_km_s: [f64; 3]) -> Result<[f64; 3], ObserveError> {
788 validate_vec3(v_obs_bary_km_s)?;
789 let beta = scale3(v_obs_bary_km_s, 1.0 / C_KM_S);
790 let beta2 = dot3(beta, beta);
791 if !beta2.is_finite() || !(0.0..1.0).contains(&beta2) {
792 return Err(ObserveError::NonFinite);
793 }
794 let inv_beta = (1.0 - beta2).sqrt();
795 let p_dot_beta = dot3(p, beta);
796 let denom = 1.0 + p_dot_beta;
797 if !denom.is_finite() || denom == 0.0 {
798 return Err(ObserveError::DegenerateGeometry);
799 }
800 let f = 1.0 + p_dot_beta / (1.0 + inv_beta);
801 let numerator = add3(scale3(p, inv_beta), scale3(beta, f));
802 unit_checked(scale3(numerator, 1.0 / denom))
803}
804
805fn apparent_horizontal(
806 station: &GeodeticStationKm,
807 ts: &TimeScales,
808 r_obs_geo_km: [f64; 3],
809 u_app: [f64; 3],
810 distance_km: f64,
811 options: ObserveOptions,
812) -> Result<Horizontal, ObserveError> {
813 let target_gcrs_km = add3(r_obs_geo_km, scale3(u_app, distance_km));
814 validate_vec3(target_gcrs_km)?;
815 let gcrs_to_itrs = match options.polar_motion {
816 Some(pole) => gcrs_to_itrs_matrix_with_polar_motion(ts, pole)?,
817 None => gcrs_to_itrs_matrix(ts)?,
818 };
819 let target_itrs_km = mat3_vec3_mul_unchecked(&gcrs_to_itrs, &target_gcrs_km);
820 let (azimuth_deg, elevation_deg, _range_km) = itrs_to_topocentric(target_itrs_km, station)?;
821 Ok(Horizontal {
822 azimuth_deg,
823 elevation_deg: apply_refraction(elevation_deg, options.refraction)?,
824 range_km: distance_km,
825 })
826}
827
828fn hour_angle(
829 station: &GeodeticStationKm,
830 ts: &TimeScales,
831 apparent: Equatorial,
832) -> Result<(f64, f64), ObserveError> {
833 let gast_deg = greenwich_apparent_sidereal_time_radians(ts)?.to_degrees();
834 let hour_angle_deg =
835 wrap_pm180(gast_deg + station.longitude_deg - apparent.right_ascension_deg);
836 Ok((hour_angle_deg, hour_angle_deg / 15.0))
837}
838
839fn ecliptic_from_true_equatorial(
840 ts: &TimeScales,
841 u_tod: [f64; 3],
842 distance_km: f64,
843) -> Result<Ecliptic, ObserveError> {
844 let eps_true = true_obliquity_radians(ts)?;
845 let cos_eps = eps_true.cos();
846 let sin_eps = eps_true.sin();
847 let x = u_tod[0];
848 let y = cos_eps * u_tod[1] + sin_eps * u_tod[2];
849 let z = -sin_eps * u_tod[1] + cos_eps * u_tod[2];
850 Ok(Ecliptic {
851 longitude_deg: wrap_360(y.atan2(x).to_degrees()),
852 latitude_deg: clamp_unit(z).asin().to_degrees(),
853 distance_km,
854 })
855}
856
857fn true_obliquity_radians(ts: &TimeScales) -> Result<f64, ObserveError> {
858 let mean = skyfield_mean_obliquity_radians(ts.jd_tdb).map_err(|_| ObserveError::NonFinite)?;
859 let (_dpsi, deps) = skyfield_iau2000a_radians(ts.jd_tt).map_err(|_| ObserveError::NonFinite)?;
860 let eps = mean + deps;
861 ensure_finite(eps)?;
862 Ok(eps)
863}
864
865fn nutation_matrix(ts: &TimeScales) -> Result<[[f64; 3]; 3], ObserveError> {
866 let mean = skyfield_mean_obliquity_radians(ts.jd_tdb).map_err(|_| ObserveError::NonFinite)?;
867 let (dpsi, deps) = skyfield_iau2000a_radians(ts.jd_tt).map_err(|_| ObserveError::NonFinite)?;
868 build_skyfield_nutation_matrix(mean, mean + deps, dpsi).map_err(|_| ObserveError::NonFinite)
869}
870
871fn equatorial_from_vector(vector_km: [f64; 3]) -> Result<Equatorial, ObserveError> {
872 let distance_km = norm_checked(vector_km)?;
873 Ok(equatorial_from_unit(
874 scale3(vector_km, 1.0 / distance_km),
875 distance_km,
876 ))
877}
878
879fn equatorial_from_unit(unit: [f64; 3], distance_km: f64) -> Equatorial {
880 let right_ascension_deg = wrap_360(unit[1].atan2(unit[0]).to_degrees());
881 Equatorial {
882 right_ascension_deg,
883 right_ascension_hours: right_ascension_deg / 15.0,
884 declination_deg: clamp_unit(unit[2]).asin().to_degrees(),
885 distance_km,
886 }
887}
888
889fn apply_refraction(
890 elevation_deg: f64,
891 refraction: Option<Refraction>,
892) -> Result<f64, ObserveError> {
893 ensure_finite(elevation_deg)?;
894 let Some(refraction) = refraction else {
895 return Ok(elevation_deg);
896 };
897 ensure_finite(refraction.pressure_mbar)?;
898 ensure_finite(refraction.temperature_c)?;
899 if !(-1.0..=89.9).contains(&elevation_deg) {
900 return Ok(elevation_deg);
901 }
902 let argument_deg = elevation_deg + 10.3 / (elevation_deg + 5.11);
903 let r_arcmin = refraction.pressure_mbar / 1010.0 * 283.0 / (273.0 + refraction.temperature_c)
904 * 1.02
905 / argument_deg.to_radians().tan();
906 let corrected = elevation_deg + r_arcmin / 60.0;
907 ensure_finite(corrected)?;
908 Ok(corrected)
909}
910
911fn validate_refraction(refraction: Option<Refraction>) -> Result<(), ObserveError> {
912 if let Some(refraction) = refraction {
913 ensure_finite(refraction.pressure_mbar)?;
914 ensure_finite(refraction.temperature_c)?;
915 }
916 Ok(())
917}
918
919fn norm_checked(vector: [f64; 3]) -> Result<f64, ObserveError> {
920 validate_vec3(vector)?;
921 let norm = norm3(vector);
922 if !norm.is_finite() {
923 return Err(ObserveError::NonFinite);
924 }
925 if norm == 0.0 {
926 return Err(ObserveError::DegenerateGeometry);
927 }
928 Ok(norm)
929}
930
931fn unit_checked(vector: [f64; 3]) -> Result<[f64; 3], ObserveError> {
932 unit3(vector).ok_or(ObserveError::DegenerateGeometry)
933}
934
935fn validate_vec3(vector: [f64; 3]) -> Result<(), ObserveError> {
936 if vector.iter().all(|value| value.is_finite()) {
937 Ok(())
938 } else {
939 Err(ObserveError::NonFinite)
940 }
941}
942
943fn ensure_finite(value: f64) -> Result<(), ObserveError> {
944 if value.is_finite() {
945 Ok(())
946 } else {
947 Err(ObserveError::NonFinite)
948 }
949}
950
951fn wrap_360(deg: f64) -> f64 {
952 deg.rem_euclid(360.0)
953}
954
955fn wrap_pm180(deg: f64) -> f64 {
956 let wrapped = (deg + 180.0).rem_euclid(360.0) - 180.0;
957 if wrapped <= -180.0 {
958 wrapped + 360.0
959 } else {
960 wrapped
961 }
962}
963
964fn clamp_unit(value: f64) -> f64 {
965 value.clamp(-1.0, 1.0)
966}
967
968#[cfg(test)]
969mod tests {
970 use super::*;
971
972 fn greenwich() -> GeodeticStationKm {
974 GeodeticStationKm {
975 latitude_deg: 51.4769,
976 longitude_deg: 0.0,
977 altitude_km: 0.046,
978 }
979 }
980
981 fn observe_fixture_spk() -> Spk {
982 Spk::from_bytes(include_bytes!(
983 "../../../tests/fixtures/bodies/observe_de.bsp"
984 ))
985 .expect("fixture SPK")
986 }
987
988 #[test]
989 fn sun_az_el_at_solar_transit_is_due_south_and_high() {
990 let time = UtcInstant::from_utc(2024, 6, 20, 12, 1, 42, 0).expect("valid UTC");
996 let look = sun_az_el(&greenwich(), time).expect("valid sun geometry");
997 assert!(
998 (look.azimuth_deg - 180.0).abs() < 0.5,
999 "sun azimuth {}",
1000 look.azimuth_deg
1001 );
1002 assert!(
1003 (look.elevation_deg - 61.960).abs() < 0.5,
1004 "sun elevation {}",
1005 look.elevation_deg
1006 );
1007 assert!(
1008 (look.range_km - 1.52011e8).abs() < 5.0e5,
1009 "sun range {}",
1010 look.range_km
1011 );
1012 }
1013
1014 #[test]
1015 fn moon_illumination_tracks_full_moon() {
1016 let time = UtcInstant::from_utc(2024, 4, 23, 23, 49, 0, 0).expect("valid UTC");
1021 let illum = moon_illumination(&greenwich(), time).expect("valid moon illumination");
1022 assert!(
1023 (illum.illuminated_fraction - 0.998).abs() < 0.02,
1024 "full-moon fraction {}",
1025 illum.illuminated_fraction
1026 );
1027 assert!(
1028 illum.phase_angle_deg < 11.0,
1029 "full-moon phase angle {}",
1030 illum.phase_angle_deg
1031 );
1032 }
1033
1034 #[test]
1035 fn moon_illumination_tracks_new_moon() {
1036 let time = UtcInstant::from_utc(2024, 4, 8, 18, 21, 0, 0).expect("valid UTC");
1040 let illum = moon_illumination(&greenwich(), time).expect("valid moon illumination");
1041 assert!(
1042 illum.illuminated_fraction < 0.02,
1043 "new-moon fraction {}",
1044 illum.illuminated_fraction
1045 );
1046 assert!(
1047 illum.phase_angle_deg > 168.0,
1048 "new-moon phase angle {}",
1049 illum.phase_angle_deg
1050 );
1051 }
1052
1053 #[test]
1054 fn moon_illumination_near_first_quarter() {
1055 let time = UtcInstant::from_utc(2024, 4, 15, 19, 13, 0, 0).expect("valid UTC");
1059 let illum = moon_illumination(&greenwich(), time).expect("valid moon illumination");
1060 assert!(
1061 (illum.illuminated_fraction - 0.50).abs() < 0.05,
1062 "first-quarter fraction {}",
1063 illum.illuminated_fraction
1064 );
1065 }
1066
1067 #[test]
1068 fn moon_az_el_matches_reference_at_transit() {
1069 let time = UtcInstant::from_utc(2024, 4, 23, 23, 55, 59, 0).expect("valid UTC");
1075 let look = moon_az_el(&greenwich(), time).expect("valid moon geometry");
1076 assert!(
1077 (look.azimuth_deg - 180.0).abs() < 0.3,
1078 "moon azimuth {}",
1079 look.azimuth_deg
1080 );
1081 assert!(
1082 (look.elevation_deg - 23.120).abs() < 0.3,
1083 "moon elevation {}",
1084 look.elevation_deg
1085 );
1086 assert!(
1087 (look.range_km - 397_206.0).abs() < 1000.0,
1088 "moon range {}",
1089 look.range_km
1090 );
1091 }
1092
1093 #[test]
1094 fn spk_light_time_converges_for_outer_planet() {
1095 let kernel = observe_fixture_spk();
1096 let station = greenwich();
1097 let ts = UtcInstant::from_utc(2024, 1, 1, 0, 0, 0, 0)
1098 .expect("valid UTC")
1099 .time_scales();
1100 let et = (ts.jd_tdb - J2000_JD) * SECONDS_PER_DAY;
1101 let observer =
1102 observer_barycentric(&station, &ts, &kernel, et, None).expect("observer state");
1103 let solution =
1104 solve_spk_light_time(&kernel, 5, et, observer.r_bary_km).expect("light time");
1105
1106 assert!(solution.iterations <= LIGHT_TIME_REFINEMENTS);
1107 assert!(
1108 solution.residual_s < LIGHT_TIME_TOLERANCE_S,
1109 "residual {}",
1110 solution.residual_s
1111 );
1112 }
1113
1114 #[test]
1115 fn degenerate_zero_range_returns_typed_error() {
1116 let kernel = observe_fixture_spk();
1117 let station = greenwich();
1118 let time = UtcInstant::from_utc(2024, 1, 1, 0, 0, 0, 0).expect("valid UTC");
1119 let ts = time.time_scales();
1120 let et = (ts.jd_tdb - J2000_JD) * SECONDS_PER_DAY;
1121 let observer =
1122 observer_barycentric(&station, &ts, &kernel, et, None).expect("observer state");
1123 let err = observe_with_time_scales(
1124 &station,
1125 &ts,
1126 Target::BarycentricState {
1127 kernel: &kernel,
1128 position_km: observer.r_bary_km,
1129 velocity_km_s: [0.0, 0.0, 0.0],
1130 },
1131 ObserveOptions::default(),
1132 )
1133 .unwrap_err();
1134
1135 assert!(matches!(err, ObserveError::DegenerateGeometry));
1136 }
1137}