Skip to main content

sidereon_core/astro/bodies/
observe.rs

1//! Ground-site observation convenience helpers for the Sun and Moon.
2//!
3//! These round out the "observe the sky from a site" lens by wiring the
4//! analytic Sun/Moon ephemeris ([`crate::astro::bodies::sun_moon::sun_moon_ecef`])
5//! into the existing observation geometry rather than re-deriving any math:
6//!
7//! - Topocentric azimuth/elevation/range of the Sun or Moon from a ground site
8//!   reuses the station-to-target ENU reduction
9//!   [`crate::astro::frames::transforms::itrs_to_topocentric`] (the same one the
10//!   satellite look-angle path uses).
11//! - The Moon's illuminated fraction reuses the Sun-target-observer phase angle
12//!   [`crate::astro::angles::phase_angle`] that the satellite visual-magnitude and
13//!   eclipse geometry already consume.
14//!
15//! Precision follows the underlying analytic series (sub-degree positions, see
16//! [`crate::astro::bodies::sun_moon`]); this is a planning/visualization lens, not
17//! an almanac-grade reduction.
18
19use 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/// Error returned by ground-site Sun/Moon observation helpers.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
53pub enum BodyObservationError {
54    /// The analytic Sun/Moon ephemeris rejected the instant or produced a
55    /// non-finite vector.
56    #[error("Sun/Moon ephemeris failed: {0}")]
57    Ephemeris(#[from] SunMoonError),
58    /// The station-to-body topocentric reduction rejected an input.
59    #[error("topocentric reduction failed: {0}")]
60    FrameTransform(#[from] FrameTransformError),
61    /// The phase-angle geometry rejected an input (e.g. a degenerate vector).
62    #[error("phase-angle geometry failed: {0}")]
63    Angle(#[from] AngleError),
64}
65
66/// Topocentric look angle of a body from a ground site.
67#[derive(Debug, Clone, Copy, PartialEq)]
68pub struct BodyAzEl {
69    /// Azimuth, degrees clockwise from north on `[0, 360)`.
70    pub azimuth_deg: f64,
71    /// Elevation above the local horizon, degrees on `[-90, 90]`.
72    pub elevation_deg: f64,
73    /// Slant range from the site to the body, kilometres.
74    pub range_km: f64,
75}
76
77/// The Moon's illuminated state as seen from a ground site.
78#[derive(Debug, Clone, Copy, PartialEq)]
79pub struct MoonIllumination {
80    /// Sunlit fraction of the lunar disk on `[0, 1]` (0 = new, 1 = full).
81    pub illuminated_fraction: f64,
82    /// Sun-Moon-observer phase angle, degrees on `[0, 180]` (0 = full).
83    pub phase_angle_deg: f64,
84}
85
86/// Equatorial spherical coordinates with distance.
87#[derive(Debug, Clone, Copy, PartialEq)]
88pub struct Equatorial {
89    /// Right ascension in degrees on `[0, 360)`.
90    pub right_ascension_deg: f64,
91    /// Right ascension in hours on `[0, 24)`.
92    pub right_ascension_hours: f64,
93    /// Declination in degrees on `[-90, 90]`.
94    pub declination_deg: f64,
95    /// Distance in kilometers.
96    pub distance_km: f64,
97}
98
99/// Horizontal topocentric look angle.
100#[derive(Debug, Clone, Copy, PartialEq)]
101pub struct Horizontal {
102    /// Azimuth in degrees clockwise from north on `[0, 360)`.
103    pub azimuth_deg: f64,
104    /// Elevation in degrees. Refraction-corrected only when requested.
105    pub elevation_deg: f64,
106    /// Topocentric range in kilometers.
107    pub range_km: f64,
108}
109
110/// Ecliptic spherical coordinates, true ecliptic and equinox of date.
111#[derive(Debug, Clone, Copy, PartialEq)]
112pub struct Ecliptic {
113    /// Ecliptic longitude in degrees on `[0, 360)`.
114    pub longitude_deg: f64,
115    /// Ecliptic latitude in degrees on `[-90, 90]`.
116    pub latitude_deg: f64,
117    /// Distance in kilometers.
118    pub distance_km: f64,
119}
120
121/// Full topocentric observation of a target from a ground site.
122#[derive(Debug, Clone, Copy, PartialEq)]
123pub struct Observation {
124    /// Astrometric place, light-time only, on ICRS axes for full-chain targets.
125    ///
126    /// Reduced Sun/Moon analytic variants carry mean-of-date RA/Dec here, not an
127    /// ICRS astrometric place. See [`Observation::reduced`].
128    pub astrometric: Equatorial,
129    /// Apparent place on ICRF axes before the true-of-date rotation.
130    pub apparent_icrs: Equatorial,
131    /// Apparent place, true equator and equinox of date.
132    pub apparent: Equatorial,
133    /// Apparent horizontal coordinates.
134    pub horizontal: Horizontal,
135    /// Local apparent hour angle in degrees on `(-180, 180]`.
136    pub hour_angle_deg: f64,
137    /// Local apparent hour angle in hours on `(-12, 12]`.
138    pub hour_angle_hours: f64,
139    /// Apparent ecliptic coordinates of date.
140    pub ecliptic: Ecliptic,
141    /// True for the reduced analytic Sun/Moon variants.
142    pub reduced: bool,
143}
144
145/// Optional Bennett atmospheric-refraction inputs.
146#[derive(Debug, Clone, Copy, PartialEq)]
147pub struct Refraction {
148    /// Pressure in millibars.
149    pub pressure_mbar: f64,
150    /// Temperature in degrees Celsius.
151    pub temperature_c: f64,
152}
153
154/// Options for general ground-site observation.
155#[derive(Debug, Clone, Copy, PartialEq)]
156pub struct ObserveOptions {
157    /// Optional polar-motion coordinates. `None` uses zero pole coordinates.
158    pub polar_motion: Option<PolarMotion>,
159    /// Optional output-side Bennett refraction for elevation only.
160    pub refraction: Option<Refraction>,
161    /// Apply solar gravitational light deflection on the full-chain path.
162    pub deflection: bool,
163    /// Apply annual plus diurnal aberration on the full-chain path.
164    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/// What to observe.
179#[derive(Debug, Clone, Copy)]
180pub enum Target<'a> {
181    /// SPK-covered body by NAIF id.
182    Spk { kernel: &'a Spk, naif_id: i32 },
183    /// Analytic low-precision Sun, no kernel required.
184    Sun,
185    /// Analytic low-precision Moon, no kernel required.
186    Moon,
187    /// Caller-supplied SSB-centered ICRS/J2000 state plus a kernel for Earth/Sun.
188    BarycentricState {
189        kernel: &'a Spk,
190        position_km: [f64; 3],
191        velocity_km_s: [f64; 3],
192    },
193}
194
195/// Error returned by general ground-site observation.
196#[derive(Debug, Clone, thiserror::Error)]
197pub enum ObserveError {
198    /// SPK state evaluation failed.
199    #[error("SPK state failed: {0}")]
200    Spk(#[from] SpkError),
201    /// Frame transformation failed.
202    #[error("frame transform failed: {0}")]
203    FrameTransform(#[from] FrameTransformError),
204    /// Analytic Sun/Moon ephemeris failed.
205    #[error("Sun/Moon ephemeris failed: {0}")]
206    SunMoon(#[from] SunMoonError),
207    /// Angle geometry failed.
208    #[error("angle geometry failed: {0}")]
209    Angle(#[from] AngleError),
210    /// SPK state used a reference frame this reduction does not support.
211    #[error("unsupported SPK reference frame {frame}")]
212    UnsupportedSpkFrame { frame: i32 },
213    /// A public input or computed intermediate was not finite.
214    #[error("non-finite observation input or intermediate")]
215    NonFinite,
216    /// The requested geometry collapsed to a zero-length direction.
217    #[error("degenerate observation geometry")]
218    DegenerateGeometry,
219}
220
221/// General topocentric observation.
222pub 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
231/// Convenience wrapper for observing an SPK body with default options.
232pub 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
246/// Geocentric apparent position of an SPK target in the true equator and equinox
247/// of date, in metres.
248pub(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
279/// Geocentric apparent analytic Sun/Moon position in the true equator and
280/// equinox of date, in metres.
281pub(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
297/// General topocentric observation with caller-supplied time scales.
298pub 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
327/// Topocentric azimuth/elevation/range of the Sun from a ground site at an
328/// instant.
329///
330/// Reuses [`sun_moon_ecef`] for the Earth-fixed Sun vector and
331/// [`itrs_to_topocentric`] for the station-to-target ENU reduction, so the
332/// azimuth/elevation convention matches the satellite look-angle path.
333pub 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
341/// Topocentric azimuth/elevation/range of the Moon from a ground site at an
342/// instant.
343///
344/// The Earth-fixed Moon vector from [`sun_moon_ecef`] is geocentric, so the
345/// station-to-target subtraction in [`itrs_to_topocentric`] applies the
346/// topocentric (diurnal) parallax that matters for the nearby Moon.
347pub 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
372/// Illuminated fraction of the Moon as seen from a ground site at an instant.
373///
374/// The Sun-Moon-observer phase angle is the existing
375/// [`phase_angle`] (the satellite Sun-target-observer geometry) evaluated with
376/// the Moon as the target and the site as the observer, both from the Earth-fixed
377/// Sun/Moon vectors of [`sun_moon_ecef`]. The illuminated fraction follows from
378/// the half-angle relation `k = (1 + cos(phase)) / 2`. Topocentric and geocentric
379/// fractions differ only negligibly; the site-relative phase angle is used for
380/// consistency with the other site helpers in this module.
381pub 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    // Reference site: Royal Observatory, Greenwich (WGS84), altitude ~46 m.
973    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        // Apparent solar upper transit at Greenwich on 2024-06-20 is 12:01:42 UTC
991        // (Skyfield de421 meridian_transits). At that instant the Sun is on the
992        // meridian: Skyfield gives az 180.000 deg, alt 61.960 deg, range
993        // 1.52011e8 km (near aphelion). The low-precision analytic series is held
994        // to 0.5 deg in az/el and 5.0e5 km in range.
995        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        // Full moon of 2024-04-23 23:49 UTC. Skyfield's (de421) geocentric
1017        // illuminated fraction at this instant is 0.9998 (phase angle 1.68 deg).
1018        // The low-precision analytic series is held to a coarse tolerance:
1019        // fraction within 0.02, phase angle below 11 deg.
1020        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        // New moon of 2024-04-08 18:21 UTC (the total-eclipse new moon). Skyfield's
1037        // (de421) geocentric fraction is 0.0000 (phase angle 179.65 deg). Held to
1038        // fraction within 0.02 and phase angle above 168 deg.
1039        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        // First quarter of 2024-04-15 19:13 UTC: about half the disk lit. Skyfield's
1056        // (de421) geocentric fraction is 0.5013 (phase angle 89.85 deg). Held to
1057        // fraction within 0.05.
1058        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        // Moon upper transit at Greenwich on 2024-04-23 is 23:55:59 UTC. At that
1070        // instant Skyfield (de421, apparent topocentric altaz) gives az 180.000
1071        // deg, alt 23.120 deg, range 397206 km. The low-precision analytic series
1072        // is held to 0.3 deg in az/el and 1000 km in range; the band check also
1073        // confirms the parallax-corrected station subtraction ran.
1074        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}