use super::radial_jets_nd::spherical_harmonic_jet;
use super::sphere_basis::{fill_real_spherical_harmonics_row, precompute_harmonic_norms};
use ndarray::{Array1, Array2};
fn plm_table_from_radicand(x: f64, max_degree: usize) -> Vec<f64> {
let l_cap = max_degree + 1;
let idx = |l: usize, m: usize| l * l_cap + m;
let mut p = vec![0.0_f64; l_cap * l_cap];
let somx2 = (1.0 - x * x).max(0.0).sqrt();
p[idx(0, 0)] = 1.0;
for m in 1..=max_degree {
p[idx(m, m)] = -((2 * m - 1) as f64) * somx2 * p[idx(m - 1, m - 1)];
}
for m in 0..max_degree {
p[idx(m + 1, m)] = ((2 * m + 1) as f64) * x * p[idx(m, m)];
}
for m in 0..=max_degree {
for l in (m + 2)..=max_degree {
p[idx(l, m)] = (((2 * l - 1) as f64) * x * p[idx(l - 1, m)]
- ((l + m - 1) as f64) * p[idx(l - 2, m)])
/ ((l - m) as f64);
}
}
p
}
fn forward_row_relative_error(max_degree: usize) -> f64 {
let k = 3.0 * (max_degree as f64) + 2.0;
k * f64::EPSILON / (1.0 - k * f64::EPSILON)
}
fn forward_row(lat_deg: f64, lon_deg: f64, max_degree: usize) -> Array1<f64> {
let l_cap = max_degree + 1;
let norms = precompute_harmonic_norms(max_degree);
let mut p_buf = vec![0.0_f64; l_cap * l_cap];
let mut row = Array1::<f64>::zeros(max_degree * (max_degree + 2));
let deg = std::f64::consts::PI / 180.0;
fill_real_spherical_harmonics_row(
lat_deg * deg,
lon_deg * deg,
max_degree,
&mut p_buf,
&norms,
row.view_mut(),
);
row
}
fn cos_column(l: usize, m: usize) -> usize {
let before: usize = (1..l).map(|d| 2 * d + 1).sum();
before + l + m
}
#[test]
fn zz_measure_polar_harmonic_jet_matches_forward_finite_differences() {
let max_degree = 6usize;
let lon_deg = 37.5_f64;
let deg = std::f64::consts::PI / 180.0;
let h = 1.0e-4_f64;
let ncols = max_degree * (max_degree + 2);
let mut col_scale = vec![0.0_f64; ncols];
for step in 0..=720usize {
let lat_deg = -90.0 + 180.0 * (step as f64) / 720.0;
let row = forward_row(lat_deg, lon_deg, max_degree);
for col in 0..ncols {
col_scale[col] = col_scale[col].max(row[col].abs());
}
}
println!("central-difference gate, h = {h:.1e} deg, meridian {lon_deg} deg");
println!(
"{:>14} {:>10} {:>13} {:>13} {:>11}",
"90 - lat (deg)", "(l,m)", "jet d/dlat", "central diff", "fd bound"
);
let mut worst_ratio = 0.0_f64;
for &gap in &[10.0_f64, 1.0, 1.0e-1, 1.0e-2, 1.0e-3] {
let lat_deg = 90.0 - gap;
let data = Array2::from_shape_vec((1, 2), vec![lat_deg, lon_deg])
.expect("1x2 lat/lon fixture is well formed");
let jet = spherical_harmonic_jet(data.view(), max_degree, false)
.expect("the harmonic jet must build on an interior latitude");
let up = forward_row(lat_deg + h, lon_deg, max_degree);
let down = forward_row(lat_deg - h, lon_deg, max_degree);
for col in 0..ncols {
let fd = (up[col] - down[col]) / (2.0 * h);
let shipped = jet[[0, col, 0]];
let scale = col_scale[col].max(f64::MIN_POSITIVE);
let h_rad = h * deg;
let lf = max_degree as f64;
let bound_rad = lf * lf * lf * scale * h_rad * h_rad / 6.0
+ 2.0 * forward_row_relative_error(max_degree) * scale / h_rad;
let bound = bound_rad * deg;
let miss = (shipped - fd).abs();
worst_ratio = worst_ratio.max(miss / bound);
assert!(
miss <= bound,
"column {col} at lat {lat_deg}: jet {shipped:.12e} vs central difference \
{fd:.12e}, miss {miss:.6e} exceeds the difference's own uncertainty {bound:.6e}"
);
}
let col = cos_column(3, 1);
let fd = (up[col] - down[col]) / (2.0 * h);
let scale = col_scale[col].max(f64::MIN_POSITIVE);
let h_rad = h * deg;
let lf = max_degree as f64;
let bound = (lf * lf * lf * scale * h_rad * h_rad / 6.0
+ 2.0 * forward_row_relative_error(max_degree) * scale / h_rad)
* deg;
println!(
"{gap:>14.0e} {:>10} {:>13.6e} {:>13.6e} {:>11.2e}",
"(3,1)cos",
jet[[0, col, 0]],
fd,
bound
);
}
println!("worst miss / bound over all columns and latitudes: {worst_ratio:.3e}");
}
#[test]
fn zz_measure_polar_harmonic_jet_is_exact_at_the_pole() {
let max_degree = 6usize;
let lon_deg = 37.5_f64;
let deg = std::f64::consts::PI / 180.0;
let norms = precompute_harmonic_norms(max_degree);
let l_cap = max_degree + 1;
let data = Array2::from_shape_vec((1, 2), vec![90.0, lon_deg])
.expect("1x2 lat/lon fixture is well formed");
let jet = spherical_harmonic_jet(data.view(), max_degree, false)
.expect("the harmonic jet must build at the pole");
println!("north pole, meridian {lon_deg} deg");
println!(
"{:>10} {:>16} {:>16}",
"(l,m)cos", "shipped d/dlat", "N·l(l+1)/2·cos(mψ)·deg"
);
for l in 1..=max_degree {
for m in 0..=l {
let col = cos_column(l, m);
let shipped = jet[[0, col, 0]];
let want = if m == 1 {
let nlm = norms[l * l_cap + 1];
let lf = l as f64;
nlm * (lf * (lf + 1.0) / 2.0) * (lon_deg * deg).cos() * deg
} else {
0.0
};
if m == 1 {
println!(
"{:>10} {shipped:>16.9e} {want:>16.9e}",
format!("({l},1)")
);
let tol = 4.0 * f64::EPSILON * want.abs();
assert!(
(shipped - want).abs() <= tol,
"pole (l={l}, m=1) cosine column: shipped {shipped:.17e} vs exact \
{want:.17e}"
);
assert!(
shipped.abs() > 0.0,
"pole (l={l}, m=1) must not be reported as zero"
);
} else {
let cos_pole = (90.0_f64 * deg).cos().abs();
let lf = l as f64;
let tol = 8.0 * norms[l * l_cap + m] * lf * (lf + 1.0) * cos_pole * deg;
assert!(
shipped.abs() <= tol,
"pole (l={l}, m={m}) cosine column is {shipped:.6e}, beyond the \
{tol:.6e} the representable pole's own cos(lat) admits"
);
}
}
}
}
#[test]
fn zz_measure_polar_harmonic_jet_numerator_cancels_like_two_over_cos_squared() {
let max_degree = 4usize;
let l_cap = max_degree + 1;
let idx = |l: usize, m: usize| l * l_cap + m;
println!(
"{:>14} {:>13} {:>13} {:>13}",
"90 - lat (deg)", "cos^2(lat)", "rel err", "rel/(eps/cos^2)"
);
for &gap in &[1.0e-1_f64, 1.0e-2, 1.0e-3, 1.0e-4, 1.0e-5] {
let lat = (90.0 - gap) * std::f64::consts::PI / 180.0;
let x = lat.sin();
let cos2 = lat.cos() * lat.cos();
let p = plm_table_from_radicand(x, max_degree);
let assembled = -x * p[idx(1, 0)] + p[idx(0, 0)];
let exact = cos2;
let rel = (assembled - exact).abs() / exact;
let predicted = f64::EPSILON / cos2;
println!(
"{gap:>14.0e} {cos2:>13.6e} {rel:>13.6e} {:>13.6e}",
rel / predicted
);
assert!(
rel <= 4.0 * predicted,
"cancellation at 90 - {gap} deg is {rel:.6e}, beyond 4·eps/cos²(lat) = \
{:.6e}",
4.0 * predicted
);
}
}