use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use crate::earth_orientation::EarthOrientation;
use crate::{Error, Result};
#[derive(Debug, Clone)]
pub struct ObservatoryEntry {
pub longitude_deg: f64,
pub cos_lat: f64,
pub sin_lat: f64,
pub name: String,
}
impl ObservatoryEntry {
pub fn is_space_based(&self) -> bool {
self.longitude_deg.is_nan() || (self.cos_lat == 0.0 && self.sin_lat == 0.0)
}
}
#[derive(Debug, Clone)]
pub struct ObservatoryTable {
entries: HashMap<String, ObservatoryEntry>,
earth_orientation: Option<Arc<EarthOrientation>>,
}
impl ObservatoryTable {
pub fn from_json(path: &Path) -> Result<Self> {
let data = std::fs::read_to_string(path).map_err(|e| {
Error::Io(std::io::Error::new(
e.kind(),
format!("{}: {e}", path.display()),
))
})?;
let raw: HashMap<String, serde_json::Value> = serde_json::from_str(&data)
.map_err(|e| Error::Other(format!("Failed to parse observatory JSON: {e}")))?;
let mut entries = HashMap::with_capacity(raw.len());
for (code, val) in &raw {
entries.insert(code.clone(), parse_entry(code, val)?);
}
Ok(Self {
entries,
earth_orientation: None,
})
}
pub fn with_earth_orientation(mut self, eo: EarthOrientation) -> Self {
self.earth_orientation = Some(Arc::new(eo));
self
}
pub(crate) fn earth_orientation(&self) -> Option<&EarthOrientation> {
self.earth_orientation.as_deref()
}
pub fn get(&self, code: &str) -> Option<&ObservatoryEntry> {
self.entries.get(code)
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
fn parse_entry(code: &str, val: &serde_json::Value) -> Result<ObservatoryEntry> {
let obj = val
.as_object()
.ok_or_else(|| Error::Other(format!("observatory entry {code:?}: expected JSON object")))?;
let name = obj
.get("Name")
.ok_or_else(|| Error::Other(format!("observatory entry {code:?}: missing Name")))?
.as_str()
.ok_or_else(|| Error::Other(format!("observatory entry {code:?}: Name is not a string")))?
.to_string();
let has_lon = obj.contains_key("Longitude");
let has_cos = obj.contains_key("cos");
let has_sin = obj.contains_key("sin");
match (has_lon, has_cos, has_sin) {
(false, false, false) => Ok(ObservatoryEntry {
longitude_deg: f64::NAN,
cos_lat: 0.0,
sin_lat: 0.0,
name,
}),
(true, true, true) => {
let longitude_deg = parse_f64(obj, "Longitude", code)?;
let cos_lat = parse_f64(obj, "cos", code)?;
let sin_lat = parse_f64(obj, "sin", code)?;
Ok(ObservatoryEntry {
longitude_deg,
cos_lat,
sin_lat,
name,
})
}
_ => Err(Error::Other(format!(
"observatory entry {code:?}: partial surface coordinates \
(Longitude={has_lon}, cos={has_cos}, sin={has_sin}); \
ground entries need all three, space-based entries need none"
))),
}
}
fn parse_f64(
obj: &serde_json::Map<String, serde_json::Value>,
key: &str,
code: &str,
) -> Result<f64> {
obj.get(key)
.and_then(|v| v.as_f64())
.ok_or_else(|| Error::Other(format!("observatory entry {code:?}: {key} is not a number")))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn write_json(body: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(body.as_bytes()).unwrap();
f
}
#[test]
fn parses_ground_and_space_based_entries() {
let f = write_json(
r#"{
"500": {"Longitude": 0.0, "cos": 0.0, "sin": 0.0, "Name": "Geocentric"},
"I11": {"Longitude": 289.26345, "cos": 0.86502, "sin": -0.500901, "Name": "Gemini South"},
"250": {"Name": "HST"}
}"#,
);
let t = ObservatoryTable::from_json(f.path()).unwrap();
assert_eq!(t.len(), 3);
assert!(!t.get("I11").unwrap().is_space_based());
assert!(t.get("250").unwrap().is_space_based());
assert!(t.get("500").unwrap().is_space_based()); }
#[test]
fn rejects_partial_surface_coordinates() {
let f = write_json(r#"{"X": {"Longitude": 1.0, "cos": 0.5, "Name": "missing sin"}}"#);
let err = ObservatoryTable::from_json(f.path()).unwrap_err();
assert!(err.to_string().contains("partial surface coordinates"));
}
#[test]
fn rejects_missing_name() {
let f = write_json(r#"{"X": {"Longitude": 1.0, "cos": 0.5, "sin": 0.5}}"#);
let err = ObservatoryTable::from_json(f.path()).unwrap_err();
assert!(err.to_string().contains("missing Name"));
}
#[test]
fn rejects_non_numeric_coords() {
let f =
write_json(r#"{"X": {"Longitude": "deg", "cos": 0.5, "sin": 0.5, "Name": "broken"}}"#);
let err = ObservatoryTable::from_json(f.path()).unwrap_err();
assert!(err.to_string().contains("Longitude is not a number"));
}
}