use super::{BasisError, PeriodicBSplineBasisSpec, build_periodic_bspline_basis_1d};
use ndarray::{Array1, Array2, ArrayView1};
#[inline]
pub(crate) fn wrap_to_period(x: f64, start: f64, period: f64) -> f64 {
let offset = (x - start).rem_euclid(period);
if offset >= period {
start
} else {
start + offset
}
}
#[inline]
pub(crate) fn cyclic_knot_anchor(start: f64, period: f64, num_basis: usize) -> (f64, f64) {
let h = period / num_basis as f64;
(start, h)
}
pub(crate) fn cyclic_uniform_knot_vector(
start: f64,
end: f64,
degree: usize,
num_basis: usize,
) -> Array1<f64> {
let period = end - start;
let (anchor, h) = cyclic_knot_anchor(start, period, num_basis);
let total_knots = num_basis + 2 * degree + 1;
Array1::from_iter((0..total_knots).map(|i| anchor + (i as f64 - degree as f64) * h))
}
pub(crate) fn create_cyclic_bspline_basis_dense(
data: ArrayView1<'_, f64>,
start: f64,
end: f64,
degree: usize,
num_basis: usize,
) -> Result<(Array2<f64>, Array1<f64>), BasisError> {
if end <= start {
return Err(BasisError::InvalidRange(start, end));
}
if num_basis <= degree {
crate::bail_invalid_basis!(
"cyclic B-spline basis requires more basis functions ({num_basis}) than degree ({degree})"
);
}
let period = end - start;
let knots = cyclic_uniform_knot_vector(start, end, degree, num_basis);
let (anchor, _h) = cyclic_knot_anchor(start, period, num_basis);
let cyclic = build_periodic_bspline_basis_1d(
data,
&PeriodicBSplineBasisSpec {
degree,
num_basis,
period,
origin: anchor,
penalty_order: degree.min(2),
},
)?;
Ok((cyclic, knots))
}
#[cfg(test)]
mod closure_tests {
use super::*;
#[test]
fn cyclic_basis_rigid_rotation_under_whole_knot_shift() {
let degree = 3usize;
let num_basis = 8usize;
let start = 0.0_f64;
let period = std::f64::consts::TAU;
let h = period / num_basis as f64;
let thetas = Array1::from_iter((0..40).map(|i| (i as f64 + 0.3) / 40.0 * period));
let (b0, _) = create_cyclic_bspline_basis_dense(
thetas.view(),
start,
start + period,
degree,
num_basis,
)
.unwrap();
let (b1, _) = create_cyclic_bspline_basis_dense(
thetas.view(),
start + h,
start + h + period,
degree,
num_basis,
)
.unwrap();
let mut best = f64::INFINITY;
let mut best_shift = 0usize;
for shift in 0..num_basis {
let mut maxerr = 0.0_f64;
for r in 0..b0.nrows() {
for j in 0..num_basis {
let permuted = b0[[r, (j + shift) % num_basis]];
maxerr = maxerr.max((b1[[r, j]] - permuted).abs());
}
}
if maxerr < best {
best = maxerr;
best_shift = shift;
}
}
eprintln!("[cyclic-rigid] best cyclic-shift match err={best:.3e} at shift={best_shift}");
assert!(
best < 1e-10,
"cyclic basis is NOT a rigid cyclic permutation under a whole-knot seam shift: \
best max|ΔB| over all {num_basis} shifts = {best:.3e} (shift {best_shift})"
);
}
#[test]
fn cyclic_basis_translation_equivariant() {
let degree = 3usize;
let num_basis = 12usize;
let period = std::f64::consts::TAU;
let h = period / num_basis as f64;
let thetas = Array1::from_iter((0..200).map(|i| (i as f64 + 0.123) / 200.0 * period));
let (reference, ref_knots) =
create_cyclic_bspline_basis_dense(thetas.view(), 0.0, period, degree, num_basis)
.unwrap();
for frac in [0.37_f64, 0.5, 0.81, 1.0, 1.5, 2.8, 1.0e6 + 0.37] {
let c = frac * h;
let shifted = thetas.mapv(|t| t + c);
let (bs, knots) =
create_cyclic_bspline_basis_dense(shifted.view(), c, c + period, degree, num_basis)
.unwrap();
let mut maxerr = 0.0_f64;
for r in 0..reference.nrows() {
for j in 0..num_basis {
maxerr = maxerr.max((bs[[r, j]] - reference[[r, j]]).abs());
}
}
let mut knot_err = 0.0_f64;
for (k_ref, k_new) in ref_knots.iter().zip(knots.iter()) {
knot_err = knot_err.max((k_new - (k_ref + c)).abs());
}
eprintln!(
"[cyclic-translate] c={c:.4} (frac {frac}) design err={maxerr:.3e} knot err={knot_err:.3e}"
);
let ulp = 1e-9 * c.abs().max(1.0);
assert!(
maxerr < 1e-9 + ulp,
"cyclic basis is NOT translation equivariant at offset frac={frac}: \
max row error {maxerr:.3e} (knots must anchor to the declared domain origin)"
);
assert!(
knot_err < 1e-9 + ulp,
"cyclic knots did not translate with the domain at offset frac={frac}: \
max knot drift {knot_err:.3e}"
);
}
}
}