1use std::collections::HashMap;
15use std::path::Path;
16use std::sync::Arc;
17
18use crate::earth_orientation::EarthOrientation;
19use crate::{Error, Result};
20
21#[derive(Debug, Clone)]
23pub struct ObservatoryEntry {
24 pub longitude_deg: f64,
26 pub cos_lat: f64,
28 pub sin_lat: f64,
30 pub name: String,
32}
33
34impl ObservatoryEntry {
35 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#[derive(Debug, Clone)]
48pub struct ObservatoryTable {
49 entries: HashMap<String, ObservatoryEntry>,
50 earth_orientation: Option<Arc<EarthOrientation>>,
51}
52
53impl ObservatoryTable {
54 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 pub fn with_earth_orientation(mut self, eo: EarthOrientation) -> Self {
89 self.earth_orientation = Some(Arc::new(eo));
90 self
91 }
92
93 pub(crate) fn earth_orientation(&self) -> Option<&EarthOrientation> {
95 self.earth_orientation.as_deref()
96 }
97
98 pub fn get(&self, code: &str) -> Option<&ObservatoryEntry> {
100 self.entries.get(code)
101 }
102
103 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()); }
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}