use std::{path::PathBuf, time::Duration};
use ahash::AHashMap;
use thiserror::Error;
use ureq::Agent;
use crate::observer::{
Observer,
error_model::{ErrorModelData, get_bias_rms},
};
pub type MpcCode = [u8; 3];
pub type MpcCodeObs = AHashMap<MpcCode, Observer>;
#[derive(Error, Debug)]
pub enum MPCError {
#[error(transparent)]
UreqError(#[from] ureq::Error),
#[error("cache I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("could not determine platform cache directory")]
CacheDirUnavailable,
}
fn parse_f32(
s: &str,
slice: std::ops::Range<usize>,
code: &str,
) -> Result<f32, std::num::ParseFloatError> {
s.get(slice)
.unwrap_or_else(|| panic!("Failed to parse float for observer code: {code}"))
.trim()
.parse()
}
fn parse_remain(remain: &str, code: &str) -> Option<(f32, f32, f32, String)> {
let name = remain.get(27..)?.to_string();
let longitude = parse_f32(remain, 1..10, code).unwrap_or(0.0);
let cos = parse_f32(remain, 10..18, code).unwrap_or(0.0);
let sin = parse_f32(remain, 18..27, code).unwrap_or(0.0);
Some((longitude, cos, sin, name))
}
fn cache_file_path(cache_dir_override: Option<&std::path::Path>) -> Result<PathBuf, MPCError> {
if let Some(dir) = cache_dir_override {
return Ok(dir.join("mpc_obs.html"));
}
if let Ok(dir) = std::env::var("PHOTOM_MPC_CACHE_DIR") {
return Ok(PathBuf::from(dir).join("mpc_obs.html"));
}
let base = directories::BaseDirs::new().ok_or(MPCError::CacheDirUnavailable)?;
Ok(base.cache_dir().join("photom").join("mpc_obs.html"))
}
pub fn init_observatories(error_model: &ErrorModelData) -> Result<MpcCodeObs, MPCError> {
let config = Agent::config_builder()
.timeout_global(Some(Duration::from_secs(10)))
.build();
let agent: Agent = config.into();
init_observatories_impl(agent, error_model, None)
}
fn init_observatories_impl(
ureq_agent: Agent,
error_model: &ErrorModelData,
cache_dir_override: Option<&std::path::Path>,
) -> Result<MpcCodeObs, MPCError> {
let cache_path = cache_file_path(cache_dir_override)?;
let mpc_document = match std::fs::read_to_string(&cache_path) {
Ok(content) => content,
Err(_) => mpc_obs_request(ureq_agent, &cache_path)?,
};
parse_mpc_obs_result(&mpc_document, error_model)
}
fn mpc_obs_request(ureq_agent: Agent, cache_path: &std::path::Path) -> Result<String, MPCError> {
let fetched = ureq_agent
.get("https://minorplanetcenter.net/iau/lists/ObsCodes.html")
.call()?
.body_mut()
.read_to_string()?;
if let Some(parent) = cache_path.parent() {
std::fs::create_dir_all(parent)?;
let tmp_path = cache_path.with_extension("html.tmp");
std::fs::write(&tmp_path, fetched.as_bytes())?;
if let Err(e) = std::fs::rename(&tmp_path, cache_path) {
let _ = std::fs::remove_file(&tmp_path);
return Err(MPCError::Io(e));
}
}
Ok(fetched)
}
fn parse_mpc_obs_result(
mpc_document: &str,
error_model: &ErrorModelData,
) -> Result<MpcCodeObs, MPCError> {
let mut observatories = MpcCodeObs::with_capacity(2048);
for line in mpc_document.lines().skip(2) {
let line = line.trim();
let Some((code, remain)) = line.split_at_checked(3) else {
continue;
};
let Ok(mpc_code) = code.as_bytes().try_into() else {
continue;
};
let Some((longitude, cos, sin, name)) = parse_remain(remain.trim_end(), code) else {
continue;
};
let (ra_acc, dec_acc) = get_bias_rms(error_model, mpc_code, "c")
.map(|(ra, dec)| (Some(ra as f64), Some(dec as f64)))
.unwrap_or((None, None));
if let Ok(observer) = Observer::from_parallax(
(longitude as f64).to_radians(),
cos as f64,
sin as f64,
Some(name),
ra_acc,
dec_acc,
) {
observatories.insert(mpc_code, observer);
}
}
Ok(observatories)
}
#[cfg(test)]
mod mpc_obs_tests {
use super::*;
use crate::observer::Observer;
use approx::assert_relative_eq;
#[test]
fn parse_f32_valid_integer() {
let result = parse_f32(" 42 ", 0..6, "TST");
assert!(result.is_ok(), "Expected Ok but got: {:?}", result);
assert_relative_eq!(result.unwrap(), 42.0_f32);
}
#[test]
fn parse_f32_valid_float() {
let result = parse_f32(" 1.5 ", 0..6, "TST");
assert!(result.is_ok(), "Expected Ok but got: {:?}", result);
assert_relative_eq!(result.unwrap(), 1.5_f32);
}
#[test]
fn parse_f32_negative() {
let result = parse_f32("-1.23 ", 0..6, "TST");
assert!(result.is_ok(), "Expected Ok but got: {:?}", result);
assert_relative_eq!(result.unwrap(), -1.23_f32, epsilon = 1e-6_f32);
}
#[test]
fn parse_f32_invalid_returns_error() {
let result = parse_f32(" abc ", 0..6, "TST");
assert!(
result.is_err(),
"Expected Err for non-numeric input, but got: {:?}",
result
);
}
#[test]
fn parse_remain_valid_line() {
let remain = " 289.265770.864977-0.500219Observatoire de Haute-Provence";
let result = parse_remain(remain, "I41");
assert!(result.is_some(), "Expected Some but got None");
let (longitude, cos, sin, name) = result.unwrap();
assert_relative_eq!(longitude, 289.26577_f32, epsilon = 1e-4_f32);
assert_relative_eq!(cos, 0.864977_f32, epsilon = 1e-5_f32);
assert_relative_eq!(sin, -0.500219_f32, epsilon = 1e-5_f32);
assert!(
name.contains("Observatoire"),
"Expected name to contain 'Observatoire', got: {name:?}"
);
}
#[test]
fn parse_remain_too_short_returns_none() {
let remain = " 289.265770.864977";
let result = parse_remain(remain, "TST");
assert!(
result.is_none(),
"Expected None for too-short input, got: {:?}",
result
);
}
#[test]
fn parse_remain_name_is_trimmed_correctly() {
let remain = " 000.000000.0000000.000000 TestSiteName";
let result = parse_remain(remain, "TST");
assert!(result.is_some(), "Expected Some but got None");
let (_, _, _, name) = result.unwrap();
assert_eq!(
name, "TestSiteName",
"Name should be exactly the substring from byte 27; got: {name:?}"
);
}
#[test]
fn parse_remain_zero_fallback() {
let remain = " Spacewatch";
let result = parse_remain(remain, "TST");
assert!(result.is_some(), "Expected Some but got None");
let (longitude, cos, sin, name) = result.unwrap();
assert_relative_eq!(longitude, 0.0_f32);
assert_relative_eq!(cos, 0.0_f32);
assert_relative_eq!(sin, 0.0_f32);
assert_eq!(
name, "Spacewatch",
"Name should be the text at byte 27; got: {name:?}"
);
}
#[test]
fn mpc_code_key_lookup() {
let key: MpcCode = *b"G96";
let observer = Observer::from_parallax(
110.789_f64, 0.836_f64, 0.547_f64, Some("Catalina Sky Survey".to_string()),
None,
None,
)
.unwrap();
let mut map = MpcCodeObs::new();
map.insert(key, observer.clone());
let found = map.get(&key);
assert!(
found.is_some(),
"Expected to find observer under key b\"G96\", but got None"
);
assert_eq!(
found.unwrap(),
&observer,
"Retrieved observer does not match the inserted one"
);
}
const MOCK_MPC_DOCUMENT: &str = "\
Code Long. cos sin Name\n\
\n\
000 0.0000 0.62411 +0.77873 Greenwich\n\
001 0.1542 0.62992 +0.77411 Crowborough\n\
002 0.62 0.622 +0.781 Rayleigh\n\
005 2.231000.659891+0.748875Meudon\n\
006 2.124170.751042+0.658129Fabra Observatory, Barcelona\n\
";
struct MockMpcMiddleware;
impl ureq::middleware::Middleware for MockMpcMiddleware {
fn handle(
&self,
_request: ureq::http::Request<ureq::SendBody>,
_next: ureq::middleware::MiddlewareNext,
) -> Result<ureq::http::Response<ureq::Body>, ureq::Error> {
let body = ureq::Body::builder()
.mime_type("text/plain")
.data(MOCK_MPC_DOCUMENT.as_bytes().to_vec());
Ok(ureq::http::Response::builder()
.status(200)
.body(body)
.expect("valid response"))
}
}
fn mock_agent() -> ureq::Agent {
ureq::config::Config::builder()
.middleware(MockMpcMiddleware)
.build()
.new_agent()
}
#[test]
fn init_observatories_parses_mock_document() {
let tmp = tempfile::tempdir().expect("tempdir");
let agent = mock_agent();
let error_model: crate::observer::error_model::ErrorModelData =
std::collections::HashMap::new();
let result = init_observatories_impl(agent, &error_model, Some(tmp.path()));
assert!(
result.is_ok(),
"Expected Ok from init_observatories, got: {:?}",
result
);
let map = result.unwrap();
assert!(
map.contains_key(b"000"),
"Expected code '000' (Greenwich) to be in the map"
);
assert!(
map.contains_key(b"001"),
"Expected code '001' (Crowborough) to be in the map"
);
assert!(
map.contains_key(b"005"),
"Expected code '005' (Meudon) to be in the map"
);
assert!(
map.contains_key(b"006"),
"Expected code '006' (Fabra Observatory) to be in the map"
);
}
#[test]
fn init_observatories_observer_name_is_correct() {
let tmp = tempfile::tempdir().expect("tempdir");
let agent = mock_agent();
let error_model: crate::observer::error_model::ErrorModelData =
std::collections::HashMap::new();
let map = init_observatories_impl(agent, &error_model, Some(tmp.path())).unwrap();
let greenwich = map.get(b"000").expect("Greenwich must be present");
assert_eq!(
greenwich.name.as_deref(),
Some("Greenwich"),
"Observer name for code '000' should be 'Greenwich'"
);
}
#[test]
fn init_observatories_observer_parallax_is_correct() {
let tmp = tempfile::tempdir().expect("tempdir");
let agent = mock_agent();
let error_model: crate::observer::error_model::ErrorModelData =
std::collections::HashMap::new();
let map = init_observatories_impl(agent, &error_model, Some(tmp.path())).unwrap();
let meudon = map.get(b"005").expect("Meudon must be present");
assert_relative_eq!(
meudon.longitude.into_inner(),
0.03893829467988711_f64,
epsilon = 1e-6_f64
);
assert_relative_eq!(
meudon.rho_cos_phi.into_inner(),
0.659891_f64,
epsilon = 1e-6_f64
);
assert_relative_eq!(
meudon.rho_sin_phi.into_inner(),
0.748875_f64,
epsilon = 1e-6_f64
);
}
#[test]
fn init_observatories_empty_document_produces_empty_map() {
struct EmptyMpcMiddleware;
impl ureq::middleware::Middleware for EmptyMpcMiddleware {
fn handle(
&self,
_request: ureq::http::Request<ureq::SendBody>,
_next: ureq::middleware::MiddlewareNext,
) -> Result<ureq::http::Response<ureq::Body>, ureq::Error> {
let body = ureq::Body::builder()
.mime_type("text/plain")
.data("header line 1\nheader line 2\n".as_bytes().to_vec());
Ok(ureq::http::Response::builder()
.status(200)
.body(body)
.expect("valid response"))
}
}
let agent = ureq::config::Config::builder()
.middleware(EmptyMpcMiddleware)
.build()
.new_agent();
let tmp = tempfile::tempdir().expect("tempdir");
let map =
init_observatories_impl(agent, &std::collections::HashMap::new(), Some(tmp.path()))
.unwrap();
assert!(
map.is_empty(),
"Expected empty map for a document with only header lines, got {} entries",
map.len()
);
}
#[test]
fn init_observatories_skips_malformed_lines() {
struct MalformedMpcMiddleware;
impl ureq::middleware::Middleware for MalformedMpcMiddleware {
fn handle(
&self,
_request: ureq::http::Request<ureq::SendBody>,
_next: ureq::middleware::MiddlewareNext,
) -> Result<ureq::http::Response<ureq::Body>, ureq::Error> {
let content = "hdr\nhdr\nX\n\n000 0.0000 0.62411 +0.77873 Greenwich\n";
let body = ureq::Body::builder()
.mime_type("text/plain")
.data(content.as_bytes().to_vec());
Ok(ureq::http::Response::builder()
.status(200)
.body(body)
.expect("valid response"))
}
}
let agent = ureq::config::Config::builder()
.middleware(MalformedMpcMiddleware)
.build()
.new_agent();
let tmp = tempfile::tempdir().expect("tempdir");
let map =
init_observatories_impl(agent, &std::collections::HashMap::new(), Some(tmp.path()))
.unwrap();
assert_eq!(map.len(), 1, "Expected exactly 1 entry, got {}", map.len());
assert!(
map.contains_key(b"000"),
"Expected code '000' to be present"
);
}
struct PanicMiddleware;
impl ureq::middleware::Middleware for PanicMiddleware {
fn handle(
&self,
_request: ureq::http::Request<ureq::SendBody>,
_next: ureq::middleware::MiddlewareNext,
) -> Result<ureq::http::Response<ureq::Body>, ureq::Error> {
panic!("network request was made despite a warm cache");
}
}
fn panic_agent() -> ureq::Agent {
ureq::config::Config::builder()
.middleware(PanicMiddleware)
.build()
.new_agent()
}
#[test]
fn init_observatories_uses_cache_when_warm() {
let tmp = tempfile::tempdir().expect("tempdir");
let cache_file = tmp.path().join("mpc_obs.html");
std::fs::write(&cache_file, MOCK_MPC_DOCUMENT).expect("write cache");
let map = init_observatories_impl(
panic_agent(),
&std::collections::HashMap::new(),
Some(tmp.path()),
)
.expect("init_observatories must succeed with warm cache");
assert!(
map.contains_key(b"000"),
"Expected '000' (Greenwich) from cached document"
);
assert!(
map.contains_key(b"005"),
"Expected '005' (Meudon) from cached document"
);
}
#[test]
fn init_observatories_writes_cache_on_miss() {
let tmp = tempfile::tempdir().expect("tempdir");
let cache_file = tmp.path().join("mpc_obs.html");
assert!(!cache_file.exists(), "cache must not exist before the test");
let map = init_observatories_impl(
mock_agent(),
&std::collections::HashMap::new(),
Some(tmp.path()),
)
.expect("init_observatories must succeed on cache miss");
assert!(
map.contains_key(b"000"),
"Expected '000' from network response"
);
assert!(
cache_file.exists(),
"cache file must be written after a miss"
);
let cached = std::fs::read_to_string(&cache_file).expect("read cache");
assert!(
cached.contains("Greenwich"),
"Cache file should contain the network response body"
);
}
}