use std::collections::HashMap;
use std::fmt;
use crate::{Coefficients, Term, TermKind, TideSystem, TimeVariable};
#[derive(Clone, Debug, PartialEq)]
pub struct Gfc {
pub name: Option<String>,
pub model: TimeVariable,
pub sigma: Option<Coefficients>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum GfcError {
MissingHeader(&'static str),
Unsupported(String),
BadRecord {
line: usize,
reason: &'static str,
},
}
impl fmt::Display for GfcError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
GfcError::MissingHeader(key) => write!(f, "missing or invalid .gfc header key `{key}`"),
GfcError::Unsupported(what) => write!(f, "unsupported .gfc content: {what}"),
GfcError::BadRecord { line, reason } => write!(f, ".gfc line {line}: {reason}"),
}
}
}
impl std::error::Error for GfcError {}
pub fn julian_epoch(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> f64 {
let y = i64::from(year) - i64::from(month <= 2);
let era = y.div_euclid(400);
let yoe = y - era * 400;
let mp = (i64::from(month) + 9) % 12;
let doy = (153 * mp + 2) / 5 + i64::from(day) - 1;
let days = era * 146_097 + yoe * 365 + yoe / 4 - yoe / 100 + doy - 719_468;
let mjd = days as f64 + 40_587.0 + (f64::from(hour) + f64::from(minute) / 60.0) / 24.0;
2000.0 + (mjd - 51_544.5) / 365.25
}
pub fn parse_date(s: &str) -> Option<f64> {
let (date, time) = s.split_once('.').unwrap_or((s, "0000"));
if date.len() != 8 || !date.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let time = format!("{time:0<4}");
let (year, month, day) = (date[..4].parse().ok()?, date[4..6].parse().ok()?, date[6..].parse().ok()?);
let (hour, minute) = (time.get(..2)?.parse().ok()?, time.get(2..4)?.parse().ok()?);
let valid = (1..=12).contains(&month) && (1..=31).contains(&day) && hour < 24 && minute < 60;
valid.then(|| julian_epoch(year, month, day, hour, minute))
}
fn number(s: &str) -> Option<f64> {
s.replace(['D', 'd'], "e").parse().ok()
}
pub fn read_gfc(text: &str) -> Result<Gfc, GfcError> {
let mut lines = text.lines().enumerate();
let (mut gm, mut radius, mut nmax) = (None, None, None);
let (mut tide, mut name, mut has_errors, mut icgem2) = (TideSystem::Unknown, None, false, false);
let mut header_done = false;
for (_, line) in lines.by_ref() {
let mut fields = line.split_whitespace();
let (Some(key), value) = (fields.next(), fields.next()) else { continue };
match key {
"end_of_head" => {
header_done = true;
break;
}
"earth_gravity_constant" => gm = value.and_then(number),
"radius" => radius = value.and_then(number),
"max_degree" => nmax = value.and_then(|v| v.parse::<usize>().ok()),
"modelname" | "product_name" => name = value.map(str::to_owned),
"errors" => has_errors = value != Some("no"),
"format" => icgem2 = value == Some("icgem2.0"),
"tide_system" => {
tide = match value {
Some("zero_tide") => TideSystem::ZeroTide,
Some("tide_free") => TideSystem::TideFree,
Some("mean_tide") => TideSystem::MeanTide,
_ => TideSystem::Unknown,
}
}
"norm" if value == Some("unnormalized") => {
return Err(GfcError::Unsupported("norm unnormalized".into()));
}
_ => {}
}
}
if !header_done {
return Err(GfcError::MissingHeader("end_of_head"));
}
let gm = gm.ok_or(GfcError::MissingHeader("earth_gravity_constant"))?;
let radius = radius.ok_or(GfcError::MissingHeader("radius"))?;
let nmax = nmax.ok_or(GfcError::MissingHeader("max_degree"))?;
if nmax > crate::MAX_DEGREE {
return Err(GfcError::Unsupported(format!("max_degree {nmax} > {}", crate::MAX_DEGREE)));
}
let mut base = Coefficients::zeros(gm, radius, nmax);
base.tide_system = tide;
let mut sigma = has_errors.then(|| base.clone());
let mut terms = Vec::new();
let mut epochs: HashMap<(usize, usize), f64> = HashMap::new();
for (i, line) in lines {
let f: Vec<&str> = line.split_whitespace().collect();
let Some(&key) = f.first() else { continue };
let bad = |reason| GfcError::BadRecord { line: i + 1, reason };
let (n, m) = match (f.get(1).and_then(|s| s.parse().ok()), f.get(2).and_then(|s| s.parse().ok())) {
(Some(n), Some(m)) if m <= n && n <= nmax => (n, m),
(Some(_), Some(_)) => return Err(bad("degree/order outside max_degree")),
_ => return Err(bad("bad degree/order")),
};
let (c, s) = match (f.get(3).copied().and_then(number), f.get(4).copied().and_then(number)) {
(Some(c), Some(s)) => (c, s),
_ => return Err(bad("bad coefficient")),
};
let from_end = |k: usize| f.len().checked_sub(k).filter(|&j| j >= 5).map(|j| f[j]);
let date = |k| from_end(k).and_then(parse_date).ok_or_else(|| bad("bad or missing date"));
let period = || from_end(1).and_then(number).filter(|p| *p > 0.0).ok_or_else(|| bad("bad period"));
let mut term = |kind, epoch: f64, valid| terms.push(Term { n, m, kind, c, s, epoch, valid });
let unbounded = f64::NEG_INFINITY..f64::INFINITY;
let gfct_epoch = || epochs.get(&(n, m)).copied().ok_or_else(|| bad("record before its gfct"));
match (key, icgem2) {
("gfc", _) => base.set(n, m, c, s),
("gfct", false) => {
epochs.insert((n, m), date(1)?);
base.set(n, m, c, s);
}
("gfct", true) => {
let (t0, t1) = (date(2)?, date(1)?);
term(TermKind::Value, t0, t0..t1);
}
("trnd" | "dot", false) => term(TermKind::Trend, gfct_epoch()?, unbounded),
("trnd" | "dot", true) => {
let (t0, t1) = (date(2)?, date(1)?);
term(TermKind::Trend, t0, t0..t1);
}
("acos" | "asin", false) => {
let period = period()?;
let kind = if key == "acos" { TermKind::Cos { period } } else { TermKind::Sin { period } };
term(kind, gfct_epoch()?, unbounded);
}
("acos" | "asin", true) => {
let (period, t0, t1) = (period()?, date(3)?, date(2)?);
let kind = if key == "acos" { TermKind::Cos { period } } else { TermKind::Sin { period } };
term(kind, t0, t0..t1);
}
_ => return Err(bad("unknown record key")),
}
let date_cols = match (key, icgem2) {
("gfc", _) => Some(0),
("gfct", false) => Some(1),
("gfct", true) => Some(2),
_ => None,
};
if let (Some(sigma), Some(date_cols)) = (sigma.as_mut(), date_cols) {
if f.len() >= 7 + date_cols {
match (number(f[5]), number(f[6])) {
(Some(sc), Some(ss)) => sigma.set(n, m, sc, ss),
_ => return Err(bad("bad error column")),
}
}
}
}
Ok(Gfc { name, model: TimeVariable { base, terms }, sigma })
}
#[cfg(test)]
mod tests {
use super::*;
const COSTG: &str = include_str!("../tests/data/costg_2026-07_n10.gfc");
const GOCO: &str = include_str!("../tests/data/goco06s_n4.gfc");
#[test]
fn julian_epochs() {
assert_eq!(julian_epoch(2000, 1, 1, 12, 0), 2000.0);
assert_eq!(julian_epoch(2010, 1, 1, 0, 0), 2000.0 + (55_197.0 - 51_544.5) / 365.25);
assert_eq!(parse_date("20100101"), Some(julian_epoch(2010, 1, 1, 0, 0)));
assert_eq!(parse_date("20020815.0817"), Some(julian_epoch(2002, 8, 15, 8, 17)));
assert_eq!(parse_date("6.658e-13"), None);
assert_eq!(parse_date("20101301"), None);
}
#[test]
fn costg_monthly() {
let g = read_gfc(COSTG).unwrap();
assert_eq!(g.name.as_deref(), Some("GSM-2_2026182-2026212_GRFO_COSTG_BF01_0201"));
let b = &g.model.base;
assert_eq!((b.gm, b.radius, b.max_degree()), (3.986004415e14, 6378136.3, 90));
assert_eq!(b.tide_system, TideSystem::TideFree);
assert_eq!((b.c(0, 0), b.c(2, 0)), (1.0, -4.84165307066e-4));
assert_eq!(b.s(2, 2), -1.40038200535e-6);
assert!(g.model.terms.is_empty());
let sigma = g.sigma.unwrap();
assert_eq!((sigma.c(2, 1), sigma.s(2, 1)), (2.45322715392e-11, 1.62793405520e-11));
}
#[test]
fn swarm_style_header() {
let g = read_gfc(include_str!("../tests/data/swarm_style_synthetic.gfc")).unwrap();
let b = &g.model.base;
assert_eq!(g.name.as_deref(), Some("SYNTHETIC_SWARM_STYLE_2025-12"));
assert_eq!(b.max_degree(), 40);
assert_eq!((b.c(2, 1), b.s(2, 1)), (-1e-10, 2e-9));
assert_eq!((b.c(40, 40), b.s(40, 40)), (-1e-9, 5e-10));
assert!(g.sigma.is_none());
}
#[test]
fn goco06s_time_variable() {
let g = read_gfc(GOCO).unwrap();
assert_eq!(g.name.as_deref(), Some("GOCO06s"));
assert_eq!(g.model.base.tide_system, TideSystem::ZeroTide);
assert_eq!(g.model.terms.len(), 3 * (15 - 3)); assert_eq!(g.sigma.as_ref().unwrap().c(2, 0), 6.658069597318e-13);
let t0 = julian_epoch(2010, 1, 1, 0, 0);
let t = julian_epoch(2026, 7, 16, 12, 0);
let w = core::f64::consts::TAU * (t - t0);
let c20 = -4.841694947139e-4 - 2.154662425317e-11 * (t - t0)
+ 8.331958055607e-11 * w.cos()
+ 7.523339889891e-11 * w.sin();
assert!((g.model.at(t).c(2, 0) - c20).abs() < 1e-18);
let acos44 = g.model.terms.iter().find(|x| (x.n, x.m, x.kind) == (4, 4, TermKind::Cos { period: 1.0 }));
let expect = -1.884945995629e-7 + acos44.unwrap().c;
assert!((g.model.at(t0).c(4, 4) - expect).abs() < 1e-22);
}
#[test]
fn icgem2_intervals() {
let text = "earth_gravity_constant 1\nradius 1\nmax_degree 2\nformat icgem2.0\nerrors no\nend_of_head\n\
gfct 2 0 1.0 0.0 20000101.0000 20100101.0000\n\
gfct 2 0 2.0 0.0 20100101.0000 20200101.0000\n\
trnd 2 0 0.5 0.0 20100101.0000 20200101.0000\n\
acos 2 0 0.25 0.0 20100101.0000 20200101.0000 1.0\n";
let m = read_gfc(text).unwrap().model;
assert_eq!(m.at(julian_epoch(2005, 1, 1, 0, 0)).c(2, 0), 1.0);
let (t0, t) = (julian_epoch(2010, 1, 1, 0, 0), julian_epoch(2012, 1, 1, 0, 0));
let expect = 2.0 + 0.5 * (t - t0) + 0.25 * (core::f64::consts::TAU * (t - t0)).cos();
assert!((m.at(t).c(2, 0) - expect).abs() < 1e-15);
assert_eq!(m.at(julian_epoch(2021, 1, 1, 0, 0)).c(2, 0), 0.0);
}
#[test]
fn missing_error_columns_are_not_read_as_dates() {
let text = "earth_gravity_constant 1\nradius 1\nmax_degree 2\nerrors formal\nend_of_head\n\
gfct 2 0 1.0 0.0 20100101\ngfc 2 1 1.0 2.0 3.0 4.0\n";
let sigma = read_gfc(text).unwrap().sigma.unwrap();
assert_eq!((sigma.c(2, 0), sigma.c(2, 1), sigma.s(2, 1)), (0.0, 3.0, 4.0));
}
#[test]
fn rejects_bad_input() {
let head = "earth_gravity_constant 1\nradius 1\nmax_degree 2\nend_of_head\n";
let err = |body: &str| read_gfc(&format!("{head}{body}")).unwrap_err();
assert_eq!(err("gfc 3 0 1 0\n"), GfcError::BadRecord { line: 5, reason: "degree/order outside max_degree" });
assert_eq!(err("gfc 1 2 1 0\n"), GfcError::BadRecord { line: 5, reason: "degree/order outside max_degree" });
assert_eq!(err("gfc 2 0 x 0\n"), GfcError::BadRecord { line: 5, reason: "bad coefficient" });
assert_eq!(err("trnd 2 0 1 0\n"), GfcError::BadRecord { line: 5, reason: "record before its gfct" });
assert_eq!(err("gfct 2 0 1 0 6.6e-13\n"), GfcError::BadRecord { line: 5, reason: "bad or missing date" });
assert_eq!(err("xyz 2 0 1 0\n"), GfcError::BadRecord { line: 5, reason: "unknown record key" });
assert_eq!(read_gfc("radius 1\nmax_degree 2\nend_of_head\n"), Err(GfcError::MissingHeader("earth_gravity_constant")));
assert_eq!(read_gfc("gfc 0 0 1 0\n"), Err(GfcError::MissingHeader("end_of_head")));
assert!(matches!(read_gfc("norm unnormalized\nend_of_head\n"), Err(GfcError::Unsupported(_))));
}
}