#![cfg(feature = "mpc_80_col")]
use ahash::AHashMap;
use camino::Utf8Path;
use hifitime::{Epoch, TimeScale};
use nom::{
IResult, Parser,
bytes::complete::take_while1,
character::complete::{digit1, space1},
combinator::{map_res, opt},
};
use thiserror::Error;
use crate::{
Arcseconds, Degrees, Radians, TrajId,
constants::ARCSEC_TO_RAD,
coordinates::equatorial::EquCoord,
observation_dataset::{
ObsDataset, ObsId,
index::{ObsMapIndex, TrajIndexMap},
observation::ObservationInput,
},
observer::dataset::ObserverId,
photometry::{Filter, Photometry},
};
#[derive(Debug, Error)]
pub enum Mpc80ColError {
#[error("I/O error reading MPC 80-col file: {0}")]
Io(#[from] std::io::Error),
#[error("failed to parse MPC 80-col line {line_no}: {reason}\n line: {line}")]
InvalidLine {
line_no: usize,
line: String,
reason: String,
},
}
pub(crate) fn parse_mpc_80_col_file(
path: &Utf8Path,
start_id: ObsId,
) -> Result<ObsDataset, Mpc80ColError> {
let content = std::fs::read_to_string(path)?;
parse_mpc_80_col_str(&content, start_id)
}
pub(crate) fn parse_mpc_80_col_str(
content: &str,
start_id: ObsId,
) -> Result<ObsDataset, Mpc80ColError> {
let mut records: Vec<(TrajId, LineRecord)> = Vec::new();
for (line_no, line) in content.lines().enumerate() {
if line.len() < 80 {
continue;
}
if line.as_bytes()[14] == b's' {
continue;
}
let record = parse_line(line).map_err(|reason| Mpc80ColError::InvalidLine {
line_no: line_no + 1,
line: line.to_string(),
reason,
})?;
let traj_id = record.traj_id.clone();
records.push((traj_id, record));
}
let primary: TrajId = records
.iter()
.find_map(|(id, _)| match id {
TrajId::Int(_) => Some(id.clone()),
TrajId::Str(_) => None,
})
.or_else(|| records.first().map(|(id, _)| id.clone()))
.unwrap_or(TrajId::Str(String::new()));
let mut observations: Vec<ObservationInput> = Vec::with_capacity(records.len());
let mut seen_aliases: AHashMap<String, ()> = AHashMap::new();
for (traj_id, record) in records {
let idx = observations.len();
observations.push(record.into_observation(idx, start_id));
if let TrajId::Str(s) = &traj_id
&& traj_id != primary
{
seen_aliases.entry(s.clone()).or_default();
}
}
let mut traj_index: TrajIndexMap = AHashMap::new();
let all_indices: Vec<usize> = (0..observations.len()).collect();
traj_index.insert(primary.clone(), ObsMapIndex::Split(all_indices));
let mut dataset = ObsDataset::new(observations, vec![], None, None, Some(traj_index));
for alias in seen_aliases.into_keys() {
dataset.register_alias(alias, primary.clone());
}
Ok(dataset)
}
#[derive(Debug)]
struct LineRecord {
traj_id: TrajId,
mjd_tt: f64,
ra_rad: Radians,
ra_error_rad: Radians,
dec_rad: Radians,
dec_error_rad: Radians,
mag: Option<f32>,
band: Option<String>,
obs_code: [u8; 3],
}
impl LineRecord {
fn into_observation(self, idx: usize, start_id: ObsId) -> ObservationInput {
let equ_coord = EquCoord::new(
self.ra_rad,
self.ra_error_rad,
self.dec_rad,
self.dec_error_rad,
);
let photometry = Photometry {
magnitude: self.mag.unwrap_or(0.0) as f64,
error: 0.0,
filter: self
.band
.map(Filter::String)
.unwrap_or(Filter::String("unknown".to_string())),
};
ObservationInput {
id: start_id + idx as u64,
equ_coord,
photometry,
mjd_tt: self.mjd_tt,
observer: Some(ObserverId::MpcCode(self.obs_code)),
}
}
}
fn parse_line(line: &str) -> Result<LineRecord, String> {
let traj_id = extract_traj_id(line);
let date_str = line[15..32].trim();
let ra_str = line[32..44].trim();
let dec_str = line[44..56].trim();
let mag_str = line[65..70].trim();
let band_str = line[70..71].trim();
let obs_code_str = line[77..80].trim();
let mjd_tt = parse_frac_date(date_str)
.map_err(|_| format!("invalid date field: '{date_str}'"))?
.1;
let (ra_deg, ra_acc) = parse_ra(ra_str)
.map_err(|_| format!("invalid RA field: '{ra_str}'"))?
.1;
let (dec_deg, dec_acc) = parse_dec(dec_str)
.map_err(|_| format!("invalid Dec field: '{dec_str}'"))?
.1;
let mag = mag_str
.trim_end_matches(|c: char| !c.is_ascii_digit() && c != '.')
.parse::<f32>()
.ok();
let band = if band_str.is_empty() {
None
} else {
Some(band_str.to_string())
};
let obs_code = mpc_str_to_bytes(obs_code_str);
let dec_rad = dec_deg.to_radians();
let dec_rad_cos = dec_rad.cos();
const FLOOR_ARCSEC: f64 = 0.01;
let ra_acc_rad = (ra_acc.max(FLOOR_ARCSEC) * ARCSEC_TO_RAD) / dec_rad_cos;
let dec_acc_rad = dec_acc.max(FLOOR_ARCSEC) * ARCSEC_TO_RAD;
Ok(LineRecord {
traj_id,
mjd_tt,
ra_rad: ra_deg.to_radians(),
ra_error_rad: ra_acc_rad,
dec_rad,
dec_error_rad: dec_acc_rad,
mag,
band,
obs_code,
})
}
fn extract_traj_id(line: &str) -> TrajId {
let num_part = line[0..5].trim_start_matches('0').trim();
let raw = if num_part.is_empty() {
line[5..12].trim()
} else {
num_part
};
if let Ok(n) = raw.parse::<u32>() {
TrajId::Int(n)
} else {
TrajId::Str(raw.to_string())
}
}
fn parse_frac_date(input: &str) -> IResult<&str, f64> {
let (input, year) = map_res(digit1, |s: &str| s.parse::<i32>()).parse(input)?;
let (input, _) = space1(input)?;
let (input, month) = map_res(digit1, |s: &str| s.parse::<u8>()).parse(input)?;
let (input, _) = space1(input)?;
let (input, day_str) = take_while1(|c: char| c.is_ascii_digit() || c == '.').parse(input)?;
let day_frac: f64 = day_str.parse().map_err(|_| {
nom::Err::Error(nom::error::Error::new(input, nom::error::ErrorKind::Float))
})?;
let day = day_frac.trunc() as u8;
let frac = day_frac - day as f64;
let total_sec = frac * 86_400.0;
let hour = (total_sec / 3_600.0).trunc() as u8;
let rem_sec = (total_sec - hour as f64 * 3_600.0).max(0.0);
let minute = (rem_sec / 60.0).trunc() as u8;
let rem_sec2 = (rem_sec - minute as f64 * 60.0).max(0.0);
let second = rem_sec2.trunc() as u8;
let nano = ((rem_sec2 - second as f64) * 1e9).round() as u32;
let epoch = Epoch::from_gregorian(year, month, day, hour, minute, second, nano, TimeScale::UTC);
Ok((input, epoch.to_mjd_tt_days()))
}
fn parse_ra(input: &str) -> IResult<&str, (Degrees, Arcseconds)> {
let (input, (h, _, m, _, s_str)) = (
map_res(digit1, |s: &str| s.parse::<f64>()),
space1,
map_res(digit1, |s: &str| s.parse::<f64>()),
space1,
take_while1(|c: char| c.is_ascii_digit() || c == '.'),
)
.parse(input)?;
let s: f64 = s_str.parse().map_err(|_| {
nom::Err::Error(nom::error::Error::new(input, nom::error::ErrorKind::Float))
})?;
let ra_deg = (h + m / 60.0 + s / 3_600.0) * 15.0;
let acc_arcsec = decimal_accuracy(s_str) * 15.0;
Ok((input, (ra_deg, acc_arcsec)))
}
fn parse_dec(input: &str) -> IResult<&str, (Degrees, Arcseconds)> {
let (input, sign_char) = opt(nom::character::complete::one_of("+-")).parse(input)?;
let sign = if sign_char == Some('-') { -1.0 } else { 1.0 };
let (input, (d, _, m, _, s_str)) = (
map_res(digit1, |s: &str| s.parse::<f64>()),
space1,
map_res(digit1, |s: &str| s.parse::<f64>()),
space1,
take_while1(|c: char| c.is_ascii_digit() || c == '.'),
)
.parse(input)?;
let s: f64 = s_str.parse().map_err(|_| {
nom::Err::Error(nom::error::Error::new(input, nom::error::ErrorKind::Float))
})?;
let dec_deg = sign * (d + m / 60.0 + s / 3_600.0);
let acc_arcsec = decimal_accuracy(s_str);
Ok((input, (dec_deg, acc_arcsec)))
}
fn decimal_accuracy(seconds_str: &str) -> f64 {
let digits = seconds_str
.find('.')
.map(|dot| seconds_str.trim_end().len() - dot - 1)
.unwrap_or(0);
10f64.powi(-(digits as i32))
}
fn mpc_str_to_bytes(code: &str) -> [u8; 3] {
let bytes = code.as_bytes();
let mut arr = [b' '; 3];
let len = bytes.len().min(3);
arr[..len].copy_from_slice(&bytes[..len]);
arr
}
#[cfg(test)]
mod mpc_80_col_tests {
use super::*;
#[test]
fn test_parse_ra_basic() {
let (_, (ra, acc)) = parse_ra("22 52 23.37").unwrap();
let expected = (22.0 + 52.0 / 60.0 + 23.37 / 3600.0) * 15.0;
assert!((ra - expected).abs() < 1e-9);
assert!((acc - 0.15).abs() < 1e-9);
}
#[test]
fn test_parse_dec_negative() {
let (_, (dec, acc)) = parse_dec("-14 47 05.4").unwrap();
let expected = -(14.0 + 47.0 / 60.0 + 5.4 / 3600.0);
assert!((dec - expected).abs() < 1e-9);
assert!((acc - 0.1).abs() < 1e-9);
}
#[test]
fn test_parse_dec_positive_explicit_sign() {
let (_, (dec, _)) = parse_dec("+08 01 18.05").unwrap();
let expected = 8.0 + 1.0 / 60.0 + 18.05 / 3600.0;
assert!((dec - expected).abs() < 1e-9);
}
#[test]
fn test_parse_frac_date() {
let (_, mjd) = parse_frac_date("2009 09 15.22735").unwrap();
assert!(mjd > 50_000.0, "MJD should be positive: {mjd}");
}
#[test]
fn test_extract_traj_id_numbered() {
let line =
"08467 C2024 12 03.05243000 23 45.348+08 01 18.05 18.93cV~8TCpW68";
assert_eq!(extract_traj_id(line), TrajId::Int(8467));
}
#[test]
fn test_extract_traj_id_provisional() {
let line =
" K09R05F* C2009 09 15.22735 22 52 23.37 -14 47 05.4 20.7 Vr~097wG96";
assert_eq!(extract_traj_id(line), TrajId::Str("K09R05F".to_string()));
}
#[test]
fn test_decimal_precision_arcsec() {
assert_eq!(decimal_accuracy("23.37"), 0.01);
assert_eq!(decimal_accuracy("05.4"), 0.1);
assert_eq!(decimal_accuracy("18.05"), 0.01);
assert_eq!(decimal_accuracy("45.348"), 0.001);
assert_eq!(decimal_accuracy("23.3"), 0.1);
assert_eq!(decimal_accuracy("23"), 1.0);
assert_eq!(decimal_accuracy("23.370"), 0.001);
assert_eq!(decimal_accuracy("23.37"), 0.01);
}
#[test]
fn test_parse_line() {
use approx::assert_relative_eq;
let line =
" K09R05F C2009 09 15.23433 22 52 22.62 -14 47 03.2 20.8 Vr~097wG96";
let record = parse_line(line).unwrap();
assert_eq!(record.traj_id, TrajId::from("K09R05F"));
assert_relative_eq!(record.mjd_tt, 55_089.235_096_018_51, max_relative = 1e-10);
assert_relative_eq!(record.ra_rad, 5.988_124_307_160_555, max_relative = 1e-10);
assert_relative_eq!(
record.ra_error_rad,
7.521_204_506_165_675e-7,
max_relative = 1e-6
);
assert_relative_eq!(
record.dec_rad,
-0.258_033_355_124_290_54,
max_relative = 1e-10
);
assert_relative_eq!(
record.dec_error_rad,
4.848_136_811_095_36e-7,
max_relative = 1e-6
);
assert_eq!(record.mag, Some(20.8_f32));
assert_eq!(record.band, Some("V".to_string()));
assert_eq!(record.obs_code, [b'G', b'9', b'6']);
let obs = record.into_observation(0, 0);
assert_eq!(obs.id, 0);
assert_relative_eq!(
obs.equ_coord.ra,
5.988_124_307_160_555,
max_relative = 1e-10
);
assert_relative_eq!(
obs.equ_coord.ra_error,
7.521_204_506_165_675e-7,
max_relative = 1e-6
);
assert_relative_eq!(
obs.equ_coord.dec,
-0.258_033_355_124_290_54,
max_relative = 1e-10
);
assert_relative_eq!(
obs.equ_coord.dec_error,
4.848_136_811_095_36e-7,
max_relative = 1e-6
);
assert_relative_eq!(obs.photometry.magnitude, 20.8_f64, max_relative = 1e-5);
assert_relative_eq!(obs.photometry.error, 0.0_f64, epsilon = 1e-10);
assert_eq!(obs.photometry.filter, Filter::String("V".to_string()));
assert_relative_eq!(obs.mjd_tt, 55_089.235_096_018_51, max_relative = 1e-10);
assert_eq!(obs.observer, Some(ObserverId::MpcCode([b'G', b'9', b'6'])));
}
}