use ndarray::{Array1, Array2, ArrayView1};
use super::{
BasisError, BasisOptions, Dense, KnotSource, create_basis, create_ispline_derivative_dense,
};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ISplineBoundary {
#[default]
Saturate,
LinearTails,
}
pub fn ispline_modelling_interval(
knots: ArrayView1<'_, f64>,
degree: usize,
) -> Result<Option<(f64, f64)>, BasisError> {
let bspline_degree = degree
.checked_add(1)
.ok_or_else(|| BasisError::InvalidInput("I-spline degree overflow".to_string()))?;
let bspline_columns = knots.len().checked_sub(bspline_degree + 1).ok_or_else(|| {
BasisError::InvalidInput(format!(
"I-spline knot vector of length {} cannot carry a degree-{bspline_degree} B-spline \
frame",
knots.len()
))
})?;
if bspline_columns == 0 || bspline_degree >= knots.len() {
return Ok(None);
}
let left = knots[bspline_degree];
let right = knots[bspline_columns];
if !(left.is_finite() && right.is_finite() && left < right) {
return Ok(None);
}
Ok(Some((left, right)))
}
pub fn ispline_value_and_first_derivative(
data: ArrayView1<'_, f64>,
knots: ArrayView1<'_, f64>,
degree: usize,
boundary: ISplineBoundary,
) -> Result<(Array2<f64>, Array2<f64>), BasisError> {
let owned_knots = knots.to_owned();
let (value_arc, _) = create_basis::<Dense>(
data,
KnotSource::Provided(knots),
degree,
BasisOptions::i_spline(),
)?;
let mut value = value_arc.as_ref().clone();
let mut derivative = create_ispline_derivative_dense(data, &owned_knots, degree, 1)?;
if derivative.ncols() != value.ncols() || derivative.nrows() != value.nrows() {
return Err(BasisError::DimensionMismatch(format!(
"I-spline derivative basis is {:?} but the value basis is {:?}",
derivative.dim(),
value.dim()
)));
}
match boundary {
ISplineBoundary::Saturate => {}
ISplineBoundary::LinearTails => {
extend_ispline_pair_affinely(data, knots, degree, &mut value, &mut derivative)?;
}
}
Ok((value, derivative))
}
pub fn ispline_value(
data: ArrayView1<'_, f64>,
knots: ArrayView1<'_, f64>,
degree: usize,
boundary: ISplineBoundary,
) -> Result<Array2<f64>, BasisError> {
let (value, _derivative) = ispline_value_and_first_derivative(data, knots, degree, boundary)?;
Ok(value)
}
fn extend_ispline_pair_affinely(
data: ArrayView1<'_, f64>,
knots: ArrayView1<'_, f64>,
degree: usize,
value: &mut Array2<f64>,
derivative: &mut Array2<f64>,
) -> Result<(), BasisError> {
let Some((left, right)) = ispline_modelling_interval(knots, degree)? else {
return Ok(());
};
if !data.iter().any(|&x| x < left || x > right) {
return Ok(());
}
let boundary_points = Array1::from_vec(vec![left, right]);
let (boundary_value_arc, _) = create_basis::<Dense>(
boundary_points.view(),
KnotSource::Provided(knots),
degree,
BasisOptions::i_spline(),
)?;
let boundary_value = boundary_value_arc.as_ref();
let boundary_derivative =
create_ispline_derivative_dense(boundary_points.view(), &knots.to_owned(), degree, 1)?;
let columns = value.ncols();
if boundary_value.ncols() != columns || boundary_derivative.ncols() != columns {
return Err(BasisError::DimensionMismatch(format!(
"I-spline boundary bases are {}/{} columns wide but the evaluated basis is {columns}",
boundary_value.ncols(),
boundary_derivative.ncols()
)));
}
for (row, &x) in data.iter().enumerate() {
let (end, anchor) = if x < left {
(0usize, left)
} else if x > right {
(1usize, right)
} else {
continue;
};
let step = x - anchor;
for column in 0..columns {
let slope = boundary_derivative[[end, column]];
value[[row, column]] = boundary_value[[end, column]] + step * slope;
derivative[[row, column]] = slope;
}
}
Ok(())
}
#[cfg(test)]
mod tests_ispline_boundary {
use super::*;
fn clamped_knots(lo: f64, hi: f64, internal: usize) -> Array1<f64> {
let mut knots: Vec<f64> = vec![lo; 5];
for k in 1..=internal {
knots.push(lo + (hi - lo) * (k as f64) / ((internal + 1) as f64));
}
knots.extend(std::iter::repeat_n(hi, 5));
Array1::from_vec(knots)
}
fn probe_points(lo: f64, hi: f64) -> Array1<f64> {
Array1::from_vec(vec![
lo - 4.0,
lo - 0.7,
lo,
lo + 0.25 * (hi - lo),
0.5 * (lo + hi),
hi - 0.25 * (hi - lo),
hi,
hi + 0.7,
hi + 4.0,
])
}
#[test]
fn the_modelling_interval_is_the_frames_partition_of_unity_span() {
let knots = clamped_knots(-1.0, 2.0, 3);
let interval = ispline_modelling_interval(knots.view(), 3)
.expect("interval")
.expect("a usable interval");
assert!(
(interval.0 - (-1.0)).abs() < 1e-15 && (interval.1 - 2.0).abs() < 1e-15,
"modelling interval {interval:?} is not the clamped support"
);
}
#[test]
fn both_conventions_agree_with_the_raw_evaluator_inside_the_knots() {
let knots = clamped_knots(0.0, 1.0, 4);
let inside = Array1::from_vec(vec![0.0, 0.1, 0.37, 0.5, 0.81, 1.0]);
let (saturating_value, saturating_derivative) = ispline_value_and_first_derivative(
inside.view(),
knots.view(),
3,
ISplineBoundary::Saturate,
)
.expect("saturating pair");
let (linear_value, linear_derivative) = ispline_value_and_first_derivative(
inside.view(),
knots.view(),
3,
ISplineBoundary::LinearTails,
)
.expect("linear-tail pair");
assert_eq!(
saturating_value, linear_value,
"the two conventions must be BIT-identical inside the knots"
);
assert_eq!(
saturating_derivative, linear_derivative,
"the two conventions must be BIT-identical inside the knots"
);
}
#[test]
fn the_derivative_is_a_finite_difference_of_the_value_2705() {
let (lo, hi) = (0.0_f64, 1.0_f64);
let knots = clamped_knots(lo, hi, 4);
let points = probe_points(lo, hi);
let step = 1e-6_f64;
for boundary in [ISplineBoundary::Saturate, ISplineBoundary::LinearTails] {
for &x in points.iter() {
if (x - lo).abs() < 2.0 * step || (x - hi).abs() < 2.0 * step {
continue;
}
let center = Array1::from_vec(vec![x]);
let plus = Array1::from_vec(vec![x + step]);
let minus = Array1::from_vec(vec![x - step]);
let (_, analytic) =
ispline_value_and_first_derivative(center.view(), knots.view(), 3, boundary)
.expect("analytic derivative");
let (value_plus, _) =
ispline_value_and_first_derivative(plus.view(), knots.view(), 3, boundary)
.expect("forward value");
let (value_minus, _) =
ispline_value_and_first_derivative(minus.view(), knots.view(), 3, boundary)
.expect("backward value");
for column in 0..analytic.ncols() {
let difference =
(value_plus[[0, column]] - value_minus[[0, column]]) / (2.0 * step);
let gap = (difference - analytic[[0, column]]).abs();
assert!(
gap < 1e-6,
"{boundary:?}: column {column} at x={x}: analytic {} vs finite \
difference {difference} (gap {gap:.3e})",
analytic[[0, column]]
);
}
}
}
}
#[test]
fn saturating_holds_the_boundary_value_and_kills_the_slope() {
let (lo, hi) = (0.0_f64, 1.0_f64);
let knots = clamped_knots(lo, hi, 4);
let points = Array1::from_vec(vec![lo - 3.0, lo, hi, hi + 3.0]);
let (value, derivative) = ispline_value_and_first_derivative(
points.view(),
knots.view(),
3,
ISplineBoundary::Saturate,
)
.expect("saturating pair");
for column in 0..value.ncols() {
assert_eq!(
value[[0, column]],
value[[1, column]],
"column {column} must hold its left-boundary value"
);
assert_eq!(
value[[3, column]],
value[[2, column]],
"column {column} must hold its right-boundary value"
);
assert_eq!(
derivative[[0, column]],
0.0,
"column {column} must carry no exterior slope"
);
assert_eq!(
derivative[[3, column]],
0.0,
"column {column} must carry no exterior slope"
);
}
}
#[test]
fn linear_tails_continue_at_the_boundary_slope_and_stay_monotone() {
let (lo, hi) = (0.0_f64, 1.0_f64);
let knots = clamped_knots(lo, hi, 4);
let far = 3.0_f64;
let points = Array1::from_vec(vec![lo - far, lo, hi, hi + far]);
let (value, derivative) = ispline_value_and_first_derivative(
points.view(),
knots.view(),
3,
ISplineBoundary::LinearTails,
)
.expect("linear-tail pair");
let mut left_slope_total = 0.0_f64;
let mut right_slope_total = 0.0_f64;
for column in 0..value.ncols() {
let left_slope = derivative[[1, column]];
let right_slope = derivative[[2, column]];
left_slope_total += left_slope;
right_slope_total += right_slope;
assert!(
left_slope >= 0.0 && right_slope >= 0.0,
"column {column} boundary slopes must be non-negative: {left_slope}, {right_slope}"
);
assert_eq!(
derivative[[0, column]],
left_slope,
"column {column} must carry its LEFT boundary slope below the support"
);
assert_eq!(
derivative[[3, column]],
right_slope,
"column {column} must carry its RIGHT boundary slope above the support"
);
let expected_below = value[[1, column]] - far * left_slope;
let expected_above = value[[2, column]] + far * right_slope;
assert!(
(value[[0, column]] - expected_below).abs() < 1e-12,
"column {column} below the support: {} vs {expected_below}",
value[[0, column]]
);
assert!(
(value[[3, column]] - expected_above).abs() < 1e-12,
"column {column} above the support: {} vs {expected_above}",
value[[3, column]]
);
}
assert!(
left_slope_total > 0.0 && right_slope_total > 0.0,
"the fixture must have a nonzero boundary slope on both sides; got \
{left_slope_total} and {right_slope_total}"
);
}
}