use crate::clock_specs::{x_clock_ns, LunarClock};
use crate::lunar_time_budget::{default_tau_grid, lunar_time_budget, BudgetParams};
use serde::Deserialize;
const LABEL: &str = "MODELLED end-to-end LTC time-error budget. The τ-slopes are \
closed-form and analytically checkable (clock τ^{+1/2}/τ^{+1}, floors τ^0, measurement \
τ^{-1/2}) and the clock rows reproduce the published one-day clock specs; the RF/optical \
link, frame-realisation, relativistic-residual and ephemeris floor MAGNITUDES are \
Modelled budget allocations (documented defaults, caller-overridable), not measurements. \
The contribution is the reproducible clock-vs-frame crossover τ, not a certified per-term \
number. Not certified for operational timekeeping.";
#[derive(Clone, Debug, Default, Deserialize)]
pub struct LunarTimeBudgetScenario {
pub clock: Option<String>,
pub tau_min_s: Option<f64>,
pub tau_max_s: Option<f64>,
pub points_per_decade: Option<u32>,
}
impl LunarTimeBudgetScenario {
fn resolve_clock(&self) -> Result<LunarClock, String> {
match self.clock.as_deref().unwrap_or("passive-h-maser") {
"optical-master" => Ok(LunarClock::OpticalMaster),
"passive-h-maser" | "phm" => Ok(LunarClock::Phm),
"rafs" => Ok(LunarClock::Rafs),
"mini-rafs" => Ok(LunarClock::MiniRafs),
other => Err(format!(
"unknown clock {other:?}; expected one of optical-master, \
passive-h-maser, rafs, mini-rafs"
)),
}
}
fn build_tau_grid(&self) -> Result<Vec<f64>, String> {
if self.tau_min_s.is_none() && self.tau_max_s.is_none() && self.points_per_decade.is_none()
{
return Ok(default_tau_grid());
}
let lo = self.tau_min_s.unwrap_or(1.0);
let hi = self.tau_max_s.unwrap_or(1.0e7);
let ppd = self.points_per_decade.unwrap_or(8);
if !(lo.is_finite() && lo > 0.0) {
return Err(format!("tau_min_s must be finite and positive, got {lo}"));
}
if !(hi.is_finite() && hi > lo) {
return Err(format!(
"tau_max_s must be finite and greater than tau_min_s ({lo}), got {hi}"
));
}
if ppd == 0 {
return Err("points_per_decade must be ≥ 1".to_string());
}
let decades = (hi / lo).log10();
let n = (decades * ppd as f64).round() as i64 + 1;
Ok((0..n)
.map(|k| lo * 10f64.powf(k as f64 / ppd as f64))
.collect())
}
pub fn run_json(&self) -> Result<(String, String), String> {
let clock = self.resolve_clock()?;
let taus = self.build_tau_grid()?;
let params = BudgetParams::for_clock(clock);
let budget = lunar_time_budget(¶ms, &taus);
let mut v = serde_json::to_value(&budget).map_err(|e| e.to_string())?;
if let Some(obj) = v.as_object_mut() {
obj.insert(
"kind".to_string(),
serde_json::Value::from("lunar-time-budget"),
);
obj.insert("label".to_string(), serde_json::Value::from(LABEL));
}
let json = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
let x_1day_ns = x_clock_ns(clock, 86_400.0);
let summary = format!(
"lunar-time-budget | clock {} | 7-term x_Σ(τ) over {} τ-points \
({:.0}–{:.0e} s) | crossover τ {:.3e} s (x {:.3e} s) | frame floor \
{:.3e} s | clock x(1 d) {:.3} ns (MODELLED)",
budget.clock,
taus.len(),
taus.first().copied().unwrap_or(0.0),
taus.last().copied().unwrap_or(0.0),
budget.crossover_tau_s,
budget.crossover_x_s,
budget.frame_term_s,
x_1day_ns,
);
Ok((json, summary))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::clock_specs::sigma_y;
use serde_json::Value;
#[test]
fn default_scenario_runs_and_is_modelled() {
let (json, summary) = LunarTimeBudgetScenario::default().run_json().unwrap();
let v: Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["kind"], "lunar-time-budget");
assert!(v["label"].as_str().unwrap().contains("MODELLED"));
assert_eq!(v["terms"].as_array().unwrap().len(), 7);
assert_eq!(v["clock"], "passive-h-maser");
assert!(summary.contains("lunar-time-budget"));
assert!(summary.contains("MODELLED"));
}
#[test]
fn rss_total_dominates_each_term_everywhere() {
let (json, _s) = LunarTimeBudgetScenario::default().run_json().unwrap();
let v: Value = serde_json::from_str(&json).unwrap();
let sigma: Vec<f64> = v["x_sigma_s"]
.as_array()
.unwrap()
.iter()
.map(|x| x.as_f64().unwrap())
.collect();
for term in v["terms"].as_array().unwrap() {
for (i, xi) in term["x_s"].as_array().unwrap().iter().enumerate() {
assert!(sigma[i] >= xi.as_f64().unwrap() - 1e-24);
}
}
}
#[test]
fn phm_crossover_matches_the_flicker_floor_closed_form() {
let (json, _s) = LunarTimeBudgetScenario::default().run_json().unwrap();
let v: Value = serde_json::from_str(&json).unwrap();
let tau_star = v["crossover_tau_s"].as_f64().unwrap();
let frame = v["frame_term_s"].as_f64().unwrap();
let floor = sigma_y(&LunarClock::Phm.powerlaw(), 1.0);
let analytic = frame / floor;
assert!(
(tau_star - analytic).abs() / analytic < 1e-9,
"crossover {tau_star} vs closed form {analytic}"
);
}
#[test]
fn is_deterministic() {
let scn = LunarTimeBudgetScenario::default();
assert_eq!(scn.run_json().unwrap(), scn.run_json().unwrap());
}
#[test]
fn custom_grid_and_clock_parse() {
let scn = LunarTimeBudgetScenario {
clock: Some("optical-master".to_string()),
tau_min_s: Some(1.0),
tau_max_s: Some(1.0e6),
points_per_decade: Some(4),
};
let (json, _s) = scn.run_json().unwrap();
let v: Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["clock"], "optical-master");
assert_eq!(v["tau_s"].as_array().unwrap().len(), 25);
}
#[test]
fn unknown_clock_is_rejected() {
let scn = LunarTimeBudgetScenario {
clock: Some("grandfather".to_string()),
..Default::default()
};
assert!(scn.run_json().is_err());
}
}