use crate::lunar::{mci_to_mcmf, MOON_GM_M3_S2};
use crate::lunar_service::{LunarSat, PositionsMcmf};
use serde::Serialize;
use sha2::{Digest, Sha256};
type Vec3 = [f64; 3];
pub const LAGRANGE_ORDER: usize = 8;
const SECONDS_PER_DAY: f64 = 86_400.0;
const MAGIC: &str = "# kshana-lunar-constellation 1";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EphemerisFormat {
States,
Elements,
}
impl EphemerisFormat {
pub fn provenance_class(self) -> &'static str {
match self {
EphemerisFormat::States => "published-ephemeris",
EphemerisFormat::Elements => "published-elements",
}
}
pub fn as_str(self) -> &'static str {
match self {
EphemerisFormat::States => "states",
EphemerisFormat::Elements => "elements",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StateFrame {
Icrf,
Mci,
}
impl StateFrame {
pub fn as_str(self) -> &'static str {
match self {
StateFrame::Icrf => "icrf",
StateFrame::Mci => "mci",
}
}
}
pub fn true_to_mean_anomaly_deg(true_anom_deg: f64, e: f64) -> f64 {
let nu = true_anom_deg.to_radians();
let ea = 2.0 * (((1.0 - e) / (1.0 + e)).sqrt() * (nu * 0.5).tan()).atan();
(ea - e * ea.sin()).to_degrees()
}
#[derive(Clone, Debug)]
struct Track {
t_s: Vec<f64>,
r_m: Vec<Vec3>,
}
impl Track {
fn at(&self, t: f64) -> Vec3 {
let n = self.t_s.len();
if n == 1 {
return self.r_m[0];
}
let want = (LAGRANGE_ORDER + 1).min(n);
let hi = self.t_s.partition_point(|&x| x < t);
let start = hi.saturating_sub(want / 2 + 1).min(n.saturating_sub(want));
let end = start + want;
let mut out = [0.0f64; 3];
for i in start..end {
let mut w = 1.0f64;
for j in start..end {
if i != j {
w *= (t - self.t_s[j]) / (self.t_s[i] - self.t_s[j]);
}
}
for (o, c) in out.iter_mut().zip(self.r_m[i]) {
*o += w * c;
}
}
out
}
}
#[derive(Clone, Debug)]
pub struct LunarEphemeris {
format: EphemerisFormat,
frame: StateFrame,
epoch_jd_tdb: f64,
sats: Vec<LunarSat>,
tracks: Vec<Track>,
meta: Vec<(String, String)>,
sha256: String,
path: String,
}
fn meta_of(meta: &[(String, String)], key: &str) -> String {
meta.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.clone())
.unwrap_or_default()
}
impl LunarEphemeris {
pub fn load(path: &str) -> Result<Self, String> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read lunar ephemeris {path}: {e}"))?;
let mut e = Self::parse(&text)?;
e.path = path.to_string();
Ok(e)
}
pub fn parse(text: &str) -> Result<Self, String> {
let sha256 = hex::encode(Sha256::digest(text.as_bytes()));
let mut meta: Vec<(String, String)> = Vec::new();
let mut rows: Vec<&str> = Vec::new();
let mut columns: Vec<String> = Vec::new();
let mut saw_magic = false;
for (lineno, raw) in text.lines().enumerate() {
let line = raw.trim_end();
if line.trim().is_empty() {
continue;
}
if lineno == 0 {
if line.trim() != MAGIC {
return Err(format!(
"lunar ephemeris: first line must be `{MAGIC}`, found `{}`",
line.trim()
));
}
saw_magic = true;
continue;
}
if let Some(rest) = line.strip_prefix('#') {
if let Some((k, v)) = rest.split_once(':') {
let (k, v) = (k.trim(), v.trim());
if !k.is_empty() && !k.contains(' ') && !meta.iter().any(|(e, _)| e == k) {
meta.push((k.to_string(), v.to_string()));
}
}
continue;
}
if columns.is_empty() {
columns = line.split(',').map(|s| s.trim().to_string()).collect();
} else {
rows.push(line);
}
}
if !saw_magic {
return Err("lunar ephemeris: file is empty".to_string());
}
let format = match meta_of(&meta, "format").as_str() {
"states" => EphemerisFormat::States,
"elements" => EphemerisFormat::Elements,
other => {
return Err(format!(
"lunar ephemeris: `# format:` must be `states` or `elements`, found `{other}`"
))
}
};
if rows.is_empty() {
return Err("lunar ephemeris: no data rows".to_string());
}
let mut out = LunarEphemeris {
format,
frame: StateFrame::Mci,
epoch_jd_tdb: 0.0,
sats: Vec::new(),
tracks: Vec::new(),
meta,
sha256,
path: "<in-memory>".to_string(),
};
match format {
EphemerisFormat::Elements => out.parse_elements(&columns, &rows)?,
EphemerisFormat::States => out.parse_states(&columns, &rows)?,
}
Ok(out)
}
fn column(columns: &[String], name: &str) -> Result<usize, String> {
columns
.iter()
.position(|c| c == name)
.ok_or_else(|| format!("lunar ephemeris: missing column `{name}`"))
}
fn parse_elements(&mut self, columns: &[String], rows: &[&str]) -> Result<(), String> {
self.frame = match meta_of(&self.meta, "elements_frame").as_str() {
"" | "mci" => StateFrame::Mci,
"icrf" => StateFrame::Icrf,
other => {
return Err(format!(
"lunar ephemeris: `# elements_frame:` must be `icrf` or `mci`, found `{other}`"
))
}
};
if self.frame == StateFrame::Icrf {
self.epoch_jd_tdb =
meta_of(&self.meta, "epoch_jd_tdb")
.parse::<f64>()
.map_err(|e| {
format!(
"lunar ephemeris: `elements_frame: icrf` needs `# epoch_jd_tdb:` (the \
epoch the elements are referred to, which is also where the \
ICRF-to-Moon-fixed rotation is evaluated): {e}"
)
})?;
}
let base = ["sat", "sma_km", "ecc", "inc_deg", "raan_deg", "argp_deg"];
let idx: Vec<usize> = base
.iter()
.map(|c| Self::column(columns, c))
.collect::<Result<_, _>>()?;
let (anom_idx, anom_is_true) = match (
Self::column(columns, "mean_anom_deg"),
Self::column(columns, "true_anom_deg"),
) {
(Ok(_), Ok(_)) => {
return Err(
"lunar ephemeris: give exactly one of `mean_anom_deg` or `true_anom_deg`"
.to_string(),
)
}
(Ok(i), Err(_)) => (i, false),
(Err(_), Ok(i)) => (i, true),
(Err(e), Err(_)) => return Err(e),
};
let mut sats = Vec::new();
for (n, row) in rows.iter().enumerate() {
let f: Vec<&str> = row.split(',').map(str::trim).collect();
let at = |i: usize| -> Result<f64, String> {
f.get(i)
.ok_or_else(|| format!("lunar ephemeris: short element row {}", n + 1))?
.parse::<f64>()
.map_err(|e| format!("lunar ephemeris: element row {}: {e}", n + 1))
};
let get = |k: usize| at(idx[k]);
let sma_km = get(1)?;
let ecc = get(2)?;
if !(sma_km.is_finite() && sma_km > 0.0) {
return Err(format!(
"lunar ephemeris: element row {} has a non-positive semi-major axis",
n + 1
));
}
if !(0.0..1.0).contains(&ecc) {
return Err(format!(
"lunar ephemeris: element row {} has eccentricity {ecc}, outside [0, 1)",
n + 1
));
}
let anom = at(anom_idx)?;
sats.push(LunarSat {
sma_m: sma_km * 1000.0,
eccentricity: ecc,
inc_deg: get(3)?,
raan_deg: get(4)?,
argp_deg: get(5)?,
mean_anom_deg: if anom_is_true {
true_to_mean_anomaly_deg(anom, ecc)
} else {
anom
},
});
}
self.sats = sats;
Ok(())
}
fn parse_states(&mut self, columns: &[String], rows: &[&str]) -> Result<(), String> {
self.frame = match meta_of(&self.meta, "frame").as_str() {
"icrf" => StateFrame::Icrf,
"mci" => StateFrame::Mci,
other => {
return Err(format!(
"lunar ephemeris: a `states` file needs `# frame: icrf` or `# frame: mci`, found `{other}`"
))
}
};
if self.frame == StateFrame::Icrf {
self.epoch_jd_tdb =
meta_of(&self.meta, "epoch_jd_tdb")
.parse::<f64>()
.map_err(|e| {
format!(
"lunar ephemeris: an `icrf` states file needs `# epoch_jd_tdb:`: {e}"
)
})?;
}
let want = ["sat", "t_s", "x_km", "y_km", "z_km"];
let idx: Vec<usize> = want
.iter()
.map(|c| Self::column(columns, c))
.collect::<Result<_, _>>()?;
let mut tracks: Vec<Track> = Vec::new();
for (n, row) in rows.iter().enumerate() {
let f: Vec<&str> = row.split(',').map(str::trim).collect();
let get = |k: usize| -> Result<f64, String> {
f.get(idx[k])
.ok_or_else(|| format!("lunar ephemeris: short state row {}", n + 1))?
.parse::<f64>()
.map_err(|e| format!("lunar ephemeris: state row {}: {e}", n + 1))
};
let sat = get(0)? as usize;
let t = get(1)?;
let r = [get(2)? * 1000.0, get(3)? * 1000.0, get(4)? * 1000.0];
if !(t.is_finite() && r.iter().all(|c| c.is_finite())) {
return Err(format!(
"lunar ephemeris: state row {} is not finite",
n + 1
));
}
while tracks.len() <= sat {
tracks.push(Track {
t_s: Vec::new(),
r_m: Vec::new(),
});
}
let tr = &mut tracks[sat];
if let Some(&last) = tr.t_s.last() {
if t <= last {
return Err(format!(
"lunar ephemeris: state row {} for satellite {sat} goes back in time \
({t} after {last}); epochs must be strictly increasing per satellite",
n + 1
));
}
}
tr.t_s.push(t);
tr.r_m.push(r);
}
if tracks.iter().any(|t| t.t_s.is_empty()) {
return Err(
"lunar ephemeris: satellite indices must be contiguous from 0 with no gaps"
.to_string(),
);
}
self.tracks = tracks;
Ok(())
}
pub fn format(&self) -> EphemerisFormat {
self.format
}
pub fn n_sats(&self) -> usize {
match self.format {
EphemerisFormat::Elements => self.sats.len(),
EphemerisFormat::States => self.tracks.len(),
}
}
pub fn n_epochs(&self) -> Option<usize> {
match self.format {
EphemerisFormat::Elements => None,
EphemerisFormat::States => self.tracks.first().map(|t| t.t_s.len()),
}
}
pub fn covered_until_s(&self) -> Option<f64> {
match self.format {
EphemerisFormat::Elements => None,
EphemerisFormat::States => self
.tracks
.iter()
.filter_map(|t| t.t_s.last().copied())
.fold(None, |acc: Option<f64>, v| {
Some(acc.map_or(v, |a: f64| a.min(v)))
}),
}
}
pub fn sha256(&self) -> &str {
&self.sha256
}
pub fn path(&self) -> &str {
&self.path
}
pub fn meta(&self, key: &str) -> String {
meta_of(&self.meta, key)
}
pub fn provenance_class(&self) -> &'static str {
self.format.provenance_class()
}
pub fn state_frame(&self) -> StateFrame {
self.frame
}
pub fn elements(&self) -> &[LunarSat] {
&self.sats
}
}
impl PositionsMcmf for LunarEphemeris {
fn positions_mcmf(&self, t_s: f64) -> Vec<Vec3> {
let inertial: Vec<Vec3> = match self.format {
EphemerisFormat::Elements => self.sats.iter().map(|s| s.position_mci(t_s)).collect(),
EphemerisFormat::States => self.tracks.iter().map(|tr| tr.at(t_s)).collect(),
};
match self.frame {
StateFrame::Mci => inertial.into_iter().map(|r| mci_to_mcmf(r, t_s)).collect(),
StateFrame::Icrf => {
let jd = self.epoch_jd_tdb + t_s / SECONDS_PER_DAY;
let m = crate::lunar_frame::icrf_to_iau_moon(jd);
inertial
.into_iter()
.map(|r| crate::precession::mat_vec(&m, r))
.collect()
}
}
}
}
pub fn published_frame_tie_angle_deg(jd_tdb: f64) -> f64 {
let jc = |jd: f64| (jd - 2_451_545.0) / 36_525.0;
let h = 1.0 / 24.0; let rm = crate::ephem::moon_position(jc(jd_tdb));
let rp = crate::ephem::moon_position(jc(jd_tdb + h));
let rn = crate::ephem::moon_position(jc(jd_tdb - h));
let v = [
(rp[0] - rn[0]) / (2.0 * h),
(rp[1] - rn[1]) / (2.0 * h),
(rp[2] - rn[2]) / (2.0 * h),
];
let n = [
rm[1] * v[2] - rm[2] * v[1],
rm[2] * v[0] - rm[0] * v[2],
rm[0] * v[1] - rm[1] * v[0],
];
let (ra, dec) = crate::lunar_frame::lunar_pole_ra_dec(jd_tdb);
let pole = [dec.cos() * ra.cos(), dec.cos() * ra.sin(), dec.sin()];
let nn = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
if nn == 0.0 {
return 0.0;
}
let c = ((n[0] * pole[0] + n[1] * pole[1] + n[2] * pole[2]) / nn).clamp(-1.0, 1.0);
c.acos().to_degrees()
}
#[derive(Clone, Debug, Serialize)]
pub struct EphemerisSourceBlock {
pub path: String,
pub sha256: String,
pub format: String,
pub provenance_class: String,
pub frame: String,
pub n_sats: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub n_epochs: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub covered_until_s: Option<f64>,
pub name: String,
pub source: String,
pub url: String,
pub retrieved: String,
pub source_sha256: String,
pub published_frame: String,
pub source_caveat: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub published_frame_tie_angle_deg: Option<f64>,
pub note: String,
}
pub fn source_block(e: &LunarEphemeris) -> EphemerisSourceBlock {
let published_frame = e.meta("published_frame");
let tie = (e.format() == EphemerisFormat::Elements
&& published_frame.to_ascii_uppercase().starts_with("OP"))
.then(|| {
published_frame_tie_angle_deg(2_451_545.0)
});
let note = match e.format() {
EphemerisFormat::States => format!(
"Tabulated Moon-centred state ephemeris ({} frame), Lagrange-interpolated at \
order {LAGRANGE_ORDER} between table epochs and reduced to MCMF by {}. The \
states are NOT propagated by this engine. Provenance class \
`published-ephemeris`: distinct from every element-derived figure.",
e.state_frame().as_str(),
match e.state_frame() {
StateFrame::Icrf =>
"the IAU 2015 / WGCCRE lunar orientation (precessing pole, analytic libration)",
StateFrame::Mci => "the mean-rotation lunar spin model",
}
),
EphemerisFormat::Elements if e.state_frame() == StateFrame::Icrf => format!(
"Published constellation DEFINITION stated in Moon-centred ICRF (`{}`), \
propagated by this engine's own Kepler solver in that same inertial frame and \
reduced to MCMF by the IAU 2015 / WGCCRE lunar orientation (precessing pole, \
analytic physical libration) at each epoch. NO frame approximation: the source \
names the frame and the epoch, and both are used. Provenance class \
`published-elements`: distinct from every kernel-derived figure.",
if published_frame.is_empty() {
"ICRF"
} else {
published_frame.as_str()
}
),
EphemerisFormat::Elements => format!(
"Published constellation DEFINITION, propagated by this engine's own Kepler \
solver and reduced by the mean-rotation lunar spin model — exactly the path \
the illustrative constellation takes, so the only difference between the two \
runs is the constellation design. The source states the elements in the `{}` \
frame; they are read here as MCI, which tilts the constellation by \
`published_frame_tie_angle_deg`. Provenance class `published-elements`: \
distinct from every kernel-derived figure.",
if published_frame.is_empty() {
"(unstated)"
} else {
published_frame.as_str()
}
),
};
EphemerisSourceBlock {
path: e.path().to_string(),
sha256: e.sha256().to_string(),
format: e.format().as_str().to_string(),
provenance_class: e.provenance_class().to_string(),
frame: e.state_frame().as_str().to_string(),
n_sats: e.n_sats(),
n_epochs: e.n_epochs(),
covered_until_s: e.covered_until_s(),
name: e.meta("name"),
source: e.meta("source"),
url: e.meta("url"),
retrieved: e.meta("retrieved"),
source_sha256: e.meta("source_sha256"),
published_frame,
source_caveat: e.meta("source_caveat"),
published_frame_tie_angle_deg: tie,
note,
}
}
pub const PROPAGATION_MOON_GM_M3_S2: f64 = MOON_GM_M3_S2;
#[cfg(test)]
mod tests {
use super::*;
const ELEMENTS: &str = "\
# kshana-lunar-constellation 1
# format: elements
# name: two-satellite test set
# published_frame: OP (Earth orbital plane frame)
sat,sma_km,ecc,inc_deg,raan_deg,argp_deg,mean_anom_deg
0,6143,0.6,51.7,0,90,0
1,6143,0.6,51.7,180,90,90
";
const STATES: &str = "\
# kshana-lunar-constellation 1
# format: states
# frame: mci
# epoch_jd_tdb: 2459945.5
sat,t_s,x_km,y_km,z_km
0,0,1000,0,0
0,600,1000,600,0
0,1200,1000,1200,0
1,0,0,2000,0
1,600,0,2000,600
1,1200,0,2000,1200
";
const ELEMENTS_ICRF: &str = "\
# kshana-lunar-constellation 1
# format: elements
# elements_frame: icrf
# epoch_jd_tdb: 2461406.5
# published_frame: ICRF, as stated by the source
sat,sma_km,ecc,inc_deg,raan_deg,argp_deg,true_anom_deg
0,6143,0.6,51.7,0,90,0
1,6143,0.6,51.7,180,90,90
";
#[test]
fn elements_parse_into_the_same_kepler_satellites_the_scenario_uses() {
let e = LunarEphemeris::parse(ELEMENTS).expect("parses");
assert_eq!(e.format(), EphemerisFormat::Elements);
assert_eq!(e.n_sats(), 2);
assert_eq!(e.provenance_class(), "published-elements");
assert_eq!(e.elements()[1].raan_deg, 180.0);
assert_eq!(e.elements()[0].sma_m, 6_143_000.0);
let want: Vec<_> = e
.elements()
.iter()
.map(|s| mci_to_mcmf(s.position_mci(1234.0), 1234.0))
.collect();
assert_eq!(e.positions_mcmf(1234.0), want);
}
#[test]
fn states_parse_and_interpolate_exactly_at_table_nodes() {
let e = LunarEphemeris::parse(STATES).expect("parses");
assert_eq!(e.format(), EphemerisFormat::States);
assert_eq!(e.provenance_class(), "published-ephemeris");
assert_eq!(e.n_sats(), 2);
assert_eq!(e.n_epochs(), Some(3));
assert_eq!(e.covered_until_s(), Some(1200.0));
let at600 = e.positions_mcmf(600.0);
let want0 = mci_to_mcmf([1_000_000.0, 600_000.0, 0.0], 600.0);
for k in 0..3 {
assert!(
(at600[0][k] - want0[k]).abs() < 1e-6,
"node value not reproduced: {:?} vs {want0:?}",
at600[0]
);
}
}
#[test]
fn states_interpolate_a_linear_track_exactly_off_node() {
let e = LunarEphemeris::parse(STATES).expect("parses");
let got = e.positions_mcmf(300.0);
let want = mci_to_mcmf([1_000_000.0, 300_000.0, 0.0], 300.0);
for k in 0..3 {
assert!(
(got[0][k] - want[k]).abs() < 1e-6,
"off-node interpolation wrong: {:?} vs {want:?}",
got[0]
);
}
}
#[test]
fn a_hash_is_taken_over_the_exact_bytes() {
let a = LunarEphemeris::parse(ELEMENTS).unwrap();
let b = LunarEphemeris::parse(&format!("{ELEMENTS}# trailing comment\n")).unwrap();
assert_ne!(a.sha256(), b.sha256(), "the hash must follow the bytes");
assert_eq!(a.sha256().len(), 64);
}
#[test]
fn bad_files_are_refused_rather_than_guessed_at() {
for (src, why) in [
("nonsense\n", "first line"),
(
"# kshana-lunar-constellation 1\n# format: nope\nsat\n0\n",
"format",
),
(
"# kshana-lunar-constellation 1\n# format: states\n# frame: galactic\nsat,t_s,x_km,y_km,z_km\n0,0,1,1,1\n",
"frame",
),
(
"# kshana-lunar-constellation 1\n# format: elements\nsat,sma_km,ecc,inc_deg,raan_deg,argp_deg\n0,1,2,3,4,5\n",
"missing column",
),
(
"# kshana-lunar-constellation 1\n# format: elements\nsat,sma_km,ecc,inc_deg,raan_deg,argp_deg,mean_anom_deg\n0,6143,1.4,51,0,90,0\n",
"eccentricity",
),
(
"# kshana-lunar-constellation 1\n# format: states\n# frame: mci\nsat,t_s,x_km,y_km,z_km\n0,100,1,1,1\n0,50,1,1,1\n",
"back in time",
),
(
"# kshana-lunar-constellation 1\n# format: elements\n# elements_frame: icrf\nsat,sma_km,ecc,inc_deg,raan_deg,argp_deg,mean_anom_deg\n0,6143,0.6,51,0,90,0\n",
"epoch_jd_tdb",
),
(
"# kshana-lunar-constellation 1\n# format: elements\nsat,sma_km,ecc,inc_deg,raan_deg,argp_deg,mean_anom_deg,true_anom_deg\n0,6143,0.6,51,0,90,0,0\n",
"exactly one",
),
] {
let e = LunarEphemeris::parse(src).expect_err("must be refused");
assert!(e.contains(why), "error {e:?} does not mention {why:?}");
}
}
#[test]
fn icrf_elements_take_the_iau_reduction_not_the_mean_rotation_one() {
let icrf = LunarEphemeris::parse(ELEMENTS_ICRF).expect("parses");
assert_eq!(icrf.state_frame(), StateFrame::Icrf);
assert_eq!(icrf.n_sats(), 2);
let mci = LunarEphemeris::parse(ELEMENTS).expect("parses");
assert_eq!(icrf.elements()[0].sma_m, mci.elements()[0].sma_m);
assert_eq!(icrf.elements()[0].mean_anom_deg, 0.0);
let a = icrf.positions_mcmf(3600.0)[0];
let b = mci.positions_mcmf(3600.0)[0];
let sep = ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt();
assert!(
sep > 1.0e6,
"the ICRF reduction is indistinguishable from the mean-rotation one ({sep} m apart)"
);
let jd = 2_461_406.5 + 3600.0 / SECONDS_PER_DAY;
let want = crate::precession::mat_vec(
&crate::lunar_frame::icrf_to_iau_moon(jd),
icrf.elements()[0].position_mci(3600.0),
);
assert_eq!(a, want);
}
#[test]
fn true_to_mean_anomaly_inverts_keplers_equation() {
for e in [0.0, 0.1, 0.6, 0.721] {
for nu in [0.0, 180.0, -180.0] {
assert!(
(true_to_mean_anomaly_deg(nu, e) - nu).abs() < 1e-9,
"at nu={nu} the anomalies must coincide for any eccentricity"
);
}
for nu in [-147.49, -119.79, -5.0, 37.0, 90.0, 151.3] {
let m = true_to_mean_anomaly_deg(nu, e).to_radians();
let mut ea = m;
for _ in 0..80 {
ea -= (ea - e * ea.sin() - m) / (1.0 - e * ea.cos());
}
let back = 2.0
* ((1.0 + e).sqrt() * (ea * 0.5).sin())
.atan2((1.0 - e).sqrt() * (ea * 0.5).cos());
assert!(
(back.to_degrees() - nu).abs() < 1e-8,
"e={e}, nu={nu}: round-trip gave {}",
back.to_degrees()
);
}
}
}
#[test]
fn the_published_frame_tie_is_computed_and_small_but_not_zero() {
let a = published_frame_tie_angle_deg(2_451_545.0);
assert!(
(1.0..15.0).contains(&a),
"OP-frame tie angle {a} deg is outside the physically possible band"
);
let b = published_frame_tie_angle_deg(2_451_545.0 + 3000.0);
assert!((a - b).abs() > 1e-6, "the tie angle is not epoch-dependent");
}
}