use crate::coordinates::centers::Geodetic;
use crate::coordinates::frames::ECEF;
use crate::qtty::{Degrees, Hectopascals, Kelvins, Meters};
use alloc::borrow::{Cow, ToOwned};
use alloc::string::{String, ToString};
use alloc::vec;
use alloc::vec::Vec;
#[cfg(feature = "serde")]
#[cfg(feature = "std")]
use std::path::{Path, PathBuf};
#[cfg(feature = "serde")]
#[path = "observatory_schema.rs"]
mod observatory_schema;
#[cfg(all(test, feature = "serde"))]
const BUNDLED_CATALOG_TOML: &str = include_str!("../../data/observatories.toml");
#[derive(Debug, Clone, PartialEq)]
pub struct Observatory {
pub name: Cow<'static, str>,
pub geodetic: Geodetic<ECEF>,
pub reference_pressure: Hectopascals,
pub reference_temperature: Option<Kelvins>,
pub reference_relative_humidity: Option<f64>,
}
impl Observatory {
#[inline]
pub const fn geodetic(&self) -> Geodetic<ECEF> {
self.geodetic
}
#[cfg(feature = "serde")]
fn from_dto(record: observatory_schema::ObservatoryDto) -> Self {
Self {
name: Cow::Owned(record.name),
geodetic: Geodetic::new_raw(
Degrees::new(record.longitude_deg),
Degrees::new(record.latitude_deg),
Meters::new(record.height_m),
),
reference_pressure: Hectopascals::new(record.reference_pressure_hpa),
reference_temperature: record.reference_temperature_k.map(Kelvins::new),
reference_relative_humidity: record.reference_relative_humidity,
}
}
}
include!(concat!(env!("OUT_DIR"), "/observatories_generated.rs"));
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ObservatoryCatalog {
observatories: Vec<Observatory>,
}
impl ObservatoryCatalog {
pub fn builtin() -> Self {
Self {
observatories: generated_builtin_observatories(),
}
}
pub fn len(&self) -> usize {
self.observatories.len()
}
pub fn is_empty(&self) -> bool {
self.observatories.is_empty()
}
pub fn iter(&self) -> impl ExactSizeIterator<Item = &Observatory> {
self.observatories.iter()
}
pub fn as_slice(&self) -> &[Observatory] {
&self.observatories
}
pub fn get(&self, name: &str) -> Option<&Observatory> {
self.observatories
.iter()
.find(|observatory| observatory.name == name)
}
pub fn extend(&mut self, other: Self) -> Result<(), ObservatoryCatalogError> {
for observatory in &other.observatories {
if self.get(observatory.name.as_ref()).is_some() {
return Err(ObservatoryCatalogError::DuplicateName {
name: observatory.name.to_string(),
});
}
}
self.observatories.extend(other.observatories);
Ok(())
}
#[cfg(feature = "serde")]
pub fn from_toml(input: &str) -> Result<Self, ObservatoryCatalogError> {
let catalog = observatory_schema::parse_catalog(input)?;
observatory_schema::validate_catalog(&catalog)
.map_err(ObservatoryCatalogError::from_validation)?;
Ok(Self {
observatories: catalog
.observatory
.into_iter()
.map(Observatory::from_dto)
.collect(),
})
}
#[cfg(feature = "serde")]
pub fn from_path(path: impl AsRef<Path>) -> Result<Self, ObservatoryCatalogError> {
let path = path.as_ref();
let input =
std::fs::read_to_string(path).map_err(|source| ObservatoryCatalogError::Read {
path: path.to_owned(),
source,
})?;
Self::from_toml(&input)
}
}
impl<'a> IntoIterator for &'a ObservatoryCatalog {
type Item = &'a Observatory;
type IntoIter = core::slice::Iter<'a, Observatory>;
fn into_iter(self) -> Self::IntoIter {
self.observatories.iter()
}
}
#[derive(Debug, thiserror::Error)]
pub enum ObservatoryCatalogError {
#[cfg(feature = "serde")]
#[error("failed to read observatory catalog `{path}`: {source}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[cfg(feature = "serde")]
#[error("invalid observatory catalog TOML: {0}")]
Toml(#[from] toml::de::Error),
#[cfg(feature = "serde")]
#[error("invalid observatory record {record} (`{name}`), field `{field}`: {reason}")]
InvalidField {
record: usize,
name: String,
field: &'static str,
reason: String,
},
#[error("duplicate observatory name `{name}`")]
DuplicateName {
name: String,
},
}
#[cfg(feature = "serde")]
impl ObservatoryCatalogError {
fn from_validation(error: observatory_schema::ValidationError) -> Self {
Self::InvalidField {
record: error.record,
name: error.name,
field: error.field,
reason: error.reason,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "serde")]
use core::error::Error as _;
#[test]
fn builtin_catalog_is_ordered_and_uses_public_model() {
let catalog = ObservatoryCatalog::builtin();
let sites: &[Observatory] = catalog.as_slice();
assert!(sites.len() >= 4);
for expected in [
EL_PARANAL,
ROQUE_DE_LOS_MUCHACHOS,
MAUNA_KEA,
LA_SILLA_OBSERVATORY,
] {
assert_eq!(catalog.get(expected.name.as_ref()), Some(&expected));
}
}
#[cfg(feature = "serde")]
fn one_record(overrides: &str) -> String {
format!(
"[[observatory]]\nname = \"Test Site\"\nlongitude_deg = 1.0\nlatitude_deg = 2.0\nheight_m = 3.0\nreference_pressure_hpa = 900.0\n{overrides}"
)
}
#[cfg(feature = "serde")]
#[test]
fn canonical_toml_matches_generated_compatibility_constants() {
let parsed = ObservatoryCatalog::from_toml(BUNDLED_CATALOG_TOML).unwrap();
assert_eq!(parsed, ObservatoryCatalog::builtin());
}
#[cfg(feature = "serde")]
#[test]
fn custom_observatory_and_optional_atmosphere_load() {
let catalog = ObservatoryCatalog::from_toml(&one_record(
"reference_temperature_k = 280.5\nreference_relative_humidity = 0.42\n",
))
.unwrap();
let site: &Observatory = catalog.get("Test Site").unwrap();
assert_eq!(site.geodetic.lon.value(), 1.0);
assert_eq!(site.geodetic.lat.value(), 2.0);
assert_eq!(site.geodetic.height.value(), 3.0);
assert_eq!(site.reference_pressure.value(), 900.0);
assert_eq!(site.reference_temperature.unwrap().value(), 280.5);
assert_eq!(site.reference_relative_humidity, Some(0.42));
}
#[cfg(feature = "serde")]
#[test]
fn optional_atmosphere_can_be_omitted() {
let catalog = ObservatoryCatalog::from_toml(&one_record("")).unwrap();
let site = catalog.get("Test Site").unwrap();
assert!(site.reference_temperature.is_none());
assert!(site.reference_relative_humidity.is_none());
}
#[cfg(feature = "serde")]
#[test]
fn rejects_invalid_scientific_values() {
for (field, valid, invalid) in [
("longitude_deg", "1.0", "181.0"),
("latitude_deg", "2.0", "-91.0"),
("height_m", "3.0", "10001.0"),
("reference_pressure_hpa", "900.0", "0.0"),
] {
let input = one_record("").replacen(
&format!("{field} = {valid}"),
&format!("{field} = {invalid}"),
1,
);
let error = ObservatoryCatalog::from_toml(&input).unwrap_err();
assert!(error.to_string().contains(field), "{error}");
}
for extra in [
"reference_temperature_k = 0.0\n",
"reference_relative_humidity = 1.1\n",
] {
let error = ObservatoryCatalog::from_toml(&one_record(extra)).unwrap_err();
assert!(error.to_string().contains("reference_"), "{error}");
}
}
#[cfg(feature = "serde")]
#[test]
fn rejects_duplicate_names() {
let input = format!("{}{}", one_record(""), one_record(""));
let error = ObservatoryCatalog::from_toml(&input).unwrap_err();
assert!(error.to_string().contains("duplicate observatory name"));
}
#[cfg(feature = "serde")]
#[test]
fn malformed_toml_is_useful() {
let error = ObservatoryCatalog::from_toml("[[observatory]\n").unwrap_err();
assert!(error.to_string().contains("TOML"));
assert!(error.source().is_some());
}
#[cfg(feature = "serde")]
#[test]
fn path_errors_include_the_path() {
let path = Path::new("definitely-missing-observatory-catalog.toml");
let error = ObservatoryCatalog::from_path(path).unwrap_err();
assert!(error.to_string().contains(path.to_str().unwrap()));
}
#[cfg(feature = "serde")]
#[test]
fn external_path_loads_unknown_observatory() {
let path = std::env::temp_dir().join(format!(
"siderust-observatories-{}-{}.toml",
std::process::id(),
std::thread::current().name().unwrap_or("test")
));
std::fs::write(&path, one_record("")).unwrap();
let catalog = ObservatoryCatalog::from_path(&path).unwrap();
std::fs::remove_file(&path).unwrap();
assert_eq!(catalog.get("Test Site").unwrap().name, "Test Site");
}
#[cfg(feature = "serde")]
#[test]
fn extend_appends_unknown_observatories_and_rejects_duplicates() {
let mut catalog = ObservatoryCatalog::builtin();
let baseline = catalog.len();
let extension = ObservatoryCatalog::from_toml(&one_record("")).unwrap();
catalog.extend(extension).unwrap();
assert_eq!(catalog.len(), baseline + 1);
assert_eq!(catalog.get("Test Site").unwrap().name, "Test Site");
let duplicate = ObservatoryCatalog::from_toml(
"[[observatory]]\nname = \"El Paranal Observatory\"\nlongitude_deg = 1.0\nlatitude_deg = 2.0\nheight_m = 3.0\nreference_pressure_hpa = 900.0\n",
)
.unwrap();
let error = catalog.extend(duplicate).unwrap_err();
assert!(error.to_string().contains("duplicate observatory name"));
assert!(error.to_string().contains("El Paranal Observatory"));
}
}