use crate::Result;
use netcdf3::FileReader;
use std::f64::consts::TAU;
use std::path::Path;
use std::sync::OnceLock;
pub struct VmecData {
pub s_grid: Vec<f64>,
pub rmnc: Vec<Vec<f64>>,
pub zmns: Vec<Vec<f64>>,
pub mode_poloidal: Vec<f64>,
pub mode_toroidal: Vec<f64>,
splines: OnceLock<(Vec<CubicSpline>, Vec<CubicSpline>)>,
}
pub struct RZ {
pub r: f64,
pub z: f64,
pub dr_dtheta: f64,
pub dr_dphi: f64,
pub dz_dtheta: f64,
pub dz_dphi: f64,
}
#[derive(Clone, Copy, Debug)]
#[allow(dead_code)] pub enum NormalKind {
Planar,
Surface,
}
impl VmecData {
pub fn load(path: &Path) -> Result<Self> {
let mut file = FileReader::open(path)
.map_err(|e| format!("open {}: {:?}", path.display(), e))?;
let shape: Vec<usize> = file
.data_set()
.get_var("rmnc")
.ok_or("missing rmnc")?
.get_dims()
.iter()
.map(|d| d.size())
.collect();
let ns = shape[0]; let mnmax = shape[1];
let read_f64 = |f: &mut FileReader, name: &str| -> Result<Vec<f64>> {
f.read_var(name)
.map_err(|e| format!("read {}: {:?}", name, e))?
.get_f64_into()
.map_err(|_| format!("{} is not f64", name).into())
};
let rmnc_flat = read_f64(&mut file, "rmnc")?;
let zmns_flat = read_f64(&mut file, "zmns")?;
let mode_poloidal = read_f64(&mut file, "xm")?;
let mode_toroidal = read_f64(&mut file, "xn")?;
let rmnc: Vec<Vec<f64>> = (0..ns)
.map(|i| rmnc_flat[i * mnmax..(i + 1) * mnmax].to_vec())
.collect();
let zmns: Vec<Vec<f64>> = (0..ns)
.map(|i| zmns_flat[i * mnmax..(i + 1) * mnmax].to_vec())
.collect();
let s_grid: Vec<f64> = (0..ns).map(|i| i as f64 / (ns - 1) as f64).collect();
Ok(Self {
s_grid,
rmnc,
zmns,
mode_poloidal,
mode_toroidal,
splines: OnceLock::new(),
})
}
fn eval_rz(&self, r_coeff: &[f64], z_coeff: &[f64], theta: f64, phi: f64) -> RZ {
let mnmax = self.mode_poloidal.len();
let mut res = RZ {
r: 0.0,
z: 0.0,
dr_dtheta: 0.0,
dr_dphi: 0.0,
dz_dtheta: 0.0,
dz_dphi: 0.0,
};
for k in 0..mnmax {
let angle = self.mode_poloidal[k] * theta - self.mode_toroidal[k] * phi;
res.r += r_coeff[k] * angle.cos();
res.z += z_coeff[k] * angle.sin();
let dangle_dtheta = self.mode_poloidal[k];
let dangle_dphi = -self.mode_toroidal[k];
res.dr_dtheta += r_coeff[k] * (-angle.sin()) * dangle_dtheta;
res.dr_dphi += r_coeff[k] * (-angle.sin()) * dangle_dphi;
res.dz_dtheta += z_coeff[k] * angle.cos() * dangle_dtheta;
res.dz_dphi += z_coeff[k] * angle.cos() * dangle_dphi;
}
res
}
#[allow(dead_code)] pub fn index_rz(&self, index_s: usize, theta: f64, phi: f64) -> RZ {
self.eval_rz(&self.rmnc[index_s], &self.zmns[index_s], theta, phi)
}
#[allow(dead_code)] pub fn mesh(
&self,
div_theta: usize,
div_phi: usize,
s: f64,
offset: f64,
normal: NormalKind,
) -> Vec<Vec<[f64; 3]>> {
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
}
let mut grid: Vec<Vec<[f64; 3]>> = Vec::with_capacity(div_phi);
for i in 0..div_phi {
let phi = TAU * (i as f64) / (div_phi as f64);
let (sp, cp) = phi.sin_cos();
let mut row: Vec<[f64; 3]> = Vec::with_capacity(div_theta);
for j in 0..div_theta {
let theta = TAU * (j as f64) / (div_theta as f64);
let rz = self.interpolate_rz(s, theta, phi);
let mut p = [rz.r, 0.0, rz.z];
if offset != 0.0 {
let n = {
let [t_theta, t_phi] = match normal {
NormalKind::Planar => {
[
[rz.dr_dtheta, 0.0, rz.dz_dtheta],
[0.0, 1.0, 0.0],
]
}
NormalKind::Surface => {
[
[rz.dr_dtheta, 0.0, rz.dz_dtheta],
[rz.dr_dphi, rz.r, rz.dz_dphi],
]
}
};
cross(t_phi, t_theta)
};
let inv_len = 1.0 / (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
p[0] += offset * n[0] * inv_len;
p[1] += offset * n[1] * inv_len;
p[2] += offset * n[2] * inv_len;
}
row.push([p[0] * cp - p[1] * sp, p[0] * sp + p[1] * cp, p[2]]);
}
grid.push(row);
}
grid
}
pub fn interpolate_rz(&self, s: f64, theta: f64, phi: f64) -> RZ {
let (r_splines, z_splines) = self.splines.get_or_init(|| {
let mnmax = self.mode_poloidal.len();
let mut r_splines = Vec::with_capacity(mnmax);
let mut z_splines = Vec::with_capacity(mnmax);
for k in 0..mnmax {
let r_col: Vec<f64> = self.rmnc.iter().map(|row| row[k]).collect();
let z_col: Vec<f64> = self.zmns.iter().map(|row| row[k]).collect();
r_splines.push(CubicSpline::new(&self.s_grid, &r_col, BoundaryCondition::Natural));
z_splines.push(CubicSpline::new(&self.s_grid, &z_col, BoundaryCondition::Natural));
}
(r_splines, z_splines)
});
let mnmax = self.mode_poloidal.len();
let r_at_s: Vec<f64> = (0..mnmax).map(|k| r_splines[k].eval(s)).collect();
let z_at_s: Vec<f64> = (0..mnmax).map(|k| z_splines[k].eval(s)).collect();
self.eval_rz(&r_at_s, &z_at_s, theta, phi)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BoundaryCondition {
Natural,
NotAKnot,
}
struct CubicSpline {
xs: Vec<f64>,
a: Vec<f64>,
b: Vec<f64>,
c: Vec<f64>,
d: Vec<f64>,
}
impl CubicSpline {
fn new(xs: &[f64], ys: &[f64], bc: BoundaryCondition) -> Self {
let n = xs.len();
assert_eq!(ys.len(), n);
assert!(n >= 2, "スプライン構築には最低 2 点必要");
let h: Vec<f64> = (0..n - 1).map(|i| xs[i + 1] - xs[i]).collect();
let mut m = vec![0.0; n];
let effective_bc = if n < 4 {
BoundaryCondition::Natural
} else {
bc
};
if n >= 3 {
let k = n - 2; let mut lower = vec![0.0; k]; let mut diag = vec![0.0; k]; let mut upper = vec![0.0; k]; let mut rhs = vec![0.0; k];
for r in 0..k {
let i = r + 1;
lower[r] = h[i - 1];
diag[r] = 2.0 * (h[i - 1] + h[i]);
upper[r] = h[i];
rhs[r] = 6.0 * ((ys[i + 1] - ys[i]) / h[i] - (ys[i] - ys[i - 1]) / h[i - 1]);
}
match effective_bc {
BoundaryCondition::Natural => {
lower[0] = 0.0;
upper[k - 1] = 0.0;
}
BoundaryCondition::NotAKnot => {
let h0 = h[0];
let h1 = h[1];
diag[0] = (h0 + h1) * (h0 + 2.0 * h1) / h1;
upper[0] = (h1 * h1 - h0 * h0) / h1;
lower[0] = 0.0;
let ha = h[n - 3];
let hb = h[n - 2];
lower[k - 1] = (ha * ha - hb * hb) / ha;
diag[k - 1] = (ha + hb) * (hb + 2.0 * ha) / ha;
upper[k - 1] = 0.0;
}
}
for r in 1..k {
let w = lower[r] / diag[r - 1];
diag[r] -= w * upper[r - 1];
rhs[r] -= w * rhs[r - 1];
}
let mut m_inner = vec![0.0; k];
m_inner[k - 1] = rhs[k - 1] / diag[k - 1];
for r in (0..k - 1).rev() {
m_inner[r] = (rhs[r] - upper[r] * m_inner[r + 1]) / diag[r];
}
for r in 0..k {
m[r + 1] = m_inner[r];
}
if effective_bc == BoundaryCondition::NotAKnot {
m[0] = ((h[0] + h[1]) * m[1] - h[0] * m[2]) / h[1];
m[n - 1] =
((h[n - 3] + h[n - 2]) * m[n - 2] - h[n - 2] * m[n - 3]) / h[n - 3];
}
}
let mut a = Vec::with_capacity(n - 1);
let mut b = Vec::with_capacity(n - 1);
let mut c = Vec::with_capacity(n - 1);
let mut d = Vec::with_capacity(n - 1);
for i in 0..n - 1 {
let hi = h[i];
a.push(ys[i]);
b.push((ys[i + 1] - ys[i]) / hi - hi * (2.0 * m[i] + m[i + 1]) / 6.0);
c.push(m[i] / 2.0);
d.push((m[i + 1] - m[i]) / (6.0 * hi));
}
CubicSpline {
xs: xs.to_vec(),
a,
b,
c,
d,
}
}
fn eval(&self, x: f64) -> f64 {
let n = self.xs.len();
let idx = if x <= self.xs[0] {
0
} else if x >= self.xs[n - 1] {
n - 2
} else {
match self.xs.binary_search_by(|v| v.partial_cmp(&x).unwrap()) {
Ok(i) => i.min(n - 2), Err(i) => i - 1, }
};
let dx = x - self.xs[idx];
self.a[idx] + self.b[idx] * dx + self.c[idx] * dx.powi(2) + self.d[idx] * dx.powi(3)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Instant;
#[test]
fn interpolate_rz_periodic_and_differentiable_at_phi_seam() {
let path = Path::new("parastell/examples/wout_vmec.nc");
if !path.exists() {
eprintln!("skip: {} not found", path.display());
return;
}
let vmec = VmecData::load(path).expect("load vmec");
let xn_nonint = vmec
.mode_toroidal
.iter()
.enumerate()
.find(|&(_, &n)| (n - n.round()).abs() > 1e-10);
let xm_nonint = vmec
.mode_poloidal
.iter()
.enumerate()
.find(|&(_, &m)| (m - m.round()).abs() > 1e-10);
assert!(xn_nonint.is_none(), "xn non-integer at {:?}", xn_nonint);
assert!(xm_nonint.is_none(), "xm non-integer at {:?}", xm_nonint);
let tol = 1e-9;
let mut max_diff_r = 0.0f64;
let mut max_diff_z = 0.0f64;
let mut max_diff_dr_dtheta = 0.0f64;
let mut max_diff_dz_dtheta = 0.0f64;
let mut max_diff_dr_dphi = 0.0f64;
let mut max_diff_dz_dphi = 0.0f64;
for &s in &[0.25, 0.5, 1.0, 1.08] {
for j in 0..64 {
let theta = std::f64::consts::TAU * (j as f64) / 64.0;
let a = vmec.interpolate_rz(s, theta, 0.0);
let b = vmec.interpolate_rz(s, theta, std::f64::consts::TAU);
let d_r = (a.r - b.r).abs();
let d_z = (a.z - b.z).abs();
let d_dr_dt = (a.dr_dtheta - b.dr_dtheta).abs();
let d_dz_dt = (a.dz_dtheta - b.dz_dtheta).abs();
let d_dr_dp = (a.dr_dphi - b.dr_dphi).abs();
let d_dz_dp = (a.dz_dphi - b.dz_dphi).abs();
assert!(d_r < tol, "s={s} θ={theta}: R mismatch {} vs {}", a.r, b.r);
assert!(d_z < tol, "s={s} θ={theta}: Z mismatch {} vs {}", a.z, b.z);
assert!(d_dr_dt < tol, "s={s} θ={theta}: ∂R/∂θ mismatch");
assert!(d_dz_dt < tol, "s={s} θ={theta}: ∂Z/∂θ mismatch");
assert!(d_dr_dp < tol, "s={s} θ={theta}: ∂R/∂φ mismatch");
assert!(d_dz_dp < tol, "s={s} θ={theta}: ∂Z/∂φ mismatch");
max_diff_r = max_diff_r.max(d_r);
max_diff_z = max_diff_z.max(d_z);
max_diff_dr_dtheta = max_diff_dr_dtheta.max(d_dr_dt);
max_diff_dz_dtheta = max_diff_dz_dtheta.max(d_dz_dt);
max_diff_dr_dphi = max_diff_dr_dphi.max(d_dr_dp);
max_diff_dz_dphi = max_diff_dz_dphi.max(d_dz_dp);
}
}
eprintln!(
"seam @ phi=0 vs phi=TAU (across 4×64 = 256 sample points):\n \
max|ΔR| = {max_diff_r:.3e}\n \
max|ΔZ| = {max_diff_z:.3e}\n \
max|Δ∂R/∂θ| = {max_diff_dr_dtheta:.3e}\n \
max|Δ∂Z/∂θ| = {max_diff_dz_dtheta:.3e}\n \
max|Δ∂R/∂φ| = {max_diff_dr_dphi:.3e}\n \
max|Δ∂Z/∂φ| = {max_diff_dz_dphi:.3e}"
);
}
#[test]
fn mesh_phi_seam_matches_row0() {
let path = Path::new("parastell/examples/wout_vmec.nc");
if !path.exists() {
eprintln!("skip: {} not found", path.display());
return;
}
let vmec = VmecData::load(path).expect("load vmec");
let div_theta = 64;
let div_phi = 240;
let s = 1.08;
for (offset, kind) in [(0.0, NormalKind::Planar), (0.05, NormalKind::Planar), (0.05, NormalKind::Surface)] {
let grid = vmec.mesh(div_theta, div_phi, s, offset, kind);
let row0 = &grid[0];
let phi = std::f64::consts::TAU;
let (sp, cp) = phi.sin_cos();
let mut virt: Vec<[f64; 3]> = Vec::with_capacity(div_theta);
for j in 0..div_theta {
let theta = std::f64::consts::TAU * (j as f64) / (div_theta as f64);
let rz = vmec.interpolate_rz(s, theta, phi);
let mut p = [rz.r, 0.0, rz.z];
if offset != 0.0 {
let (a, b) = match kind {
NormalKind::Planar => (
[rz.dr_dtheta, 0.0, rz.dz_dtheta],
[0.0_f64, 1.0, 0.0],
),
NormalKind::Surface => (
[rz.dr_dtheta, 0.0, rz.dz_dtheta],
[rz.dr_dphi, rz.r, rz.dz_dphi],
),
};
let n = [
b[1] * a[2] - b[2] * a[1],
b[2] * a[0] - b[0] * a[2],
b[0] * a[1] - b[1] * a[0],
];
let inv_len = 1.0 / (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
p[0] += offset * n[0] * inv_len;
p[1] += offset * n[1] * inv_len;
p[2] += offset * n[2] * inv_len;
}
virt.push([p[0] * cp - p[1] * sp, p[0] * sp + p[1] * cp, p[2]]);
}
let mut max_diff = 0.0f64;
for j in 0..div_theta {
let dx = row0[j][0] - virt[j][0];
let dy = row0[j][1] - virt[j][1];
let dz = row0[j][2] - virt[j][2];
max_diff = max_diff.max((dx * dx + dy * dy + dz * dz).sqrt());
}
eprintln!(
"offset={offset:.3} kind={:?}: max |row0 - virt(phi=TAU)| = {max_diff:.3e}",
kind
);
assert!(max_diff < 1e-6, "seam too large");
}
}
#[test]
fn bench_interpolate_rz_1000pts() {
let path = Path::new("parastell/examples/wout_vmec.nc");
if !path.exists() {
eprintln!("skip: {} not found", path.display());
return;
}
let vmec = VmecData::load(path).expect("load vmec");
const N: usize = 1000;
let mut checksum = 0.0f64;
let start = Instant::now();
for i in 0..N {
let t = i as f64 / N as f64;
let s = 0.2 + 0.88 * t;
let theta = 0.037 * i as f64;
let phi = 0.041 * i as f64;
let rz = vmec.interpolate_rz(s, theta, phi);
checksum += rz.r + rz.z;
}
let elapsed = start.elapsed();
eprintln!(
"interpolate_rz × {}pts: {:?} ({:.2} us/pt), checksum={:.6}",
N,
elapsed,
elapsed.as_secs_f64() * 1e6 / N as f64,
checksum
);
}
#[test]
fn index_rz_matches_interpolate_rz_on_grid() {
let path = Path::new("parastell/examples/wout_vmec.nc");
if !path.exists() {
eprintln!("skip: {} not found", path.display());
return;
}
let vmec = VmecData::load(path).expect("load vmec");
for &i in &[0, vmec.s_grid.len() / 2, vmec.s_grid.len() - 1] {
let s = vmec.s_grid[i];
for (theta, phi) in [(0.0, 0.0), (0.37, 1.29), (1.0, 0.5)] {
let idx = vmec.index_rz(i, theta, phi);
let int = vmec.interpolate_rz(s, theta, phi);
let tol = 1e-9;
assert!(
(idx.r - int.r).abs() < tol,
"R mismatch at i={i}, θ={theta}, φ={phi}: idx={}, int={}",
idx.r, int.r
);
assert!(
(idx.z - int.z).abs() < tol,
"Z mismatch at i={i}, θ={theta}, φ={phi}: idx={}, int={}",
idx.z, int.z
);
}
}
}
#[test]
fn cubic_spline_passes_through_data_points() {
let xs = [0.0, 0.1, 0.3, 0.6, 1.0, 1.5, 2.1];
let ys = [0.0, 0.5, -0.2, 0.8, 0.3, -0.1, 1.2];
for bc in [BoundaryCondition::Natural, BoundaryCondition::NotAKnot] {
let sp = CubicSpline::new(&xs, &ys, bc);
for (i, &x) in xs.iter().enumerate() {
let y = sp.eval(x);
assert!(
(y - ys[i]).abs() < 1e-10,
"bc={bc:?} i={i}: eval({x}) = {y}, expected {}",
ys[i]
);
}
}
}
#[test]
fn not_a_knot_reproduces_quadratic_exactly() {
let xs: Vec<f64> = (0..5).map(|i| i as f64).collect();
let ys: Vec<f64> = xs.iter().map(|&x| x * x).collect();
let sp = CubicSpline::new(&xs, &ys, BoundaryCondition::NotAKnot);
for &x in &[0.5, 1.5, 2.5, 3.5, 4.5, 6.0, -1.0] {
let y = sp.eval(x);
let expected = x * x;
assert!(
(y - expected).abs() < 1e-9,
"not-a-knot should reproduce x²: eval({x}) = {y}, expected {expected}"
);
}
}
#[test]
fn vmec_s108_natural_vs_not_a_knot() {
let path = Path::new("parastell/examples/wout_vmec.nc");
if !path.exists() {
eprintln!("skip: {} not found", path.display());
return;
}
let vmec = VmecData::load(path).expect("load vmec");
let mnmax = vmec.mode_poloidal.len();
let s = 1.08;
let r_col: Vec<f64> = vmec.rmnc.iter().map(|row| row[0]).collect();
let sp_nat = CubicSpline::new(&vmec.s_grid, &r_col, BoundaryCondition::Natural);
let sp_nak = CubicSpline::new(&vmec.s_grid, &r_col, BoundaryCondition::NotAKnot);
let v_nat = sp_nat.eval(s);
let v_nak = sp_nak.eval(s);
let v_grid_last = r_col[r_col.len() - 1];
eprintln!(
"mode 0 (rmnc) at s=1.08: natural={v_nat:.6}, not-a-knot={v_nak:.6}, at s=1.0={v_grid_last:.6}"
);
let int = vmec.interpolate_rz(s, 0.0, 0.0);
let grid = vmec.index_rz(vmec.s_grid.len() - 1, 0.0, 0.0);
eprintln!(
"(θ=0, φ=0): at s=1.0 (R={:.4}, Z={:.4}), at s=1.08 not-a-knot (R={:.4}, Z={:.4})",
grid.r, grid.z, int.r, int.z
);
for k in 0..5.min(mnmax) {
let col: Vec<f64> = vmec.rmnc.iter().map(|row| row[k]).collect();
let a = CubicSpline::new(&vmec.s_grid, &col, BoundaryCondition::Natural);
let b = CubicSpline::new(&vmec.s_grid, &col, BoundaryCondition::NotAKnot);
eprintln!(
"k={k} (m={}, n={}): rmnc at s=1.0={:.4}, s=1.08 natural={:.4}, not-a-knot={:.4}",
vmec.mode_poloidal[k],
vmec.mode_toroidal[k],
col[col.len() - 1],
a.eval(s),
b.eval(s),
);
}
}
#[test]
fn vmec_s108_surface_drift() {
let path = Path::new("parastell/examples/wout_vmec.nc");
if !path.exists() {
eprintln!("skip: {} not found", path.display());
return;
}
let vmec = VmecData::load(path).expect("load vmec");
let mnmax = vmec.mode_poloidal.len();
let mut r_nat = Vec::with_capacity(mnmax);
let mut r_nak = Vec::with_capacity(mnmax);
let mut z_nat = Vec::with_capacity(mnmax);
let mut z_nak = Vec::with_capacity(mnmax);
for k in 0..mnmax {
let rc: Vec<f64> = vmec.rmnc.iter().map(|row| row[k]).collect();
let zc: Vec<f64> = vmec.zmns.iter().map(|row| row[k]).collect();
r_nat.push(CubicSpline::new(&vmec.s_grid, &rc, BoundaryCondition::Natural));
r_nak.push(CubicSpline::new(&vmec.s_grid, &rc, BoundaryCondition::NotAKnot));
z_nat.push(CubicSpline::new(&vmec.s_grid, &zc, BoundaryCondition::Natural));
z_nak.push(CubicSpline::new(&vmec.s_grid, &zc, BoundaryCondition::NotAKnot));
}
let s = 1.08;
let r_coef_nat: Vec<f64> = r_nat.iter().map(|sp| sp.eval(s)).collect();
let r_coef_nak: Vec<f64> = r_nak.iter().map(|sp| sp.eval(s)).collect();
let z_coef_nat: Vec<f64> = z_nat.iter().map(|sp| sp.eval(s)).collect();
let z_coef_nak: Vec<f64> = z_nak.iter().map(|sp| sp.eval(s)).collect();
let mut max_dr = 0.0f64;
let mut max_dz = 0.0f64;
let mut sum_sq_dr = 0.0f64;
let mut sum_sq_dz = 0.0f64;
let n_theta = 64;
let n_phi = 60;
for i in 0..n_phi {
let phi = std::f64::consts::TAU * (i as f64) / (n_phi as f64);
for j in 0..n_theta {
let theta = std::f64::consts::TAU * (j as f64) / (n_theta as f64);
let n = vmec.eval_rz(&r_coef_nat, &z_coef_nat, theta, phi);
let k = vmec.eval_rz(&r_coef_nak, &z_coef_nak, theta, phi);
let dr = (n.r - k.r).abs();
let dz = (n.z - k.z).abs();
max_dr = max_dr.max(dr);
max_dz = max_dz.max(dz);
sum_sq_dr += dr * dr;
sum_sq_dz += dz * dz;
}
}
let n_pts = (n_phi * n_theta) as f64;
eprintln!(
"s=1.08 surface drift: max(|ΔR|)={max_dr:.4}, max(|ΔZ|)={max_dz:.4}, \
rms(ΔR)={:.4}, rms(ΔZ)={:.4}",
(sum_sq_dr / n_pts).sqrt(),
(sum_sq_dz / n_pts).sqrt(),
);
}
#[test]
fn natural_vs_not_a_knot_extrapolation_differs() {
let n = 201;
let xs: Vec<f64> = (0..n).map(|i| i as f64 / (n - 1) as f64).collect();
let ys: Vec<f64> =
xs.iter().map(|x| (3.0 * std::f64::consts::PI * x).sin() + 0.2 * x.powi(3)).collect();
let natural = CubicSpline::new(&xs, &ys, BoundaryCondition::Natural);
let not_a_knot = CubicSpline::new(&xs, &ys, BoundaryCondition::NotAKnot);
let y_nat_mid = natural.eval(0.5);
let y_nak_mid = not_a_knot.eval(0.5);
assert!(
(y_nat_mid - y_nak_mid).abs() < 1e-6,
"natural ({y_nat_mid}) と not-a-knot ({y_nak_mid}) が内挿で大きく違う"
);
let y_nat_ext = natural.eval(1.08);
let y_nak_ext = not_a_knot.eval(1.08);
let diff = (y_nat_ext - y_nak_ext).abs();
assert!(
diff > 1e-3,
"外挿で差が出ていない: natural={y_nat_ext}, not-a-knot={y_nak_ext}, diff={diff}"
);
eprintln!(
"x=1.08 extrapolation: natural={y_nat_ext:.6}, not-a-knot={y_nak_ext:.6}, diff={diff:.6}"
);
}
}