Skip to main content

sidereon_core/astro/
observation.rs

1//! Observational-astronomy geometry primitives.
2//!
3//! Small, I/O-free geometry helpers used when reducing or planning ground- and
4//! space-based observations. Each function takes already-resolved geometry
5//! (vectors, angles, ephemeris-derived positions) so it stays a pure numerical
6//! kernel; callers supply the ephemeris and frame transforms from the rest of
7//! the engine (for example [`crate::astro::bodies::sun_moon::sun_moon_ecef`] for
8//! the Earth-fixed Sun vector that feeds the sub-solar point and terminator).
9//!
10//! Conventions used throughout this module:
11//!
12//! - Latitudes are geocentric (the direction of the position vector), returned
13//!   in degrees on `[-90, 90]`.
14//! - Longitudes are returned in degrees on `(-180, 180]` from `atan2(y, x)`,
15//!   i.e. positive toward the body-fixed `+y` axis (east of the prime meridian
16//!   for a standard right-handed body-fixed frame).
17//! - Angles passed in (hour angle, declination, phase angle, pole orientation)
18//!   are in degrees unless the name says otherwise.
19
20use crate::astro::constants::units::{DEG_TO_RAD, RAD_TO_DEG};
21use crate::astro::math::vec3;
22use crate::validate;
23
24/// Error returned by observation-geometry helpers.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
26pub enum ObservationError {
27    /// A public input was non-finite or outside its valid domain.
28    #[error("invalid observation input {field}: {reason}")]
29    InvalidInput {
30        /// Offending field name.
31        field: &'static str,
32        /// Human-readable reason.
33        reason: &'static str,
34    },
35}
36
37fn invalid(field: &'static str, reason: &'static str) -> ObservationError {
38    ObservationError::InvalidInput { field, reason }
39}
40
41fn map_field(error: validate::FieldError) -> ObservationError {
42    invalid(error.field(), error.reason())
43}
44
45/// A point on a body surface expressed as geocentric latitude and longitude.
46#[derive(Debug, Clone, Copy, PartialEq)]
47pub struct SurfacePoint {
48    /// Geocentric latitude, degrees on `[-90, 90]`.
49    pub latitude_deg: f64,
50    /// Longitude, degrees on `(-180, 180]`.
51    pub longitude_deg: f64,
52}
53
54/// Geocentric latitude/longitude of the direction of an Earth-fixed vector.
55///
56/// `vec_ecef` is any non-zero vector in an Earth-fixed (ITRS/ECEF) frame; the
57/// returned point is the sub-point where that direction pierces the sphere.
58fn sub_point(vec_ecef: [f64; 3], field: &'static str) -> Result<SurfacePoint, ObservationError> {
59    validate::finite_vec3(vec_ecef, field).map_err(map_field)?;
60    let [x, y, z] = vec_ecef;
61    let horizontal = (x * x + y * y).sqrt();
62    if horizontal == 0.0 && z == 0.0 {
63        return Err(invalid(field, "zero vector"));
64    }
65    let latitude_deg = z.atan2(horizontal) * RAD_TO_DEG;
66    let longitude_deg = y.atan2(x) * RAD_TO_DEG;
67    Ok(SurfacePoint {
68        latitude_deg,
69        longitude_deg,
70    })
71}
72
73/// Sub-solar point: the geographic point where the Sun is at the zenith.
74///
75/// `sun_ecef` is the geocentric Sun position in an Earth-fixed (ITRS/ECEF)
76/// frame, in any length unit (only its direction matters). The returned
77/// latitude is the solar declination of date and the longitude is the meridian
78/// where it is local solar noon. Pair with
79/// [`crate::astro::bodies::sun_moon::sun_moon_ecef`] to obtain `sun_ecef` from a
80/// time.
81pub fn sub_solar_point(sun_ecef: [f64; 3]) -> Result<SurfacePoint, ObservationError> {
82    sub_point(sun_ecef, "sun_ecef")
83}
84
85/// Latitude (degrees) of the day-night terminator at a given longitude.
86///
87/// The terminator is the great circle of points whose solar zenith angle is
88/// exactly 90 degrees, i.e. the locus 90 degrees of angular distance from the
89/// sub-solar point. With sub-solar latitude `delta` (solar declination) and
90/// sub-solar longitude `lambda_s`, the great-circle condition
91/// `sin(delta) sin(lat) + cos(delta) cos(lat) cos(lon - lambda_s) = 0` gives
92///
93/// ```text
94/// lat(lon) = atan( -cos(lon - lambda_s) / tan(delta) )
95/// ```
96///
97/// `sub_solar` is the sub-solar point (see [`sub_solar_point`]); `longitude_deg`
98/// is the query longitude. The result is the terminator latitude on `[-90, 90]`.
99///
100/// At an equinox the sub-solar declination is ~0 and the terminator degenerates
101/// to the meridian circle through the poles; this is reported as `+90` or `-90`
102/// (the limit of the formula) for all longitudes except the two where the
103/// terminator crosses the equator.
104pub fn terminator_latitude_deg(
105    sub_solar: SurfacePoint,
106    longitude_deg: f64,
107) -> Result<f64, ObservationError> {
108    let delta = validate::finite(sub_solar.latitude_deg, "sub_solar.latitude_deg")
109        .map_err(map_field)?
110        .to_radians();
111    let lambda_s = validate::finite(sub_solar.longitude_deg, "sub_solar.longitude_deg")
112        .map_err(map_field)?
113        .to_radians();
114    let lon = validate::finite(longitude_deg, "longitude_deg")
115        .map_err(map_field)?
116        .to_radians();
117
118    let tan_delta = delta.tan();
119    let cos_dlon = (lon - lambda_s).cos();
120    // Quadrature meridians (lon = lambda_s +/- 90) are where the terminator
121    // crosses the equator: the numerator cos(lon - lambda_s) is zero there, so
122    // the latitude is 0 regardless of declination. `cos(pi/2)` is not exactly
123    // zero in floating point (~6e-17), so at an equinox (tan(delta) -> 0) the raw
124    // ratio is tiny/zero, which atan would collapse to a spurious +/-90 instead
125    // of the equator crossing. Handle the near-zero numerator explicitly.
126    const QUADRATURE_EPS: f64 = 1e-9;
127    if cos_dlon.abs() < QUADRATURE_EPS {
128        return Ok(0.0);
129    }
130    // Away from quadrature, atan handles the `tan(delta) -> 0` (equinox) limit
131    // cleanly: the ratio diverges and atan saturates to the +/-90 degree poleward
132    // meridian, with the sign following that of -cos(lon - lambda_s), without a
133    // divide-by-zero blowing up to NaN.
134    let lat = (-cos_dlon / tan_delta).atan();
135    Ok(lat * RAD_TO_DEG)
136}
137
138/// Parallactic angle (degrees) of a target at a station.
139///
140/// The parallactic angle is the angle at the target between the directions to
141/// the celestial pole and to the observer's zenith, i.e. the position angle by
142/// which an alt-azimuth field is rotated relative to the equatorial frame. The
143/// standard formula is
144///
145/// ```text
146/// q = atan2( sin H, tan(phi) cos(dec) - sin(dec) cos H )
147/// ```
148///
149/// (Meeus, *Astronomical Algorithms*, 2nd ed., ch. 14), with `phi` the observer
150/// geodetic latitude, `H` the local hour angle (positive west of the meridian),
151/// and `dec` the target declination. The result is on `(-180, 180]`: it is `0`
152/// on the meridian, positive west of it, negative east of it.
153pub fn parallactic_angle_deg(
154    observer_latitude_deg: f64,
155    hour_angle_deg: f64,
156    declination_deg: f64,
157) -> Result<f64, ObservationError> {
158    let phi = validate::finite(observer_latitude_deg, "observer_latitude_deg")
159        .map_err(map_field)?
160        .to_radians();
161    let h = validate::finite(hour_angle_deg, "hour_angle_deg")
162        .map_err(map_field)?
163        .to_radians();
164    let dec = validate::finite(declination_deg, "declination_deg")
165        .map_err(map_field)?
166        .to_radians();
167
168    let numerator = h.sin();
169    let denominator = phi.tan() * dec.cos() - dec.sin() * h.cos();
170    Ok(numerator.atan2(denominator) * RAD_TO_DEG)
171}
172
173/// Apparent visual magnitude of a sunlit body from a diffuse-sphere phase law.
174///
175/// Model: the body is treated as a Lambertian (diffuse) sphere of fixed size
176/// and albedo. Its apparent magnitude scales with the inverse square of range
177/// (the `5 log10` distance term) and with the normalized diffuse-sphere phase
178/// function
179///
180/// ```text
181/// p(phi) = [ (pi - phi) cos(phi) + sin(phi) ] / pi ,   p(0) = 1
182/// ```
183///
184/// where `phi` is the solar phase angle (Sun-body-observer), `phi = 0` being
185/// fully illuminated (Karttunen et al., *Fundamental Astronomy*; this is the
186/// classic Lambert sphere phase integral normalized to unity at opposition).
187/// The apparent magnitude is then
188///
189/// ```text
190/// m = M_std + 5 log10(range / range_ref) - 2.5 log10( p(phi) )
191/// ```
192///
193/// where `standard_magnitude` (`M_std`) is the body's brightness at the
194/// reference range `reference_range_km` and zero phase. With the common
195/// satellite convention `reference_range_km = 1000.0`, `M_std` is the familiar
196/// "standard (intrinsic) magnitude" tabulated for many satellites.
197///
198/// `range_km` and `reference_range_km` must be positive; `phase_angle_deg` is
199/// clamped to `[0, 180]` (the physical range of a phase angle).
200pub fn satellite_visual_magnitude(
201    range_km: f64,
202    phase_angle_deg: f64,
203    standard_magnitude: f64,
204    reference_range_km: f64,
205) -> Result<f64, ObservationError> {
206    let range_km = validate::finite_positive(range_km, "range_km").map_err(map_field)?;
207    let reference_range_km =
208        validate::finite_positive(reference_range_km, "reference_range_km").map_err(map_field)?;
209    let standard_magnitude =
210        validate::finite(standard_magnitude, "standard_magnitude").map_err(map_field)?;
211    let phase_angle_deg =
212        validate::finite(phase_angle_deg, "phase_angle_deg").map_err(map_field)?;
213
214    let phi = phase_angle_deg.clamp(0.0, 180.0) * DEG_TO_RAD;
215    let pi = std::f64::consts::PI;
216    let phase = ((pi - phi) * phi.cos() + phi.sin()) / pi;
217
218    let distance_term = 5.0 * (range_km / reference_range_km).log10();
219    let phase_term = -2.5 * phase.log10();
220    Ok(standard_magnitude + distance_term + phase_term)
221}
222
223/// Sub-observer point (planetary central meridian) on a rotating body.
224///
225/// Given the observer's position relative to the body center in an inertial
226/// frame (ICRF/J2000 equatorial) and the body's IAU orientation, returns the
227/// body-fixed latitude/longitude beneath the observer: the central meridian of
228/// the visible disk.
229///
230/// The body-fixed frame follows the IAU WGCCRE convention: the north pole of
231/// rotation points to right ascension `pole_ra_deg` (alpha0), declination
232/// `pole_dec_deg` (delta0), and the prime meridian is at angle
233/// `prime_meridian_deg` (W) measured along the body equator from its ascending
234/// node on the ICRF equator. The rotation from inertial to body-fixed
235/// coordinates is
236///
237/// ```text
238/// M = Rz(W) . Rx(90 deg - delta0) . Rz(90 deg + alpha0)
239/// ```
240///
241/// (Archinal et al., "Report of the IAU Working Group on Cartographic
242/// Coordinates and Rotational Elements"). The returned longitude is the
243/// planetocentric longitude `atan2(y, x)` in the body-fixed frame, on
244/// `(-180, 180]`; latitude is planetocentric on `[-90, 90]`. Bodies whose
245/// official maps use west or `[0, 360)` longitude need the caller to remap.
246pub fn sub_observer_point(
247    observer_from_body: [f64; 3],
248    pole_ra_deg: f64,
249    pole_dec_deg: f64,
250    prime_meridian_deg: f64,
251) -> Result<SurfacePoint, ObservationError> {
252    validate::finite_vec3(observer_from_body, "observer_from_body").map_err(map_field)?;
253    if vec3::norm3(observer_from_body) == 0.0 {
254        return Err(invalid("observer_from_body", "zero vector"));
255    }
256    let alpha0 = validate::finite(pole_ra_deg, "pole_ra_deg")
257        .map_err(map_field)?
258        .to_radians();
259    let delta0 = validate::finite(pole_dec_deg, "pole_dec_deg")
260        .map_err(map_field)?
261        .to_radians();
262    let w = validate::finite(prime_meridian_deg, "prime_meridian_deg")
263        .map_err(map_field)?
264        .to_radians();
265
266    let half_pi = std::f64::consts::FRAC_PI_2;
267    let body_fixed = rot_z(
268        rot_x(
269            rot_z(observer_from_body, half_pi + alpha0),
270            half_pi - delta0,
271        ),
272        w,
273    );
274    sub_point(body_fixed, "observer_from_body")
275}
276
277/// Passive rotation of a vector about the z-axis by `theta` (radians).
278#[inline]
279fn rot_z(v: [f64; 3], theta: f64) -> [f64; 3] {
280    let (s, c) = theta.sin_cos();
281    [c * v[0] + s * v[1], -s * v[0] + c * v[1], v[2]]
282}
283
284/// Passive rotation of a vector about the x-axis by `theta` (radians).
285#[inline]
286fn rot_x(v: [f64; 3], theta: f64) -> [f64; 3] {
287    let (s, c) = theta.sin_cos();
288    [v[0], c * v[1] + s * v[2], -s * v[1] + c * v[2]]
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    const AU_KM: f64 = 149_597_870.7;
296
297    fn close(a: f64, b: f64, tol: f64) -> bool {
298        (a - b).abs() <= tol
299    }
300
301    #[test]
302    fn sub_solar_point_reads_declination_and_meridian() {
303        // Sun in the Earth-fixed frame, 23.44 deg above the equatorial plane at
304        // longitude 0: sub-solar point is at that declination and longitude.
305        let delta = 23.44_f64.to_radians();
306        let sun = [AU_KM * delta.cos(), 0.0, AU_KM * delta.sin()];
307        let point = sub_solar_point(sun).expect("valid sun vector");
308        assert!(close(point.latitude_deg, 23.44, 1e-9));
309        assert!(close(point.longitude_deg, 0.0, 1e-9));
310
311        // A longitude offset shows up directly.
312        let sun_east = [0.0, AU_KM * delta.cos(), AU_KM * delta.sin()];
313        let east = sub_solar_point(sun_east).expect("valid sun vector");
314        assert!(close(east.longitude_deg, 90.0, 1e-9));
315    }
316
317    #[test]
318    fn sub_solar_point_rejects_zero_vector() {
319        let err = sub_solar_point([0.0, 0.0, 0.0]).unwrap_err();
320        assert_eq!(
321            err,
322            ObservationError::InvalidInput {
323                field: "sun_ecef",
324                reason: "zero vector"
325            }
326        );
327    }
328
329    #[test]
330    fn terminator_matches_polar_circles_at_solstice() {
331        // Solstice sub-solar declination 23.44 deg at longitude 0. The
332        // terminator on the noon meridian sits at the Antarctic Circle and on
333        // the midnight meridian at the Arctic Circle (= 90 - 23.44).
334        let sub_solar = SurfacePoint {
335            latitude_deg: 23.44,
336            longitude_deg: 0.0,
337        };
338        let noon = terminator_latitude_deg(sub_solar, 0.0).expect("valid");
339        let midnight = terminator_latitude_deg(sub_solar, 180.0).expect("valid");
340        assert!(close(noon, -66.56, 1e-9), "noon terminator {noon}");
341        assert!(
342            close(midnight, 66.56, 1e-9),
343            "midnight terminator {midnight}"
344        );
345
346        // 90 deg from the sub-solar meridian the terminator crosses the equator.
347        let quad = terminator_latitude_deg(sub_solar, 90.0).expect("valid");
348        assert!(close(quad, 0.0, 1e-9), "quadrature terminator {quad}");
349    }
350
351    #[test]
352    fn terminator_equinox_quadrature_crosses_equator() {
353        // Equinox: sub-solar declination 0. The terminator is the meridian
354        // circle through the poles, crossing the equator exactly at the two
355        // quadrature meridians lambda_s +/- 90. There the latitude must be 0,
356        // not the spurious -90 that cos(pi/2) being ~6e-17 (not exactly 0) over
357        // tan(0) would otherwise produce.
358        let sub_solar = SurfacePoint {
359            latitude_deg: 0.0,
360            longitude_deg: 0.0,
361        };
362        let east_quad = terminator_latitude_deg(sub_solar, 90.0).expect("valid");
363        let west_quad = terminator_latitude_deg(sub_solar, -90.0).expect("valid");
364        assert!(close(east_quad, 0.0, 1e-9), "east quadrature {east_quad}");
365        assert!(close(west_quad, 0.0, 1e-9), "west quadrature {west_quad}");
366
367        // Off the quadrature meridians the degenerate equinox terminator is the
368        // poleward limit: the noon meridian heads to one pole, the midnight
369        // meridian to the other.
370        let noon = terminator_latitude_deg(sub_solar, 0.0).expect("valid");
371        let midnight = terminator_latitude_deg(sub_solar, 180.0).expect("valid");
372        assert!(close(noon.abs(), 90.0, 1e-9), "noon poleward limit {noon}");
373        assert!(
374            close(midnight.abs(), 90.0, 1e-9),
375            "midnight poleward limit {midnight}"
376        );
377    }
378
379    #[test]
380    fn terminator_near_equinox_quadrature_crosses_equator() {
381        // A small but non-zero declination (near, not exactly, an equinox) at a
382        // shifted sub-solar meridian: the quadrature longitude still crosses the
383        // equator at ~0, and the result stays continuous through it.
384        let sub_solar = SurfacePoint {
385            latitude_deg: 1.0e-4,
386            longitude_deg: 30.0,
387        };
388        let quad = terminator_latitude_deg(sub_solar, 120.0).expect("valid");
389        assert!(close(quad, 0.0, 1e-6), "near-equinox quadrature {quad}");
390    }
391
392    #[test]
393    fn terminator_offset_subsolar_longitude() {
394        let sub_solar = SurfacePoint {
395            latitude_deg: 10.0,
396            longitude_deg: 30.0,
397        };
398        let lat = terminator_latitude_deg(sub_solar, 100.0).expect("valid");
399        assert!(close(lat, -62.726_830_443_196_36, 1e-9), "terminator {lat}");
400    }
401
402    #[test]
403    fn parallactic_angle_zero_on_meridian() {
404        let q = parallactic_angle_deg(45.0, 0.0, 10.0).expect("valid");
405        assert!(close(q, 0.0, 1e-12), "meridian parallactic {q}");
406    }
407
408    #[test]
409    fn parallactic_angle_known_values() {
410        // Equatorial target (dec 0) from latitude 45 at hour angle 45:
411        // q = atan2(sin45, tan45) = atan(sin45) = 35.2644 deg.
412        let q = parallactic_angle_deg(45.0, 45.0, 0.0).expect("valid");
413        assert!(close(q, 35.264_389_682_754_654, 1e-9), "{q}");
414
415        let q2 = parallactic_angle_deg(40.0, 30.0, 20.0).expect("valid");
416        assert!(close(q2, 45.444_731_720_286_68, 1e-9), "{q2}");
417
418        // West of the meridian (positive H) gives positive q; east gives the
419        // mirror-image negative angle.
420        let west = parallactic_angle_deg(40.0, 30.0, 20.0).expect("valid");
421        let east = parallactic_angle_deg(40.0, -30.0, 20.0).expect("valid");
422        assert!(close(west, -east, 1e-9), "antisymmetry {west} {east}");
423    }
424
425    #[test]
426    fn visual_magnitude_distance_and_phase_terms() {
427        // At the reference range and full phase, m = standard magnitude.
428        let base = satellite_visual_magnitude(1000.0, 0.0, 0.0, 1000.0).expect("valid");
429        assert!(close(base, 0.0, 1e-12), "base {base}");
430
431        // Doubling the range adds 5 log10(2) ~= 1.505 mag.
432        let farther = satellite_visual_magnitude(2000.0, 0.0, 0.0, 1000.0).expect("valid");
433        assert!(
434            close(farther, 1.505_149_978_319_906, 1e-9),
435            "farther {farther}"
436        );
437
438        // 90 deg phase dims a diffuse sphere by 2.5 log10(pi) ~= 1.2429 mag.
439        let quarter = satellite_visual_magnitude(1000.0, 90.0, 0.0, 1000.0).expect("valid");
440        assert!(
441            close(quarter, 1.242_874_681_735_334_7, 1e-9),
442            "quarter {quarter}"
443        );
444    }
445
446    #[test]
447    fn visual_magnitude_phase_is_monotonic_and_clamped() {
448        // Fainter as the phase angle opens up.
449        let full = satellite_visual_magnitude(1000.0, 0.0, -1.3, 1000.0).expect("valid");
450        let half = satellite_visual_magnitude(1000.0, 90.0, -1.3, 1000.0).expect("valid");
451        let thin = satellite_visual_magnitude(1000.0, 150.0, -1.3, 1000.0).expect("valid");
452        assert!(full < half && half < thin, "{full} {half} {thin}");
453
454        // Phase angle is clamped to the physical [0, 180] range.
455        let clamped = satellite_visual_magnitude(1000.0, 200.0, -1.3, 1000.0).expect("valid");
456        let at_180 = satellite_visual_magnitude(1000.0, 180.0, -1.3, 1000.0).expect("valid");
457        assert!(close(clamped, at_180, 1e-12), "clamp {clamped} {at_180}");
458    }
459
460    #[test]
461    fn visual_magnitude_rejects_nonpositive_range() {
462        let err = satellite_visual_magnitude(0.0, 0.0, 0.0, 1000.0).unwrap_err();
463        assert_eq!(
464            err,
465            ObservationError::InvalidInput {
466                field: "range_km",
467                reason: "not positive"
468            }
469        );
470    }
471
472    #[test]
473    fn sub_observer_point_pole_along_z() {
474        // Body north pole along ICRF +z (alpha0 = 0, delta0 = 90), prime
475        // meridian W = 0. Then body-fixed +x points to ICRF +y.
476        let on_pm = sub_observer_point([0.0, 1.0, 0.0], 0.0, 90.0, 0.0).expect("valid");
477        assert!(
478            close(on_pm.latitude_deg, 0.0, 1e-9),
479            "lat {}",
480            on_pm.latitude_deg
481        );
482        assert!(
483            close(on_pm.longitude_deg, 0.0, 1e-9),
484            "lon {}",
485            on_pm.longitude_deg
486        );
487
488        // An observer over the rotation pole sees the pole as central.
489        let polar = sub_observer_point([0.0, 0.0, 1.0], 0.0, 90.0, 0.0).expect("valid");
490        assert!(
491            close(polar.latitude_deg, 90.0, 1e-9),
492            "polar lat {}",
493            polar.latitude_deg
494        );
495
496        // ICRF +x is 90 deg of longitude before the prime meridian.
497        let before = sub_observer_point([1.0, 0.0, 0.0], 0.0, 90.0, 0.0).expect("valid");
498        assert!(
499            close(before.longitude_deg, -90.0, 1e-9),
500            "lon {}",
501            before.longitude_deg
502        );
503    }
504
505    #[test]
506    fn sub_observer_point_prime_meridian_rotation() {
507        // Rotating the prime meridian by +90 deg shifts the sub-observer
508        // longitude by -90 deg.
509        let point = sub_observer_point([0.0, 1.0, 0.0], 0.0, 90.0, 90.0).expect("valid");
510        assert!(
511            close(point.longitude_deg, -90.0, 1e-9),
512            "lon {}",
513            point.longitude_deg
514        );
515    }
516
517    #[test]
518    fn sub_observer_point_iau_mars_orientation() {
519        // Mars IAU pole (alpha0 = 317.68, delta0 = 52.89) with W = 176 and an
520        // observer toward ICRF +x. Reference value from the IAU rotation matrix.
521        let point = sub_observer_point([1.0, 0.0, 0.0], 317.68, 52.89, 176.0).expect("valid");
522        assert!(
523            close(point.latitude_deg, 26.494_542_592_970_532, 1e-9),
524            "lat {}",
525            point.latitude_deg
526        );
527        assert!(
528            close(point.longitude_deg, 142.788_019_764_132_46, 1e-9),
529            "lon {}",
530            point.longitude_deg
531        );
532    }
533
534    #[test]
535    fn sub_observer_point_rejects_zero_vector() {
536        let err = sub_observer_point([0.0, 0.0, 0.0], 0.0, 90.0, 0.0).unwrap_err();
537        assert_eq!(
538            err,
539            ObservationError::InvalidInput {
540                field: "observer_from_body",
541                reason: "zero vector"
542            }
543        );
544    }
545}