Skip to main content

assist_rs/
observatory.rs

1//! MPC observatory code table.
2//!
3//! Parses `obscodes_extended.json` from the `mpc-obscodes` package into a
4//! lookup table mapping 3-character MPC codes to geodetic parallax
5//! coefficients.
6//!
7//! An [`EarthOrientation`] kernel must be attached via
8//! [`ObservatoryTable::with_earth_orientation`] before ground-based
9//! observatory states can be computed. The kernel drives the ITRF93 → ICRF
10//! rotation from a binary PCK file (matching JPL Horizons to ~μas).
11//! Looking up a ground-based observatory without an attached kernel
12//! returns `Error::MissingEarthOrientation`.
13
14use std::collections::HashMap;
15use std::path::Path;
16use std::sync::Arc;
17
18use crate::earth_orientation::EarthOrientation;
19use crate::{Error, Result};
20
21/// A single observatory's parallax coefficients.
22#[derive(Debug, Clone)]
23pub struct ObservatoryEntry {
24    /// Longitude in degrees (NaN for space-based observatories).
25    pub longitude_deg: f64,
26    /// cos(geocentric latitude) × (ρ/R_eq).
27    pub cos_lat: f64,
28    /// sin(geocentric latitude) × (ρ/R_eq).
29    pub sin_lat: f64,
30    /// Observatory name.
31    pub name: String,
32}
33
34impl ObservatoryEntry {
35    /// True if this is a space-based or geocentric observatory (no surface coordinates).
36    pub fn is_space_based(&self) -> bool {
37        self.longitude_deg.is_nan() || (self.cos_lat == 0.0 && self.sin_lat == 0.0)
38    }
39}
40
41/// Lookup table from MPC observatory code to parallax coefficients.
42///
43/// Optionally carries an [`EarthOrientation`] kernel that rotates ground-
44/// based observatory positions from ITRF93 into ICRF. Without one, queries
45/// for ground observatories return `Error::MissingEarthOrientation` rather
46/// than falling back to an approximation.
47#[derive(Debug, Clone)]
48pub struct ObservatoryTable {
49    entries: HashMap<String, ObservatoryEntry>,
50    earth_orientation: Option<Arc<EarthOrientation>>,
51}
52
53impl ObservatoryTable {
54    /// Load from `obscodes_extended.json`.
55    ///
56    /// Expected format: JSON object whose keys are MPC observatory codes.
57    /// Each value is one of:
58    ///
59    /// * Ground/geocentric — has all four fields: `Longitude` (deg), `cos`
60    ///   (cos(geocentric_lat) × ρ/R_eq), `sin` (same with sin), `Name`.
61    /// * Space-based — has only `Name`; no surface coordinates.
62    ///
63    /// Partial entries (e.g. `Longitude` without `cos`/`sin`) are rejected
64    /// rather than silently filled with NaN/zero defaults.
65    pub fn from_json(path: &Path) -> Result<Self> {
66        let data = std::fs::read_to_string(path).map_err(|e| {
67            Error::Io(std::io::Error::new(
68                e.kind(),
69                format!("{}: {e}", path.display()),
70            ))
71        })?;
72        let raw: HashMap<String, serde_json::Value> = serde_json::from_str(&data)
73            .map_err(|e| Error::Other(format!("Failed to parse observatory JSON: {e}")))?;
74
75        let mut entries = HashMap::with_capacity(raw.len());
76        for (code, val) in &raw {
77            entries.insert(code.clone(), parse_entry(code, val)?);
78        }
79
80        Ok(Self {
81            entries,
82            earth_orientation: None,
83        })
84    }
85
86    /// Attach an [`EarthOrientation`] so ground-observer states can be
87    /// rotated from ITRF93 into ICRF.
88    pub fn with_earth_orientation(mut self, eo: EarthOrientation) -> Self {
89        self.earth_orientation = Some(Arc::new(eo));
90        self
91    }
92
93    /// The attached Earth orientation kernel, if any.
94    pub(crate) fn earth_orientation(&self) -> Option<&EarthOrientation> {
95        self.earth_orientation.as_deref()
96    }
97
98    /// Look up an observatory by its MPC code.
99    pub fn get(&self, code: &str) -> Option<&ObservatoryEntry> {
100        self.entries.get(code)
101    }
102
103    /// Number of entries.
104    pub fn len(&self) -> usize {
105        self.entries.len()
106    }
107
108    pub fn is_empty(&self) -> bool {
109        self.entries.is_empty()
110    }
111}
112
113fn parse_entry(code: &str, val: &serde_json::Value) -> Result<ObservatoryEntry> {
114    let obj = val
115        .as_object()
116        .ok_or_else(|| Error::Other(format!("observatory entry {code:?}: expected JSON object")))?;
117
118    let name = obj
119        .get("Name")
120        .ok_or_else(|| Error::Other(format!("observatory entry {code:?}: missing Name")))?
121        .as_str()
122        .ok_or_else(|| Error::Other(format!("observatory entry {code:?}: Name is not a string")))?
123        .to_string();
124
125    let has_lon = obj.contains_key("Longitude");
126    let has_cos = obj.contains_key("cos");
127    let has_sin = obj.contains_key("sin");
128
129    match (has_lon, has_cos, has_sin) {
130        (false, false, false) => Ok(ObservatoryEntry {
131            longitude_deg: f64::NAN,
132            cos_lat: 0.0,
133            sin_lat: 0.0,
134            name,
135        }),
136        (true, true, true) => {
137            let longitude_deg = parse_f64(obj, "Longitude", code)?;
138            let cos_lat = parse_f64(obj, "cos", code)?;
139            let sin_lat = parse_f64(obj, "sin", code)?;
140            Ok(ObservatoryEntry {
141                longitude_deg,
142                cos_lat,
143                sin_lat,
144                name,
145            })
146        }
147        _ => Err(Error::Other(format!(
148            "observatory entry {code:?}: partial surface coordinates \
149             (Longitude={has_lon}, cos={has_cos}, sin={has_sin}); \
150             ground entries need all three, space-based entries need none"
151        ))),
152    }
153}
154
155fn parse_f64(
156    obj: &serde_json::Map<String, serde_json::Value>,
157    key: &str,
158    code: &str,
159) -> Result<f64> {
160    obj.get(key)
161        .and_then(|v| v.as_f64())
162        .ok_or_else(|| Error::Other(format!("observatory entry {code:?}: {key} is not a number")))
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use std::io::Write;
169
170    fn write_json(body: &str) -> tempfile::NamedTempFile {
171        let mut f = tempfile::NamedTempFile::new().unwrap();
172        f.write_all(body.as_bytes()).unwrap();
173        f
174    }
175
176    #[test]
177    fn parses_ground_and_space_based_entries() {
178        let f = write_json(
179            r#"{
180              "500": {"Longitude": 0.0, "cos": 0.0, "sin": 0.0, "Name": "Geocentric"},
181              "I11": {"Longitude": 289.26345, "cos": 0.86502, "sin": -0.500901, "Name": "Gemini South"},
182              "250": {"Name": "HST"}
183            }"#,
184        );
185        let t = ObservatoryTable::from_json(f.path()).unwrap();
186        assert_eq!(t.len(), 3);
187        assert!(!t.get("I11").unwrap().is_space_based());
188        assert!(t.get("250").unwrap().is_space_based());
189        assert!(t.get("500").unwrap().is_space_based()); // geocentric flagged via zero cos/sin
190    }
191
192    #[test]
193    fn rejects_partial_surface_coordinates() {
194        let f = write_json(r#"{"X": {"Longitude": 1.0, "cos": 0.5, "Name": "missing sin"}}"#);
195        let err = ObservatoryTable::from_json(f.path()).unwrap_err();
196        assert!(err.to_string().contains("partial surface coordinates"));
197    }
198
199    #[test]
200    fn rejects_missing_name() {
201        let f = write_json(r#"{"X": {"Longitude": 1.0, "cos": 0.5, "sin": 0.5}}"#);
202        let err = ObservatoryTable::from_json(f.path()).unwrap_err();
203        assert!(err.to_string().contains("missing Name"));
204    }
205
206    #[test]
207    fn rejects_non_numeric_coords() {
208        let f =
209            write_json(r#"{"X": {"Longitude": "deg", "cos": 0.5, "sin": 0.5, "Name": "broken"}}"#);
210        let err = ObservatoryTable::from_json(f.path()).unwrap_err();
211        assert!(err.to_string().contains("Longitude is not a number"));
212    }
213}