photom 0.4.0

Rust library for loading, structuring and querying astronomical observation datasets — with trajectory grouping, multi-observer support, and efficient lookups.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
//! Observer metadata and geodetic conversion utilities.
//!
//! This module defines the [`Observer`] struct, which represents a ground-based
//! observatory site, together with the two constructors that create one from
//! geodetic or from pre-computed geocentric parallax coordinates.  It also
//! exposes the low-level geodetic conversion helpers
//! [`geodetic_to_parallax`] and the error type [`ObserverError`] that covers
//! all failure modes arising during observer
//! construction.
//!
//! Two sub-modules extend this functionality:
//!
//! - [`error_model`] — astrometric bias/RMS tables (FCCT14, CBM10, VFCC17).
//! - [`mpc`] — MPC observatory code types and the network fetch routine.
//!
//! ## Public items
//!
//! | Item | Kind | Description |
//! |------|------|-------------|
//! | [`Observer`] | struct | Ground-based observatory site with parallax constants |
//! | [`ObserverError`] | enum | Errors arising during observer construction |
//! | [`geodetic_to_parallax`] | fn | Geodetic-to-geocentric parallax conversion (radians) |

pub mod dataset;
pub mod error_model;
pub mod mpc;

use std::fmt::{self, Display};

use thiserror::Error;

use ordered_float::NotNan;

use crate::{
    Degrees, Meters, Radians, ToNotNan,
    constants::{EARTH_MAJOR_AXIS, EARTH_MINOR_AXIS},
};

/// A ground-based observatory site represented by its geocentric parallax constants.
///
/// The three core fields — [`longitude`](Observer::longitude),
/// [`rho_cos_phi`](Observer::rho_cos_phi), and
/// [`rho_sin_phi`](Observer::rho_sin_phi) — follow the standard MPC
/// parallax convention: $\rho\cos\phi'$ and $\rho\sin\phi'$ are
/// dimensionless quantities expressed in units of the Earth's equatorial
/// radius.
///
/// Use [`Observer::new`] to build from geodetic latitude and elevation, or
/// [`Observer::from_parallax`] to supply the parallax constants directly.
#[derive(Debug, PartialEq, Eq, Hash, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Observer {
    /// Geodetic longitude in **radians** east of Greenwich.
    pub longitude: NotNan<Radians>,

    /// $\rho\cos\phi'$ — geocentric parallax constant, in **Earth radii** (dimensionless).
    pub rho_cos_phi: NotNan<f64>,

    /// $\rho\sin\phi'$ — geocentric parallax constant, in **Earth radii** (dimensionless).
    pub rho_sin_phi: NotNan<f64>,

    /// Optional human-readable site name.
    pub name: Option<String>,

    /// Right ascension measurement accuracy in **radians** (optional).
    pub ra_accuracy: Option<NotNan<Radians>>,

    /// Declination measurement accuracy in **radians** (optional).
    pub dec_accuracy: Option<NotNan<Radians>>,
}

/// Errors that can arise when constructing an [`Observer`].
#[derive(Debug, Error)]
pub enum ObserverError {
    /// A floating-point input was `NaN`, which cannot be stored in a [`NotNan`] wrapper.
    #[error("Invalid floating-point value (NaN encountered): {0}")]
    InvalidFloatValue(ordered_float::FloatIsNan),

    /// The requested MPC code was not found in the observatory lookup table.
    #[error("MPC code not found in lookup: {0:?}")]
    MpcCodeNotFound([u8; 3]),

    /// The supplied string is not a valid three-character MPC code.
    #[error("Invalid MPC code format: {0}")]
    InvalidMpcCode(String),

    /// An observer row in the MPC data was missing its three-character code.
    #[error("Missing MPC code for observer row")]
    MissingMpcCode,
}

/// Converts [`ordered_float::FloatIsNan`] into [`ObserverError::InvalidFloatValue`].
///
/// This blanket conversion lets the `?` operator be used when wrapping
/// floating-point values in [`NotNan`] inside the [`Observer`] constructors.
impl From<ordered_float::FloatIsNan> for ObserverError {
    fn from(e: ordered_float::FloatIsNan) -> Self {
        ObserverError::InvalidFloatValue(e)
    }
}

impl Observer {
    /// Construct an [`Observer`] from geodetic latitude and elevation.
    ///
    /// Converts `(latitude, elevation)` into geocentric parallax coordinates
    /// $(\rho\cos\phi', \rho\sin\phi')$ using [`geodetic_to_parallax`], then
    /// stores all fields as [`NotNan`]-wrapped values.
    ///
    /// # Arguments
    ///
    /// - `longitude` — geodetic longitude in **radians** (east positive).
    /// - `latitude` — geodetic latitude in **radians**.
    /// - `elevation` — height above the reference ellipsoid in **meters**.
    /// - `name` — optional human-readable site name.
    /// - `ra_accuracy` — optional RA measurement accuracy in **radians**.
    /// - `dec_accuracy` — optional declination measurement accuracy in **radians**.
    ///
    /// # Returns
    ///
    /// A new [`Observer`] with the computed parallax constants stored as
    /// [`NotNan`] values.
    ///
    /// # Errors
    ///
    /// Returns [`ObserverError::InvalidFloatValue`] if any of the supplied
    /// floating-point values is `NaN`.
    pub fn new(
        longitude: Radians,
        latitude: Radians,
        elevation: Meters,
        name: Option<String>,
        ra_accuracy: Option<Radians>,
        dec_accuracy: Option<Radians>,
    ) -> Result<Observer, ObserverError> {
        let (rho_cos_phi, rho_sin_phi) = geodetic_to_parallax(latitude, elevation);

        Observer::from_parallax(
            longitude,
            rho_cos_phi,
            rho_sin_phi,
            name,
            ra_accuracy,
            dec_accuracy,
        )
    }

    /// Construct an [`Observer`] from pre-computed geocentric parallax coordinates.
    ///
    /// This constructor skips the geodetic-to-parallax conversion and stores
    /// the supplied $(\rho\cos\phi', \rho\sin\phi')$ values directly.  Use
    /// this path when the parallax constants are already available — for
    /// example, when reading MPC observatory data that lists them explicitly.
    ///
    /// # Arguments
    ///
    /// - `longitude` — geodetic longitude in **radians** (east positive).
    /// - `rho_cos_phi` — $\rho\cos\phi'$ in **Earth radii** (dimensionless).
    /// - `rho_sin_phi` — $\rho\sin\phi'$ in **Earth radii** (dimensionless).
    /// - `name` — optional human-readable site name.
    /// - `ra_accuracy` — optional RA measurement accuracy in **radians**.
    /// - `dec_accuracy` — optional declination measurement accuracy in **radians**.
    ///
    /// # Returns
    ///
    /// A new [`Observer`] with the supplied parallax constants stored as
    /// [`NotNan`] values.
    ///
    /// # Errors
    ///
    /// Returns [`ObserverError::InvalidFloatValue`] if any of the supplied
    /// floating-point values is `NaN`.
    pub fn from_parallax(
        longitude: Radians,
        rho_cos_phi: f64,
        rho_sin_phi: f64,
        name: Option<String>,
        ra_accuracy: Option<f64>,
        dec_accuracy: Option<f64>,
    ) -> Result<Observer, ObserverError> {
        Ok(Observer {
            longitude: NotNan::try_from(longitude)?,
            rho_cos_phi: NotNan::try_from(rho_cos_phi)?,
            rho_sin_phi: NotNan::try_from(rho_sin_phi)?,
            name,
            ra_accuracy: ra_accuracy.to_notnan()?,
            dec_accuracy: dec_accuracy.to_notnan()?,
        })
    }

    /// Recover geodetic latitude and ellipsoidal height (WGS-84) from parallax constants.
    ///
    /// Inverts the stored parallax coordinates `(ρ·cosφ, ρ·sinφ)` to the **geodetic**
    /// latitude `φ` (degrees) and the ellipsoidal height `h` (meters) above the
    /// WGS-84 reference ellipsoid. The computation uses **Bowring’s closed-form**
    /// formula (no iteration), which is usually sufficient for double-precision
    /// accuracy at the centimeter level or better.
    ///
    /// Units & model
    /// -------------
    /// * Inputs: `ρ·cosφ` and `ρ·sinφ` are dimensionless, expressed in **Earth radii**
    ///   (normalized by the equatorial radius). They are scaled internally by `a`
    ///   (the equatorial radius) to meters.
    /// * Output: latitude in **degrees**, height in **meters** (ellipsoidal height, not geoid/orthometric).
    /// * Ellipsoid: WGS-84 radii from `constants.rs` (`EARTH_MAJOR_AXIS` = `a`, `EARTH_MINOR_AXIS` = `b`).
    ///   If you prefer exact GRS-80 reproduction, use consistent `b` there; the difference vs WGS-84 is sub-millimetric.
    ///
    /// Notes
    /// -----
    /// * This routine **does not** compute the geodetic longitude; it only returns `(lat, h)`.
    ///   Your `Observer` already stores the geodetic longitude independently.
    /// * Numerical robustness is good across latitudes, including near the poles.
    /// * If you require bit-for-bit parity with an external reference using a different ellipsoid,
    ///   ensure `a`/`b` match that reference.
    ///
    /// Arguments
    /// -----------------
    /// * None.
    ///
    /// Return
    /// ----------
    /// * `(geodetic_latitude_deg, height_meters)` — latitude in degrees, ellipsoidal height in meters.
    ///
    /// See also
    /// ------------
    /// * [`geodetic_to_parallax`] – Forward conversion used at construction.
    /// * [`Observer::from_parallax`] – Builds an observer from `(ρ·cosφ, ρ·sinφ)`.
    /// * `constants::EARTH_MAJOR_AXIS` / `constants::EARTH_MINOR_AXIS` – Ellipsoid radii used here.
    pub fn geodetic_lat_height_wgs84(&self) -> (Radians, Meters) {
        let a = EARTH_MAJOR_AXIS;
        let b = EARTH_MINOR_AXIS;
        let e2 = 1.0 - (b * b) / (a * a);
        let ep2 = (a * a) / (b * b) - 1.0;

        let p = self.rho_cos_phi.into_inner() * a; // distance in equatorial plane [m]
        let z = self.rho_sin_phi.into_inner() * a; // z [m]

        // Bowring’s formula:
        let theta = (z * a).atan2(p * b);
        let st = theta.sin();
        let ct = theta.cos();
        let phi = (z + ep2 * b * st.powi(3)).atan2(p - e2 * a * ct.powi(3));

        let s = phi.sin();
        let n = a / (1.0 - e2 * s * s).sqrt();
        let h = p / phi.cos() - n;

        (phi, h)
    }

    /// Geocentric latitude derived from the parallax constants.
    ///
    /// Computes the angle between the equatorial plane and the direction from
    /// Earth's center to the observer, expressed in degrees.
    ///
    /// This differs from the geodetic latitude, which is defined relative to
    /// the WGS-84 ellipsoid normal. The two coincide only on a perfect sphere.
    ///
    /// $$\varphi_{\text{geo}} = \arctan\!\left(\frac{\rho\sin\varphi}{\rho\cos\varphi}\right)$$
    ///
    /// Return
    /// ------
    /// Geocentric latitude in degrees, in $[-90°, +90°]$.
    pub fn geocentric_lat_deg(&self) -> Degrees {
        let rc = self.rho_cos_phi.into_inner();
        let rs = self.rho_sin_phi.into_inner();
        rs.atan2(rc).to_degrees()
    }

    /// Geocentric distance (observer–Earth-center) in Earth radii.
    ///
    /// Derived from the IAU parallax constants $(\rho\cos\varphi,\, \rho\sin\varphi)$
    /// stored in the observer record. By definition these constants already encode
    /// the distance in units of the equatorial Earth radius $R_\oplus$, so no
    /// additional scaling is required.
    ///
    /// $$\rho = \sqrt{(\rho\cos\varphi)^2 + (\rho\sin\varphi)^2}$$
    ///
    /// Return
    /// ------
    /// Geocentric distance $\rho$ in Earth radii $R_\oplus$.
    pub fn geocentric_distance_earth_radii(&self) -> f64 {
        let rc = self.rho_cos_phi.into_inner();
        let rs = self.rho_sin_phi.into_inner();
        rc.hypot(rs)
    }
}

/// Convert geodetic latitude in **radians** and height in **meters** into
/// normalized parallax coordinates on the Earth.
///
/// The result accounts for the Earth's oblateness using the reference
/// ellipsoid defined by [`EARTH_MAJOR_AXIS`] (equatorial radius $a$, in
/// metres) and [`EARTH_MINOR_AXIS`] (polar radius $b$, in metres).
///
/// The standard geodetic-to-geocentric conversion is applied:
///
/// ```text
/// u        = atan( (b/a) · sin φ / cos φ )
/// ρ·sin φ' = (b/a) · sin u + (h/a) · sin φ
/// ρ·cos φ' = cos u + (h/a) · cos φ
/// ```
///
/// where $\varphi$ is the geodetic latitude and $h$ is the ellipsoidal height.
///
/// # Arguments
///
/// - `lat` — geodetic latitude of the observer in **radians**.
/// - `height` — observer's altitude above the reference ellipsoid in **meters**.
///
/// # Returns
///
/// A tuple `(rho_cos_phi, rho_sin_phi)` where:
///
/// - `rho_cos_phi` — $\rho\cos\phi'$, normalized projection on the equatorial plane.
/// - `rho_sin_phi` — $\rho\sin\phi'$, normalized projection on the polar axis.
pub fn geodetic_to_parallax(lat: Radians, height: Meters) -> (f64, f64) {
    // Ratio of the Earth's minor to major axis (flattening factor)
    let axis_ratio = EARTH_MINOR_AXIS / EARTH_MAJOR_AXIS;

    // Compute the auxiliary angle u (parametric latitude)
    // This corrects for the Earth's oblateness.
    let u = (lat.sin() * axis_ratio).atan2(lat.cos());

    // Compute the normalized distance along the polar axis
    let rho_sin_phi = axis_ratio * u.sin() + (height / EARTH_MAJOR_AXIS) * lat.sin();

    // Compute the normalized distance along the equatorial plane
    let rho_cos_phi = u.cos() + (height / EARTH_MAJOR_AXIS) * lat.cos();

    (rho_cos_phi, rho_sin_phi)
}

impl Display for Observer {
    /// Pretty-print an observer with optional verbose details using `{:#}` formatting.
    ///
    /// Default formatting (`{}`) prints a compact one-liner:
    /// `Name (lon: XX.XXXXXX°, lat: YY.YYYYYY° geodetic, elev: Z.ZZ km)`.
    ///
    /// Alternate formatting (`{:#}`) prints a multi-line detailed block including:
    /// - Parallax constants `(ρ·cosφ, ρ·sinφ)`,
    /// - Geocentric latitude `φ_geo` and geocentric distance `ρ` (Earth radii),
    /// - Astrometric 1-σ accuracies in arcseconds (if available).
    ///
    /// Arguments
    /// -----------------
    /// * `self`: The observer to format.
    ///
    /// Return
    /// ----------
    /// * A human-readable representation suitable for logs and diagnostics.
    ///
    /// See also
    /// ------------
    /// * [`Observer::geodetic_lat_height_wgs84`] – Geodetic latitude (deg) and ellipsoidal height (m).
    /// * [`geodetic_to_parallax`] – Forward conversion to `(ρ·cosφ, ρ·sinφ)`.
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let name = self.name.as_deref().unwrap_or("Unnamed");
        let (lat_deg, h_m) = self.geodetic_lat_height_wgs84();
        let lon_deg = self.longitude.into_inner();

        write!(
            f,
            "{name} (lon: {lon_deg:.6}°, lat: {lat_deg:.6}° geodetic, elev: {h_m:.2} m)"
        )?;

        if !f.alternate() {
            return Ok(());
        }

        let rc = self.rho_cos_phi.into_inner();
        let rs = self.rho_sin_phi.into_inner();

        let fmt_accuracy = |v: Option<NotNan<f64>>| {
            v.map_or_else(
                || "".to_string(),
                |v| format!("{:.3}", v.into_inner().to_degrees() * 3600.0),
            )
        };

        writeln!(f)?;
        writeln!(
            f,
            "  parallax: ρ·cosφ={rc:.9}, ρ·sinφ={rs:+.9}  |  φ_geo={:+.6}°  ρ={:.6} RE",
            self.geocentric_lat_deg(),
            self.geocentric_distance_earth_radii(),
        )?;
        write!(
            f,
            "  astrometric 1σ: RA={}, DEC={}",
            fmt_accuracy(self.ra_accuracy),
            fmt_accuracy(self.dec_accuracy),
        )
    }
}

#[cfg(test)]
mod observer_test {
    use super::*;

    fn to_dynamic_observer(
        longitude: Radians,
        latitude: Radians,
        height: Meters,
        name: Option<String>,
        ra_accuracy: Option<f64>,
        dec_accuracy: Option<f64>,
    ) -> Observer {
        Observer::new(longitude, latitude, height, name, ra_accuracy, dec_accuracy)
            .expect("Failed to create Observer")
    }

    #[test]
    fn test_observer_constructor() {
        let observer = to_dynamic_observer(0.0, 0.0, 0.0, None, None, None);
        assert_eq!(observer.longitude, 0.0);
        assert_eq!(observer.rho_cos_phi, 1.0);
        assert_eq!(observer.rho_sin_phi, 0.0);

        let observer = Observer::new(
            289.25058_f64.to_radians(),
            -30.2446_f64.to_radians(),
            2647.,
            Some("Rubin Observatory".to_string()),
            Some(0.0001),
            Some(0.0001),
        )
        .unwrap();

        assert_eq!(observer.longitude, 289.25058_f64.to_radians());
        assert_eq!(observer.rho_cos_phi, 0.8649760504617418);
        assert_eq!(observer.rho_sin_phi, -0.5009551027512434);
    }

    #[test]
    fn geodetic_to_parallax_test() {
        // latitude and height of Pan-STARRS 1, Haleakala
        let (pxy1, pz1) = geodetic_to_parallax(20.707233557_f64.to_radians(), 3067.694);
        assert_eq!(pxy1, 0.9362410003211518);
        assert_eq!(pz1, 0.35154299856304305);
    }

    #[cfg(test)]
    mod geodetic_inverse_tests {
        use super::*;
        use approx::assert_abs_diff_eq;

        /// Round-trip a single site through (lon, lat, h) -> parallax -> inverse
        /// and check that we recover the original geodetic latitude & height.
        ///
        /// Notes
        /// -----
        /// * `Observer::new` is given `h_m` in meters (as per current API usage).
        /// * `geodetic_lat_height_wgs84()` returns height in **meters**; we convert to meters.
        fn roundtrip_site(name: &str, lon_deg: Degrees, lat_deg: Degrees, h_m: Meters) {
            // Build observer (forward: geodetic -> parallax is done inside `Observer::new`)
            let obs = to_dynamic_observer(
                lon_deg.to_radians(),
                lat_deg.to_radians(),
                h_m,
                Some(name.to_string()),
                None,
                None,
            );

            // Inverse: parallax -> geodetic (WGS-84)
            let (lat_rec_rad, h_rec_m) = obs.geodetic_lat_height_wgs84();

            // Tolerances:
            // - Latitude: 1e-6 deg (~3.6 mas) – tight but should pass for double precision Bowring + 0–1 Newton step
            // - Height:   1e-2 m
            let tol_lat_deg = 1e-6;
            let tol_h_m = 1e-2;

            let lat_rec_deg = lat_rec_rad.to_degrees();

            assert_abs_diff_eq!(lat_rec_deg, lat_deg, epsilon = tol_lat_deg);
            assert_abs_diff_eq!(h_rec_m, h_m, epsilon = tol_h_m);
        }

        /// See also
        /// ------------
        /// * [`Observer::new`] – Forward geodetic->parallax conversion under test by round-trip.
        /// * [`Observer::from_parallax`] – Alternative constructor, if you want to inject ρ·cosφ/ρ·sinφ.
        /// * `geodetic_to_parallax` – The forward routine used internally by `Observer::new`.

        #[test]
        fn geodetic_roundtrip_known_observatories_wgs84() {
            // NOTE:
            // The heights below are commonly quoted "above sea level" (orthometric).
            // For pure algorithmic round-trip testing, that's acceptable because we feed
            // the same height into forward and inverse. If you want strict ellipsoidal
            // (h) values, substitute official WGS-84 heights here.
            let sites: &[(&str, Degrees, Degrees, Meters)] = &[
                // name,                      lon_deg (E+), lat_deg (N+), height_m
                ("Haleakala (PS1 I41)", -156.2575, 20.7075, 3055.0),
                ("Mauna Kea (CFHT)", -155.4700, 19.8261, 4205.0),
                ("ESO Paranal", -70.4025, -24.6252, 2635.0),
                ("Cerro Pachon (Rubin)", -70.7366, -30.2407, 2663.0),
                ("La Silla", -70.7346, -29.2613, 2400.0),
                ("Kitt Peak", -111.5967, 31.9583, 2096.0),
                ("Roque de los Muchachos", -17.8947, 28.7606, 2396.0),
            ];

            for (name, lon, lat, h_m) in sites.iter().copied() {
                roundtrip_site(name, lon, lat, h_m);
            }
        }

        #[test]
        fn geodetic_roundtrip_extremes_equator_and_pole() {
            // Equator, sea level
            roundtrip_site("Equator (0°, 0 m)", 0.0, 0.0, 0.0);

            // Near-North-Pole and Near-South-Pole with modest height
            roundtrip_site("Near North Pole", 0.0, 89.999, 1000.0);
            roundtrip_site("Near South Pole", 0.0, -89.999, 1000.0);
        }

        #[test]
        fn geodetic_roundtrip_high_altitude_and_negative() {
            // Very high site (simulate balloon/aircraft)
            roundtrip_site("High Alt 10 m", 10.0, 45.0, 10_000.0);

            // Negative height (below ellipsoid, synthetic but tests robustness)
            roundtrip_site("Below ellipsoid -50 m", -30.0, -10.0, -50.0);
        }
    }

    #[cfg(test)]
    mod observer_display_tests {
        use super::*;

        /// Convert arcseconds to radians.
        #[inline]
        fn arcsec_to_rad(as_val: f64) -> f64 {
            // 1 arcsec = π / (180 * 3600) rad
            std::f64::consts::PI / (180.0 * 3600.0) * as_val
        }

        /// Helper to build an Observer with optional RA/DEC accuracies (in arcseconds).
        fn make_dynamic_observer_with_acc(
            name: Option<&str>,
            lon_deg: f64,
            lat_deg: f64,
            elev_m: f64,
            ra_as: Option<f64>,
            dec_as: Option<f64>,
        ) -> Observer {
            let ra_sigma = ra_as.map(arcsec_to_rad);
            let dec_sigma = dec_as.map(arcsec_to_rad);

            to_dynamic_observer(
                lon_deg,
                lat_deg,
                elev_m, // elevation in meters
                name.map(|s| s.to_string()),
                ra_sigma,
                dec_sigma,
            )
        }

        /// Compact formatting must be a single line with name/lon/lat/elev.
        #[test]
        fn display_compact_single_line() {
            let obs = make_dynamic_observer_with_acc(Some("TestSite"), 10.0, 0.0, 0.0, None, None);

            let s = format!("{obs}");
            // Must not contain newlines in compact form
            assert!(
                !s.contains('\n'),
                "Compact format should be single-line, got:\n{s}"
            );

            // Must contain expected fragments (predictable numbers)
            assert!(
                s.contains("TestSite (lon: 10.000000°"),
                "Missing name/lon fragment. Got:\n{s}"
            );
            assert!(
                s.contains("lat: 0.000000° geodetic"),
                "Missing geodetic latitude fragment. Got:\n{s}"
            );
            assert!(
                s.contains("elev: 0.00 m"),
                "Missing elevation fragment (m). Got:\n{s}"
            );
        }

        /// Alternate formatting must be multi-line and include parallax & uncertainties.
        #[test]
        fn display_verbose_multiline_with_sections() {
            let obs = make_dynamic_observer_with_acc(
                Some("VerboseSite"),
                -70.0,
                -30.0,
                2400.0,
                None,
                None,
            );

            let s = format!("{obs:#}");

            // Must contain multiple lines and the expected section headers/fragments
            assert!(
                s.contains('\n'),
                "Verbose format should be multi-line. Got:\n{s}"
            );
            assert!(
                s.starts_with("VerboseSite (lon: -70.000000°"),
                "First line should start with site name and lon. Got:\n{s}"
            );
            assert!(
                s.contains("\n  parallax: ρ·cosφ="),
                "Missing 'parallax:' line. Got:\n{s}"
            );
            assert!(
                s.contains("φ_geo=") && s.contains("ρ="),
                "Missing φ_geo/ρ fragments. Got:\n{s}"
            );
            assert!(
                s.contains("\n  astrometric 1σ: RA=—, DEC=—"),
                "Missing astrometric 1σ line with em-dashes for None. Got:\n{s}"
            );
        }

        /// When RA/DEC accuracies are provided, they must be printed in arcseconds with three decimals.
        #[test]
        fn display_verbose_shows_ra_dec_sigmas() {
            // RA = 1.0″, DEC = 2.5″ (passed in arcseconds, converted to radians internally)
            let obs = make_dynamic_observer_with_acc(
                Some("AccSite"),
                0.0,
                0.0,
                0.0,
                Some(1.0),
                Some(2.5),
            );

            let s = format!("{obs:#}");

            assert!(
                s.contains("astrometric 1σ: RA=1.000″, DEC=2.500″"),
                "Expected RA/DEC sigma in arcseconds with 3 decimals. Got:\n{s}"
            );
        }

        /// Name fallback should be "Unnamed" when not provided.
        #[test]
        fn display_uses_unnamed_when_missing() {
            let obs = make_dynamic_observer_with_acc(None, 5.0, 0.0, 0.0, None, None);
            let s = format!("{obs}");
            assert!(
                s.starts_with("Unnamed (lon: 5.000000°"),
                "Expected 'Unnamed' fallback. Got:\n{s}"
            );
        }

        /// Basic numeric sanity: geodetic height is shown in kilometers in the display.
        /// For a 3055 m elevation, we expect ~3.055 km (rounded to 2 decimals).
        #[test]
        fn display_elevation_shown_in_km() {
            let obs = make_dynamic_observer_with_acc(
                Some("Haleakala-ish"),
                -156.2575,
                20.7075,
                3055.0,
                None,
                None,
            );

            let s = format!("{obs}");
            // Check the km conversion and rounding only; don't assert on latitude value here.
            assert!(
                s.contains("elev: 3055.00 m"),
                "Expected elevation ~3055 m rounded to 2 decimals. Got:\n{s}"
            );

            // Optional: ensure lon is printed correctly
            assert!(
                s.contains("lon: -156.257500°"),
                "Longitude formatting mismatch. Got:\n{s}"
            );
        }
    }
}