use ndarray::{Array1, ArrayView1};
use crate::chart_canonicalization::{
CanonicalChartTopology, ChartArcLengthReading, SAE_FLOW_DIFFEO_MIN_DET,
UNIT_SPEED_INLOOP_DEFECT_TOL, chart_arclength_coordinates,
};
use super::SaeManifoldTerm;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AngleFidelityVerdict {
ArcLengthHonest,
RecoverableViaArcLength,
Degenerate,
}
impl AngleFidelityVerdict {
pub fn label(self) -> &'static str {
match self {
AngleFidelityVerdict::ArcLengthHonest => "arclength_honest",
AngleFidelityVerdict::RecoverableViaArcLength => "recoverable_via_arclength",
AngleFidelityVerdict::Degenerate => "degenerate",
}
}
pub fn certified(self) -> bool {
!matches!(self, AngleFidelityVerdict::Degenerate)
}
}
pub fn angle_fidelity_verdict(reading: Option<&ChartArcLengthReading>) -> AngleFidelityVerdict {
match reading {
Some(r) if r.min_speed_over_mean > SAE_FLOW_DIFFEO_MIN_DET => {
if r.speed_cv < UNIT_SPEED_INLOOP_DEFECT_TOL {
AngleFidelityVerdict::ArcLengthHonest
} else {
AngleFidelityVerdict::RecoverableViaArcLength
}
}
_ => AngleFidelityVerdict::Degenerate,
}
}
#[derive(Debug, Clone, Copy)]
pub struct WatsonUniformity {
pub statistic: f64,
pub p_value: f64,
pub n: usize,
}
pub fn watson_u2_pvalue(u2: f64) -> f64 {
if !(u2 > 0.0) {
return 1.0;
}
let two_pi_sq = 2.0 * std::f64::consts::PI * std::f64::consts::PI;
let mut sum = 0.0_f64;
for j in 1..=100_usize {
let jf = j as f64;
let term = (-two_pi_sq * jf * jf * u2).exp();
sum += if j % 2 == 1 { term } else { -term };
if term < 1.0e-14 {
break;
}
}
(2.0 * sum).clamp(0.0, 1.0)
}
pub fn watson_u2_uniform(u: &[f64]) -> WatsonUniformity {
let n = u.len();
if n < 2 {
return WatsonUniformity {
statistic: 0.0,
p_value: 1.0,
n,
};
}
let mut v: Vec<f64> = u
.iter()
.map(|&x| {
let f = x - x.floor();
if f >= 1.0 { 0.0 } else { f }
})
.collect();
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let nf = n as f64;
let mut cvm = 1.0 / (12.0 * nf);
let mut mean = 0.0_f64;
for (i, &ui) in v.iter().enumerate() {
let expected = (2.0 * (i as f64 + 1.0) - 1.0) / (2.0 * nf);
let d = ui - expected;
cvm += d * d;
mean += ui;
}
mean /= nf;
let u2 = cvm - nf * (mean - 0.5) * (mean - 0.5);
let p_value = watson_u2_pvalue(u2);
WatsonUniformity {
statistic: u2,
p_value,
n,
}
}
pub fn coordinate_uniformity(
coords: ArrayView1<'_, f64>,
topology: &CanonicalChartTopology,
) -> Option<WatsonUniformity> {
let n = coords.len();
if n < 2 {
return None;
}
if coords.iter().any(|t| !t.is_finite()) {
return None;
}
let u: Vec<f64> = match topology {
CanonicalChartTopology::Circle { period } => {
if !(period.is_finite() && *period > 0.0) {
return None;
}
coords
.iter()
.map(|&t| t.rem_euclid(*period) / *period)
.collect()
}
CanonicalChartTopology::Interval => {
let mut lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY;
for &t in coords.iter() {
lo = lo.min(t);
hi = hi.max(t);
}
let span = hi - lo;
let scale = lo.abs().max(hi.abs()).max(1.0);
if !(span > 1.0e-12 * scale) {
return None;
}
coords.iter().map(|&t| (t - lo) / span).collect()
}
};
Some(watson_u2_uniform(&u))
}
#[derive(Debug, Clone)]
pub struct AtomCoordinateFidelity {
pub topology: &'static str,
pub uniformity_statistic: f64,
pub uniformity_p_value: f64,
pub arclength_defect: f64,
pub n_coords: usize,
pub verdict: AngleFidelityVerdict,
pub certified: bool,
pub coords_u_arc: Option<Array1<f64>>,
pub raw_arclength_defect_rms: f64,
pub raw_arclength_defect_max: f64,
pub min_speed_over_mean: f64,
pub max_speed_over_mean: f64,
pub log_speed_rms: f64,
}
#[derive(Debug, Clone, Copy)]
pub struct CoordinateFidelityCertificate<'a> {
pub atoms: &'a [Option<AtomCoordinateFidelity>],
}
impl<'a> CoordinateFidelityCertificate<'a> {
pub fn new(atoms: &'a [Option<AtomCoordinateFidelity>]) -> Self {
Self { atoms }
}
}
pub fn atom_coordinate_fidelity(
term: &SaeManifoldTerm,
atom_idx: usize,
) -> Result<Option<AtomCoordinateFidelity>, String> {
let Some(topology) = term.d1_unit_speed_topology(atom_idx) else {
return Ok(None);
};
let coords = term.assignment.coords[atom_idx].as_matrix();
if coords.ncols() != 1 {
return Ok(None);
}
let row_coords = coords.column(0);
let uniformity = coordinate_uniformity(row_coords, &topology);
let atom = &term.atoms[atom_idx];
let evaluator = atom.basis_evaluator.as_ref().ok_or_else(|| {
format!("atom_coordinate_fidelity: atom {atom_idx} has no basis evaluator")
})?;
let defect = crate::chart_canonicalization::chart_unit_speed_defect(
evaluator.as_ref(),
atom.decoder_coefficients.view(),
row_coords,
&topology,
)?;
let reading = chart_arclength_coordinates(
evaluator.as_ref(),
atom.decoder_coefficients.view(),
row_coords,
&topology,
)?;
let topology_label = match topology {
CanonicalChartTopology::Circle { .. } => "circle",
CanonicalChartTopology::Interval => "interval",
};
let is_circle = matches!(topology, CanonicalChartTopology::Circle { .. });
let (
verdict,
coords_u_arc,
raw_arclength_defect_rms,
raw_arclength_defect_max,
min_speed_over_mean,
max_speed_over_mean,
log_speed_rms,
) = match reading {
Some(r) if r.min_speed_over_mean > SAE_FLOW_DIFFEO_MIN_DET => {
let verdict = angle_fidelity_verdict(Some(&r));
let (rms, max) =
raw_vs_arclength_defect(row_coords, r.coords_u_arc.view(), &topology, is_circle);
(
verdict,
Some(r.coords_u_arc),
rms,
max,
r.min_speed_over_mean,
r.max_speed_over_mean,
r.log_speed_rms,
)
}
Some(r) => (
AngleFidelityVerdict::Degenerate,
None,
f64::NAN,
f64::NAN,
r.min_speed_over_mean,
r.max_speed_over_mean,
r.log_speed_rms,
),
None => (
AngleFidelityVerdict::Degenerate,
None,
f64::NAN,
f64::NAN,
f64::NAN,
f64::NAN,
f64::NAN,
),
};
Ok(Some(AtomCoordinateFidelity {
topology: topology_label,
uniformity_statistic: uniformity.as_ref().map(|u| u.statistic).unwrap_or(f64::NAN),
uniformity_p_value: uniformity.as_ref().map(|u| u.p_value).unwrap_or(f64::NAN),
arclength_defect: defect.unwrap_or(f64::NAN),
n_coords: uniformity.as_ref().map(|u| u.n).unwrap_or(row_coords.len()),
verdict,
certified: verdict.certified(),
coords_u_arc,
raw_arclength_defect_rms,
raw_arclength_defect_max,
min_speed_over_mean,
max_speed_over_mean,
log_speed_rms,
}))
}
fn raw_vs_arclength_defect(
raw: ArrayView1<'_, f64>,
u_arc: ArrayView1<'_, f64>,
topology: &CanonicalChartTopology,
is_circle: bool,
) -> (f64, f64) {
let n = raw.len();
if n == 0 {
return (f64::NAN, f64::NAN);
}
let r: Vec<f64> = match topology {
CanonicalChartTopology::Circle { period } => {
raw.iter().map(|&t| (t / period).rem_euclid(1.0)).collect()
}
CanonicalChartTopology::Interval => {
let mut lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY;
for &t in raw.iter() {
lo = lo.min(t);
hi = hi.max(t);
}
let span = hi - lo;
if !(span > 0.0) {
return (f64::NAN, f64::NAN);
}
raw.iter()
.map(|&t| ((t - lo) / span).clamp(0.0, 1.0))
.collect()
}
};
let circ_dist = |a: f64, b: f64| -> f64 {
let d = (a - b).rem_euclid(1.0);
d.min(1.0 - d)
};
let mut best_rms = f64::INFINITY;
let mut best_max = f64::INFINITY;
for &s in &[1.0_f64, -1.0_f64] {
let c = if is_circle {
let (mut sx, mut sy) = (0.0_f64, 0.0_f64);
for (ui, ri) in u_arc.iter().zip(r.iter()) {
let diff = ui - s * ri;
let ang = std::f64::consts::TAU * diff;
sx += ang.cos();
sy += ang.sin();
}
sy.atan2(sx) / std::f64::consts::TAU
} else {
let mut acc = 0.0_f64;
for (ui, ri) in u_arc.iter().zip(r.iter()) {
acc += ui - s * ri;
}
acc / n as f64
};
let mut sum_sq = 0.0_f64;
let mut max = 0.0_f64;
for (ui, ri) in u_arc.iter().zip(r.iter()) {
let aligned = s * ri + c;
let d = if is_circle {
circ_dist(*ui, aligned)
} else {
(ui - aligned).abs()
};
sum_sq += d * d;
max = max.max(d);
}
let rms = (sum_sq / n as f64).sqrt();
if rms < best_rms {
best_rms = rms;
best_max = max;
}
}
(best_rms, best_max)
}
pub fn prefer_candidate_basin(
candidate_ev: f64,
candidate_uniformity: Option<f64>,
incumbent_ev: f64,
incumbent_uniformity: Option<f64>,
ev_tol: f64,
) -> bool {
if !candidate_ev.is_finite() {
return false;
}
if !incumbent_ev.is_finite() {
return true;
}
if candidate_ev > incumbent_ev + ev_tol {
return true; }
if incumbent_ev > candidate_ev + ev_tol {
return false; }
match (candidate_uniformity, incumbent_uniformity) {
(Some(candidate), Some(incumbent)) => candidate < incumbent,
_ => false,
}
}
impl SaeManifoldTerm {
pub(crate) fn coordinate_uniformity_aggregate(&self) -> Option<f64> {
let mut sum = 0.0_f64;
let mut count = 0usize;
for atom_idx in 0..self.atoms.len() {
let Some(topology) = self.d1_unit_speed_topology(atom_idx) else {
continue;
};
let coords = self.assignment.coords[atom_idx].as_matrix();
if coords.ncols() != 1 {
continue;
}
if let Some(uniformity) = coordinate_uniformity(coords.column(0), &topology) {
if uniformity.statistic.is_finite() {
sum += uniformity.statistic;
count += 1;
}
}
}
if count == 0 {
None
} else {
Some(sum / count as f64)
}
}
}
#[cfg(test)]
mod coordinate_fidelity_tests {
use super::*;
use crate::manifold::{SAE_FINAL_EV_DEGRADATION_TOL, SaeBasisEvaluator};
use ndarray::{Array1, Array2, Array3, Array4, Array5, ArrayView2};
#[derive(Debug)]
struct CircleHarmonicEvaluator {
harmonics: usize,
}
impl SaeBasisEvaluator for CircleHarmonicEvaluator {
fn evaluate(
&self,
coords: ArrayView2<'_, f64>,
) -> Result<(Array2<f64>, Array3<f64>), String> {
let n = coords.nrows();
let m = 2 * self.harmonics;
let mut phi = Array2::<f64>::zeros((n, m));
let mut jet = Array3::<f64>::zeros((n, m, 1));
let tau = std::f64::consts::TAU;
for i in 0..n {
let t = coords[[i, 0]];
for h in 1..=self.harmonics {
let w = tau * h as f64;
let c = 2 * (h - 1);
let s = c + 1;
phi[[i, c]] = (w * t).cos();
phi[[i, s]] = (w * t).sin();
jet[[i, c, 0]] = -w * (w * t).sin();
jet[[i, s, 0]] = w * (w * t).cos();
}
}
Ok((phi, jet))
}
fn second_jet_dyn(
&self,
coords: ArrayView2<'_, f64>,
) -> Option<Result<Array4<f64>, String>> {
if coords.ncols() != 1 {
return Some(Err(format!(
"CircleHarmonicEvaluator::second_jet_dyn: d = 1 evaluator got {} coords",
coords.ncols()
)));
}
None
}
fn third_jet_dyn(
&self,
coords: ArrayView2<'_, f64>,
) -> Option<Result<Array5<f64>, String>> {
if coords.ncols() != 1 {
return Some(Err(format!(
"CircleHarmonicEvaluator::third_jet_dyn: d = 1 evaluator got {} coords",
coords.ncols()
)));
}
None
}
}
#[derive(Debug)]
struct IntervalLinearEvaluator;
impl SaeBasisEvaluator for IntervalLinearEvaluator {
fn evaluate(
&self,
coords: ArrayView2<'_, f64>,
) -> Result<(Array2<f64>, Array3<f64>), String> {
let n = coords.nrows();
let mut phi = Array2::<f64>::zeros((n, 2));
let mut jet = Array3::<f64>::zeros((n, 2, 1));
for i in 0..n {
phi[[i, 0]] = 1.0;
phi[[i, 1]] = coords[[i, 0]];
jet[[i, 1, 0]] = 1.0;
}
Ok((phi, jet))
}
fn second_jet_dyn(
&self,
coords: ArrayView2<'_, f64>,
) -> Option<Result<Array4<f64>, String>> {
if coords.ncols() != 1 {
return Some(Err(format!(
"IntervalLinearEvaluator::second_jet_dyn: d = 1 evaluator got {} coords",
coords.ncols()
)));
}
None
}
fn third_jet_dyn(
&self,
coords: ArrayView2<'_, f64>,
) -> Option<Result<Array5<f64>, String>> {
if coords.ncols() != 1 {
return Some(Err(format!(
"IntervalLinearEvaluator::third_jet_dyn: d = 1 evaluator got {} coords",
coords.ncols()
)));
}
None
}
}
fn circle() -> CanonicalChartTopology {
CanonicalChartTopology::Circle { period: 1.0 }
}
fn interval() -> CanonicalChartTopology {
CanonicalChartTopology::Interval
}
#[test]
fn watson_pvalue_matches_tabulated_critical_values() {
let p05 = watson_u2_pvalue(0.187);
let p01 = watson_u2_pvalue(0.267);
assert!(
(p05 - 0.05).abs() < 5.0e-3,
"p(U²=0.187) must be ≈0.05, got {p05}"
);
assert!(
(p01 - 0.01).abs() < 5.0e-3,
"p(U²=0.267) must be ≈0.01, got {p01}"
);
assert!(watson_u2_pvalue(0.05) > watson_u2_pvalue(0.15));
assert!(watson_u2_pvalue(0.15) > watson_u2_pvalue(0.30));
}
#[test]
fn uniformity_statistic_is_calibrated() {
let n = 240;
let uniform: Vec<f64> = (0..n).map(|i| i as f64 / n as f64).collect();
let uu = coordinate_uniformity(Array1::from(uniform.clone()).view(), &circle()).unwrap();
assert!(
uu.statistic < 0.187,
"uniform angles must fall below the 5% critical value, got U²={}",
uu.statistic
);
assert!(
uu.p_value > 0.10,
"uniform angles must not be flagged, p={}",
uu.p_value
);
let bunched: Vec<f64> = (0..n).map(|i| 0.05 * (i as f64 / n as f64)).collect();
let bu = coordinate_uniformity(Array1::from(bunched).view(), &circle()).unwrap();
assert!(
bu.statistic > 0.267,
"bunched angles must exceed the 1% critical value, got U²={}",
bu.statistic
);
assert!(
bu.p_value < 0.01,
"bunched angles must be flagged, p={}",
bu.p_value
);
assert!(bu.statistic > uu.statistic);
}
#[test]
fn uniformity_is_rotation_and_reflection_invariant() {
let base: Vec<f64> = (0..97)
.map(|i| {
let x = (i as f64 * 0.61803398875).fract();
x * x
})
.collect();
let u0 = watson_u2_uniform(&base).statistic;
let rotated: Vec<f64> = base.iter().map(|&x| (x + 0.37).rem_euclid(1.0)).collect();
let reflected: Vec<f64> = base.iter().map(|&x| (1.0 - x).rem_euclid(1.0)).collect();
let ur = watson_u2_uniform(&rotated).statistic;
let uf = watson_u2_uniform(&reflected).statistic;
assert!(
(u0 - ur).abs() < 1e-9,
"rotation must not change U²: {u0} vs {ur}"
);
assert!(
(u0 - uf).abs() < 1e-9,
"reflection must not change U²: {u0} vs {uf}"
);
}
#[test]
fn arclength_defect_flags_non_unit_speed_chart() {
let ev = CircleHarmonicEvaluator { harmonics: 2 };
let mut unit = Array2::<f64>::zeros((4, 2));
unit[[0, 0]] = 1.3; unit[[1, 1]] = 1.3; let row_coords = Array1::linspace(0.0, 1.0, 32);
let d_unit = crate::chart_canonicalization::chart_unit_speed_defect(
&ev,
unit.view(),
row_coords.view(),
&circle(),
)
.unwrap()
.expect("unit-speed circle must produce a defect");
assert!(
d_unit < 1e-6,
"a constant-speed circle must have ~zero arc-length defect, got {d_unit}"
);
let mut wobbly = unit.clone();
wobbly[[2, 0]] = 0.6; wobbly[[3, 1]] = 0.6; let d_wobbly = crate::chart_canonicalization::chart_unit_speed_defect(
&ev,
wobbly.view(),
row_coords.view(),
&circle(),
)
.unwrap()
.expect("wobbly circle must produce a defect");
assert!(
d_wobbly > 1e-2,
"a non-unit-speed chart must have a positive arc-length defect, got {d_wobbly}"
);
}
#[test]
fn declining_higher_jets_enforce_d1_coords_contract() {
let ev = CircleHarmonicEvaluator { harmonics: 3 };
let good = Array2::<f64>::zeros((5, 1));
assert!(
ev.second_jet_dyn(good.view()).is_none(),
"d = 1 coords must decline the second jet with None"
);
assert!(
ev.third_jet_dyn(good.view()).is_none(),
"d = 1 coords must decline the third jet with None"
);
let bad = Array2::<f64>::zeros((5, 2));
let second = ev
.second_jet_dyn(bad.view())
.expect("wrong-dimension coords must not silently decline the second jet");
assert!(
second.is_err(),
"second_jet_dyn must reject d != 1 coords, got {second:?}"
);
let third = ev
.third_jet_dyn(bad.view())
.expect("wrong-dimension coords must not silently decline the third jet");
assert!(
third.is_err(),
"third_jet_dyn must reject d != 1 coords, got {third:?}"
);
}
#[test]
fn prefer_candidate_basin_prices_ev_then_uniformity() {
let tol = SAE_FINAL_EV_DEGRADATION_TOL;
assert!(prefer_candidate_basin(
0.90,
Some(0.5),
0.80,
Some(0.01),
tol
));
assert!(!prefer_candidate_basin(
0.80,
Some(0.01),
0.90,
Some(0.5),
tol
));
assert!(prefer_candidate_basin(
0.90,
Some(0.02),
0.9005,
Some(0.20),
tol
));
assert!(!prefer_candidate_basin(
0.90,
Some(0.20),
0.9005,
Some(0.02),
tol
));
assert!(!prefer_candidate_basin(
0.90,
Some(0.05),
0.90,
Some(0.05),
tol
));
assert!(!prefer_candidate_basin(0.90, None, 0.90, Some(0.05), tol));
assert!(!prefer_candidate_basin(
f64::NAN,
Some(0.0),
0.5,
Some(0.5),
tol
));
}
#[test]
fn planted_circle_tie_break_picks_the_more_uniform_seed() {
let n = 200;
let tol = SAE_FINAL_EV_DEGRADATION_TOL;
let honest: Vec<f64> = (0..n).map(|i| i as f64 / n as f64).collect();
let squished: Vec<f64> = honest.iter().map(|&u| 0.5 * u * u + 0.25 * u).collect();
let ua = coordinate_uniformity(Array1::from(honest).view(), &circle())
.unwrap()
.statistic;
let ub = coordinate_uniformity(Array1::from(squished).view(), &circle())
.unwrap()
.statistic;
assert!(
ua < ub,
"honest chart must read a more uniform angle: {ua} vs {ub}"
);
let ev = 0.926;
assert!(
prefer_candidate_basin(ev, Some(ua), ev, Some(ub), tol),
"the honest seed must be preferred at equal EV"
);
assert!(
!prefer_candidate_basin(ev, Some(ub), ev, Some(ua), tol),
"the squished seed must NOT displace the honest incumbent at equal EV"
);
}
#[test]
fn arclength_reading_is_identity_on_a_unit_speed_circle() {
use crate::chart_canonicalization::chart_arclength_coordinates;
let ev = CircleHarmonicEvaluator { harmonics: 2 };
let mut unit = Array2::<f64>::zeros((4, 2));
unit[[0, 0]] = 1.3; unit[[1, 1]] = 1.3; let rows = Array1::linspace(0.0, 0.97, 40);
let reading = chart_arclength_coordinates(&ev, unit.view(), rows.view(), &circle())
.unwrap()
.expect("unit-speed circle yields a reading");
for (i, &t) in rows.iter().enumerate() {
let d = (reading.coords_u_arc[i] - t).rem_euclid(1.0);
let circ = d.min(1.0 - d);
assert!(
circ < 1e-6,
"u_arc must equal raw t on a unit-speed circle: {circ}"
);
}
assert!(
reading.speed_cv < 1e-6,
"flat speed ⇒ ~zero CV, got {}",
reading.speed_cv
);
assert!((reading.min_speed_over_mean - 1.0).abs() < 1e-6);
assert!((reading.max_speed_over_mean - 1.0).abs() < 1e-6);
assert_eq!(
angle_fidelity_verdict(Some(&reading)),
AngleFidelityVerdict::ArcLengthHonest
);
let (rms, max) =
raw_vs_arclength_defect(rows.view(), reading.coords_u_arc.view(), &circle(), true);
assert!(
rms < 1e-6 && max < 1e-6,
"honest chart has ~zero raw defect: rms={rms} max={max}"
);
}
#[test]
fn arclength_reading_is_affine_on_a_linear_interval() {
use crate::chart_canonicalization::chart_arclength_coordinates;
let ev = IntervalLinearEvaluator;
let mut decoder = Array2::<f64>::zeros((2, 2));
decoder[[0, 0]] = 0.7;
decoder[[0, 1]] = -0.2;
decoder[[1, 0]] = 1.5;
decoder[[1, 1]] = -0.5;
let rows = Array1::linspace(-0.4, 1.3, 37);
let reading = chart_arclength_coordinates(&ev, decoder.view(), rows.view(), &interval())
.unwrap()
.expect("linear interval yields a reading");
let lo = rows[0];
let span = rows[rows.len() - 1] - lo;
for (i, &t) in rows.iter().enumerate() {
let expected = (t - lo) / span;
assert!(
(reading.coords_u_arc[i] - expected).abs() < 1e-9,
"linear interval u_arc must be affine: got {}, expected {}",
reading.coords_u_arc[i],
expected
);
}
assert!(reading.speed_cv < 1e-9, "linear segment has constant speed");
assert_eq!(
angle_fidelity_verdict(Some(&reading)),
AngleFidelityVerdict::ArcLengthHonest
);
}
#[test]
fn arclength_reading_recovers_and_certifies_a_wobbly_circle() {
use crate::chart_canonicalization::chart_arclength_coordinates;
let ev = CircleHarmonicEvaluator { harmonics: 2 };
let mut wobbly = Array2::<f64>::zeros((4, 2));
wobbly[[0, 0]] = 1.3;
wobbly[[1, 1]] = 1.3;
wobbly[[2, 0]] = 0.2; wobbly[[3, 1]] = 0.2; let rows = Array1::linspace(0.0, 0.98, 64);
let reading = chart_arclength_coordinates(&ev, wobbly.view(), rows.view(), &circle())
.unwrap()
.expect("wobbly circle yields a reading");
assert!(
reading.speed_cv > 1e-2,
"wobbly chart must have a positive speed CV, got {}",
reading.speed_cv
);
assert!(reading.min_speed_over_mean < 1.0 && reading.max_speed_over_mean > 1.0);
assert!(reading.min_speed_over_mean > SAE_FLOW_DIFFEO_MIN_DET);
assert_eq!(
angle_fidelity_verdict(Some(&reading)),
AngleFidelityVerdict::RecoverableViaArcLength
);
let (rms, _max) =
raw_vs_arclength_defect(rows.view(), reading.coords_u_arc.view(), &circle(), true);
assert!(
rms > 1e-2,
"u_arc must materially differ from raw t on a squished chart, got rms={rms}"
);
}
#[test]
fn arclength_reading_flags_a_cusped_chart_degenerate() {
use crate::chart_canonicalization::chart_arclength_coordinates;
let ev = CircleHarmonicEvaluator { harmonics: 2 };
let mut cusped = Array2::<f64>::zeros((4, 2));
cusped[[0, 0]] = 1.0; cusped[[1, 1]] = 1.0;
cusped[[2, 0]] = 0.5; cusped[[3, 1]] = 0.5;
let rows = Array1::linspace(0.0, 0.98, 64);
let reading = chart_arclength_coordinates(&ev, cusped.view(), rows.view(), &circle())
.unwrap()
.expect("a cusped-but-finite chart still yields a reading");
assert!(
reading.min_speed_over_mean < SAE_FLOW_DIFFEO_MIN_DET,
"a cusped chart must have a collapsing min speed, got {}",
reading.min_speed_over_mean
);
assert_eq!(
angle_fidelity_verdict(Some(&reading)),
AngleFidelityVerdict::Degenerate
);
}
#[test]
fn angle_fidelity_verdict_uses_derived_thresholds() {
use crate::chart_canonicalization::{ChartArcLengthReading, UNIT_SPEED_INLOOP_DEFECT_TOL};
let mk = |speed_cv: f64, min_over: f64, max_over: f64| ChartArcLengthReading {
coords_u_arc: Array1::zeros(1),
speed_cv,
log_speed_rms: 0.0,
min_speed_over_mean: min_over,
max_speed_over_mean: max_over,
total_arc_length: 1.0,
};
assert_eq!(
angle_fidelity_verdict(Some(&mk(0.1 * UNIT_SPEED_INLOOP_DEFECT_TOL, 1.0, 1.0))),
AngleFidelityVerdict::ArcLengthHonest
);
assert_eq!(
angle_fidelity_verdict(Some(&mk(0.3, 2.0 * SAE_FLOW_DIFFEO_MIN_DET, 1.8))),
AngleFidelityVerdict::RecoverableViaArcLength
);
assert_eq!(
angle_fidelity_verdict(Some(&mk(0.3, 0.5 * SAE_FLOW_DIFFEO_MIN_DET, 3.0))),
AngleFidelityVerdict::Degenerate
);
assert_eq!(
angle_fidelity_verdict(None),
AngleFidelityVerdict::Degenerate
);
assert!(AngleFidelityVerdict::ArcLengthHonest.certified());
assert!(AngleFidelityVerdict::RecoverableViaArcLength.certified());
assert!(!AngleFidelityVerdict::Degenerate.certified());
}
#[test]
fn raw_vs_arclength_defect_is_gauge_invariant() {
let n = 80;
let raw = Array1::linspace(0.0, 1.0 - 1.0 / n as f64, n);
let u_arc = Array1::from_iter(raw.iter().map(|&t| (0.5 * t * t + 0.5 * t).rem_euclid(1.0)));
let (rms0, _) = raw_vs_arclength_defect(raw.view(), u_arc.view(), &circle(), true);
let rotated = Array1::from_iter(raw.iter().map(|&t| (t + 0.31).rem_euclid(1.0)));
let reflected = Array1::from_iter(raw.iter().map(|&t| (1.0 - t).rem_euclid(1.0)));
let (rms_rot, _) = raw_vs_arclength_defect(rotated.view(), u_arc.view(), &circle(), true);
let (rms_ref, _) = raw_vs_arclength_defect(reflected.view(), u_arc.view(), &circle(), true);
assert!(
(rms0 - rms_rot).abs() < 1e-9,
"rotation must not change the defect: {rms0} vs {rms_rot}"
);
assert!(
(rms0 - rms_ref).abs() < 1e-9,
"reflection must not change the defect: {rms0} vs {rms_ref}"
);
}
}