use crate::batch_ls::gauss_newton;
use crate::precession::{mat_vec, matmul, rx, ry, rz, transpose, Mat3};
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use rand_distr::{Distribution, Normal};
type Vec3 = [f64; 3];
const PPB: f64 = 1.0e-9;
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize)]
pub struct FrameDatum {
pub translation_m: [f64; 3],
pub rotation_rad: [f64; 3],
pub scale_ppb: f64,
}
fn rotation(theta: [f64; 3]) -> Mat3 {
matmul(&matmul(&rz(theta[2]), &ry(theta[1])), &rx(theta[0]))
}
pub fn apply_helmert(d: &FrameDatum, p: Vec3) -> Vec3 {
let r = rotation(d.rotation_rad);
let rp = mat_vec(&r, p);
let s = 1.0 + d.scale_ppb * PPB;
[
d.translation_m[0] + s * rp[0],
d.translation_m[1] + s * rp[1],
d.translation_m[2] + s * rp[2],
]
}
const URAD: f64 = 1.0e-6;
fn datum_from_params(x: &[f64]) -> FrameDatum {
FrameDatum {
translation_m: [x[0], x[1], x[2]],
rotation_rad: [x[3] * URAD, x[4] * URAD, x[5] * URAD],
scale_ppb: x[6],
}
}
fn forward(p: &[Vec3], x: &[f64]) -> Vec<f64> {
let d = datum_from_params(x);
let mut z = Vec::with_capacity(p.len() * 3);
for &pi in p {
let qi = apply_helmert(&d, pi);
z.push(qi[0]);
z.push(qi[1]);
z.push(qi[2]);
}
z
}
fn centroid(p: &[Vec3]) -> Vec3 {
let n = p.len().max(1) as f64;
let mut c = [0.0; 3];
for &pi in p {
c[0] += pi[0];
c[1] += pi[1];
c[2] += pi[2];
}
[c[0] / n, c[1] / n, c[2] / n]
}
fn helmert_fit_inner(p: &[Vec3], q: &[Vec3], sigma_m: f64) -> Option<(FrameDatum, bool)> {
if p.len() != q.len() || p.len() < 3 {
return None;
}
let c = centroid(p);
let p_c: Vec<Vec3> = p.iter().map(|&pi| sub(pi, c)).collect();
let q_c: Vec<Vec3> = q.iter().map(|&qi| sub(qi, c)).collect();
let z: Vec<f64> = q_c.iter().flat_map(|qi| [qi[0], qi[1], qi[2]]).collect();
let sig = sigma_m.max(1e-9);
let w = 1.0 / (sig * sig);
let weights = vec![w; z.len()];
let h = move |x: &[f64]| forward(&p_c, x);
let x0 = vec![0.0; 7];
let r = gauss_newton(h, &z, &weights, &x0, 50, 1e-8)?;
if !r.x.iter().all(|v| v.is_finite()) {
return None;
}
let mut d = datum_from_params(&r.x);
let rot = rotation(d.rotation_rad);
let rc = mat_vec(&rot, c);
let s = 1.0 + d.scale_ppb * PPB;
d.translation_m = [
d.translation_m[0] + c[0] - s * rc[0],
d.translation_m[1] + c[1] - s * rc[1],
d.translation_m[2] + c[2] - s * rc[2],
];
Some((d, r.converged))
}
fn sub(a: Vec3, b: Vec3) -> Vec3 {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
pub fn helmert_fit(p: &[Vec3], q: &[Vec3], sigma_m: f64) -> Option<FrameDatum> {
helmert_fit_inner(p, q, sigma_m).map(|(d, _)| d)
}
#[derive(Clone, Copy, Debug, serde::Serialize)]
pub struct RealisedFrame {
pub datum: FrameDatum,
pub rms_residual_m: f64,
pub n_points: usize,
pub converged: bool,
}
pub fn realise_frame(
estimated: &[Vec3],
datum_truth: &[Vec3],
sigma_m: f64,
) -> Option<RealisedFrame> {
let (datum, converged) = helmert_fit_inner(estimated, datum_truth, sigma_m)?;
let mut acc = 0.0;
for (&p, &q) in estimated.iter().zip(datum_truth.iter()) {
let pred = apply_helmert(&datum, p);
for k in 0..3 {
let r = q[k] - pred[k];
acc += r * r;
}
}
let n = (estimated.len() * 3) as f64;
let rms_residual_m = if n > 0.0 { (acc / n).sqrt() } else { 0.0 };
Some(RealisedFrame {
datum,
rms_residual_m,
n_points: estimated.len(),
converged,
})
}
pub fn icrf_orientation_tie(jd_tdb: f64, realised_rotation_rad: [f64; 3]) -> [f64; 3] {
let b = transpose(&crate::lunar_frame::icrf_to_iau_moon(jd_tdb)); let r_r = rotation(realised_rotation_rad);
let m = matmul(&matmul(&b, &r_r), &transpose(&b));
[
0.5 * (m[2][1] - m[1][2]),
0.5 * (m[0][2] - m[2][0]),
0.5 * (m[1][0] - m[0][1]),
]
}
fn point_network(n: usize) -> Vec<Vec3> {
let n = n.max(3);
(0..n)
.map(|k| {
let f = k as f64 / n as f64;
let lat = (-80.0 + 160.0 * f).to_radians();
let lon = ((k as f64) * 137.508).to_radians();
let alt = if k % 3 == 0 {
0.0
} else {
50_000.0 * ((k % 5) as f64)
};
crate::lunar::selenographic_to_mcmf(crate::lunar::Selenographic {
lat_rad: lat,
lon_rad: lon,
alt_m: alt,
})
})
.collect()
}
fn d_n_points() -> usize {
8
}
fn d_tx_m() -> f64 {
25.0
}
fn d_ty_m() -> f64 {
-40.0
}
fn d_tz_m() -> f64 {
15.0
}
fn d_rot_x_urad() -> f64 {
3.0
}
fn d_rot_y_urad() -> f64 {
-2.0
}
fn d_rot_z_urad() -> f64 {
5.0
}
fn d_scale_ppb() -> f64 {
100.0 }
fn d_noise_sigma_m() -> f64 {
1.0
}
fn d_seed() -> u64 {
42
}
fn d_epoch_year() -> i32 {
2024
}
fn d_epoch_month() -> u32 {
1
}
fn d_epoch_day() -> u32 {
1
}
#[derive(Clone, Copy, Debug, serde::Deserialize)]
pub struct LunarFrameRealiseScenario {
#[serde(default = "d_n_points")]
pub n_points: usize,
#[serde(default = "d_tx_m")]
pub tx_m: f64,
#[serde(default = "d_ty_m")]
pub ty_m: f64,
#[serde(default = "d_tz_m")]
pub tz_m: f64,
#[serde(default = "d_rot_x_urad")]
pub rot_x_urad: f64,
#[serde(default = "d_rot_y_urad")]
pub rot_y_urad: f64,
#[serde(default = "d_rot_z_urad")]
pub rot_z_urad: f64,
#[serde(default = "d_scale_ppb")]
pub scale_ppb: f64,
#[serde(default = "d_noise_sigma_m")]
pub noise_sigma_m: f64,
#[serde(default = "d_seed")]
pub seed: u64,
#[serde(default = "d_epoch_year")]
pub epoch_year: i32,
#[serde(default = "d_epoch_month")]
pub epoch_month: u32,
#[serde(default = "d_epoch_day")]
pub epoch_day: u32,
}
impl Default for LunarFrameRealiseScenario {
fn default() -> Self {
LunarFrameRealiseScenario {
n_points: d_n_points(),
tx_m: d_tx_m(),
ty_m: d_ty_m(),
tz_m: d_tz_m(),
rot_x_urad: d_rot_x_urad(),
rot_y_urad: d_rot_y_urad(),
rot_z_urad: d_rot_z_urad(),
scale_ppb: d_scale_ppb(),
noise_sigma_m: d_noise_sigma_m(),
seed: d_seed(),
epoch_year: d_epoch_year(),
epoch_month: d_epoch_month(),
epoch_day: d_epoch_day(),
}
}
}
impl LunarFrameRealiseScenario {
fn injected(&self) -> FrameDatum {
FrameDatum {
translation_m: [self.tx_m, self.ty_m, self.tz_m],
rotation_rad: [
self.rot_x_urad * URAD,
self.rot_y_urad * URAD,
self.rot_z_urad * URAD,
],
scale_ppb: self.scale_ppb,
}
}
pub fn run(&self) -> LunarFrameRealiseReport {
let injected = self.injected();
let p = point_network(self.n_points);
let mut rng = ChaCha8Rng::seed_from_u64(self.seed);
let noise_sigma = {
let s = self.noise_sigma_m.max(0.0);
if s.is_finite() {
s
} else {
0.0
}
};
let noise = Normal::new(0.0, noise_sigma)
.expect("noise_sigma is finite and non-negative, which Normal::new always accepts");
let q: Vec<Vec3> = p
.iter()
.map(|&pi| {
let qi = apply_helmert(&injected, pi);
if self.noise_sigma_m > 0.0 {
[
qi[0] + noise.sample(&mut rng),
qi[1] + noise.sample(&mut rng),
qi[2] + noise.sample(&mut rng),
]
} else {
qi
}
})
.collect();
let sigma_fit = self.noise_sigma_m.max(1e-3);
let realised = realise_frame(&p, &q, sigma_fit).unwrap_or(RealisedFrame {
datum: FrameDatum {
translation_m: [f64::NAN; 3],
rotation_rad: [f64::NAN; 3],
scale_ppb: f64::NAN,
},
rms_residual_m: f64::NAN,
n_points: p.len(),
converged: false,
});
let rec = realised.datum;
let trans_err_m = [
rec.translation_m[0] - injected.translation_m[0],
rec.translation_m[1] - injected.translation_m[1],
rec.translation_m[2] - injected.translation_m[2],
];
let rot_err_rad = [
rec.rotation_rad[0] - injected.rotation_rad[0],
rec.rotation_rad[1] - injected.rotation_rad[1],
rec.rotation_rad[2] - injected.rotation_rad[2],
];
let scale_err_ppb = rec.scale_ppb - injected.scale_ppb;
let trans_err_norm_m =
(trans_err_m[0].powi(2) + trans_err_m[1].powi(2) + trans_err_m[2].powi(2)).sqrt();
let rot_err_norm_rad =
(rot_err_rad[0].powi(2) + rot_err_rad[1].powi(2) + rot_err_rad[2].powi(2)).sqrt();
let jd_utc = crate::timescales::julian_date(
self.epoch_year,
self.epoch_month,
self.epoch_day,
0,
0,
0.0,
);
let jd_tt = crate::timescales::utc_to_tt(jd_utc);
let icrf_tie_rad = icrf_orientation_tie(jd_tt, rec.rotation_rad);
LunarFrameRealiseReport {
injected,
recovered: rec,
trans_err_m,
rot_err_rad,
scale_err_ppb,
trans_err_norm_m,
rot_err_norm_rad,
rms_residual_m: realised.rms_residual_m,
icrf_tie_rad,
n_points: realised.n_points,
converged: realised.converged,
}
}
}
#[derive(Clone, Copy, Debug, serde::Serialize)]
pub struct LunarFrameRealiseReport {
pub injected: FrameDatum,
pub recovered: FrameDatum,
pub trans_err_m: [f64; 3],
pub rot_err_rad: [f64; 3],
pub scale_err_ppb: f64,
pub trans_err_norm_m: f64,
pub rot_err_norm_rad: f64,
pub rms_residual_m: f64,
pub icrf_tie_rad: [f64; 3],
pub n_points: usize,
pub converged: bool,
}
pub fn lunar_frame_realise_svg(r: &LunarFrameRealiseReport) -> String {
let (w, h) = (820.0_f64, 360.0_f64);
let ml = 70.0_f64;
let mut svg = String::new();
svg.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w:.0}\" height=\"{h:.0}\" font-family=\"sans-serif\" font-size=\"12\" fill=\"#bcb3a3\">"
));
svg.push_str(&format!(
"<rect width=\"{w:.0}\" height=\"{h:.0}\" fill=\"#0c0b08\"/>"
));
svg.push_str(&format!(
"<text x=\"{ml:.0}\" y=\"22\" font-size=\"15\" font-weight=\"bold\">Lunar reference-frame realisation — 7-parameter Helmert recovery</text>"
));
svg.push_str(&format!(
"<text x=\"{ml:.0}\" y=\"40\" font-size=\"11\">{} points · RMS residual {:.3} m · converged {}</text>",
r.n_points, r.rms_residual_m, r.converged
));
let rows: [(&str, f64, f64, &str); 7] = [
(
"tx (m)",
r.injected.translation_m[0],
r.recovered.translation_m[0],
"m",
),
(
"ty (m)",
r.injected.translation_m[1],
r.recovered.translation_m[1],
"m",
),
(
"tz (m)",
r.injected.translation_m[2],
r.recovered.translation_m[2],
"m",
),
(
"θx (µrad)",
r.injected.rotation_rad[0] / URAD,
r.recovered.rotation_rad[0] / URAD,
"µrad",
),
(
"θy (µrad)",
r.injected.rotation_rad[1] / URAD,
r.recovered.rotation_rad[1] / URAD,
"µrad",
),
(
"θz (µrad)",
r.injected.rotation_rad[2] / URAD,
r.recovered.rotation_rad[2] / URAD,
"µrad",
),
(
"scale (ppb)",
r.injected.scale_ppb,
r.recovered.scale_ppb,
"ppb",
),
];
let y0 = 70.0_f64;
let dy = 38.0_f64;
for (i, (label, inj, rec, unit)) in rows.iter().enumerate() {
let y = y0 + i as f64 * dy;
svg.push_str(&format!(
"<text x=\"{ml:.0}\" y=\"{y:.0}\" font-size=\"12\">{label}: injected {inj:.4} {unit} · recovered {rec:.4} {unit} · err {:.3e}</text>",
rec - inj
));
let bar = ((rec.abs() / inj.abs().max(1e-9)).min(2.0)) * 120.0;
svg.push_str(&format!(
"<rect x=\"{:.0}\" y=\"{:.0}\" width=\"{bar:.1}\" height=\"6\" fill=\"#7fbf7f\"/>",
ml + 560.0,
y - 9.0,
));
}
svg.push_str("</svg>");
svg
}
#[cfg(test)]
mod tests {
use super::*;
fn net(n: usize) -> Vec<Vec3> {
point_network(n)
}
#[test]
fn apply_then_fit_recovers_identity_noiseless() {
let injected = FrameDatum {
translation_m: [25.0, -40.0, 15.0],
rotation_rad: [3.0 * URAD, -2.0 * URAD, 5.0 * URAD],
scale_ppb: 100.0, };
let p = net(8);
let q: Vec<Vec3> = p.iter().map(|&pi| apply_helmert(&injected, pi)).collect();
let rec = helmert_fit(&p, &q, 1.0).expect("fit");
for k in 0..3 {
assert!(
(rec.translation_m[k] - injected.translation_m[k]).abs() < 1e-6,
"tx[{k}] err = {} m",
rec.translation_m[k] - injected.translation_m[k]
);
}
for k in 0..3 {
assert!(
(rec.rotation_rad[k] - injected.rotation_rad[k]).abs() < 1e-9,
"θ[{k}] err = {} rad",
rec.rotation_rad[k] - injected.rotation_rad[k]
);
}
assert!(
(rec.scale_ppb - injected.scale_ppb).abs() * PPB < 1e-12,
"scale err = {} (dimensionless)",
(rec.scale_ppb - injected.scale_ppb).abs() * PPB
);
}
#[test]
fn recovers_with_noise() {
let r = LunarFrameRealiseScenario {
noise_sigma_m: 1.0,
..LunarFrameRealiseScenario::default()
}
.run();
assert!(
r.trans_err_norm_m < 5.0,
"translation error {} m too large with 1 m noise",
r.trans_err_norm_m
);
assert!(
r.rot_err_norm_rad < 1.0e-6,
"rotation error {} rad too large with 1 m noise",
r.rot_err_norm_rad
);
assert!(
r.scale_err_ppb.abs() < 2000.0,
"scale error {} ppb too large with 1 m noise",
r.scale_err_ppb
);
assert!(
r.rms_residual_m > 0.1 && r.rms_residual_m < 3.0,
"rms residual {} m not near the 1 m noise level",
r.rms_residual_m
);
assert!(r.trans_err_norm_m.is_finite() && r.scale_err_ppb.is_finite());
}
#[test]
fn apply_helmert_roundtrips() {
let d = FrameDatum {
translation_m: [12.0, 7.0, -9.0],
rotation_rad: [-4.0 * URAD, 6.0 * URAD, 2.0 * URAD],
scale_ppb: 50.0,
};
let p = net(8);
let q: Vec<Vec3> = p.iter().map(|&pi| apply_helmert(&d, pi)).collect();
let inv = helmert_fit(&q, &p, 1.0).expect("inverse fit");
for (&pi, &qi) in p.iter().zip(q.iter()) {
let back = apply_helmert(&inv, qi);
for k in 0..3 {
assert!(
(back[k] - pi[k]).abs() < 1e-4,
"roundtrip[{k}] = {} vs {}",
back[k],
pi[k]
);
}
}
}
#[test]
fn deterministic_same_seed() {
let a = LunarFrameRealiseScenario::default().run();
let b = LunarFrameRealiseScenario::default().run();
assert_eq!(a.trans_err_norm_m, b.trans_err_norm_m);
assert_eq!(a.rot_err_norm_rad, b.rot_err_norm_rad);
assert_eq!(a.scale_err_ppb, b.scale_err_ppb);
assert_eq!(a.rms_residual_m, b.rms_residual_m);
let c = LunarFrameRealiseScenario {
seed: 7,
..LunarFrameRealiseScenario::default()
}
.run();
assert_ne!(a.rms_residual_m, c.rms_residual_m);
assert!(c.trans_err_norm_m < 5.0 && c.rot_err_norm_rad < 1.0e-6);
}
#[test]
fn realise_frame_residual_is_machine_small_noiseless() {
let injected = FrameDatum {
translation_m: [10.0, -20.0, 30.0],
rotation_rad: [1.0 * URAD, 2.0 * URAD, -3.0 * URAD],
scale_ppb: 80.0,
};
let p = net(10);
let q: Vec<Vec3> = p.iter().map(|&pi| apply_helmert(&injected, pi)).collect();
let realised = realise_frame(&p, &q, 1.0).expect("realise");
assert!(realised.converged, "noiseless realisation did not converge");
assert!(
realised.rms_residual_m < 1e-4,
"noiseless RMS residual {} m not machine-small",
realised.rms_residual_m
);
assert!(realised.n_points == 10);
}
#[test]
fn rejects_degenerate_input() {
let p = net(2);
let q = p.clone();
assert!(helmert_fit(&p[..2], &q[..2], 1.0).is_none());
let p3 = net(3);
let q4 = net(4);
assert!(helmert_fit(&p3, &q4, 1.0).is_none());
}
#[test]
fn icrf_tie_zero_rotation_is_zero() {
let jd =
crate::timescales::utc_to_tt(crate::timescales::julian_date(2024, 1, 1, 0, 0, 0.0));
let tie = icrf_orientation_tie(jd, [0.0, 0.0, 0.0]);
for (k, &t) in tie.iter().enumerate() {
assert!(t.abs() < 1e-15, "tie[{k}] = {t} not zero");
}
}
#[test]
fn icrf_tie_preserves_rotation_magnitude() {
let jd =
crate::timescales::utc_to_tt(crate::timescales::julian_date(2024, 1, 1, 0, 0, 0.0));
let theta = [3.0 * URAD, -2.0 * URAD, 5.0 * URAD];
let tie = icrf_orientation_tie(jd, theta);
let in_mag = (theta[0].powi(2) + theta[1].powi(2) + theta[2].powi(2)).sqrt();
let tie_mag = (tie[0].powi(2) + tie[1].powi(2) + tie[2].powi(2)).sqrt();
assert!(
(in_mag - tie_mag).abs() / in_mag < 1e-6,
"rotation magnitude not preserved: in {in_mag} tie {tie_mag}"
);
}
#[test]
fn svg_is_self_contained() {
let r = LunarFrameRealiseScenario::default().run();
let svg = lunar_frame_realise_svg(&r);
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
assert!(svg.contains("Lunar reference-frame realisation"));
}
#[test]
fn run_toml_lunar_frame_realise_dispatches() {
let out = crate::api::run_toml("kind=\"lunar-frame-realisation\"\n").unwrap();
assert!(
out.summary.contains("lunar-frame-realisation"),
"summary missing kind: {}",
out.summary
);
let j: serde_json::Value = serde_json::from_str(&out.json).unwrap();
assert!(j["rms_residual_m"].as_f64().unwrap().is_finite());
assert!(out.svg.starts_with("<svg"));
}
}