use super::*;
pub fn bspline_derivative_dense_any_order(
data: ArrayView1<'_, f64>,
knot_vector: ArrayView1<'_, f64>,
degree: usize,
order: usize,
) -> Result<Array2<f64>, BasisError> {
let knots_owned = knot_vector.to_owned();
let num_cols = knot_vector.len().saturating_sub(degree + 1);
if order > degree {
return Ok(Array2::zeros((data.len(), num_cols)));
}
match order {
0 => {
let (basis, _) = create_basis::<Dense>(
data,
KnotSource::Provided(knots_owned.view()),
degree,
BasisOptions::value(),
)?;
Ok(basis.as_ref().clone())
}
1 => {
let (basis, _) = create_basis::<Dense>(
data,
KnotSource::Provided(knots_owned.view()),
degree,
BasisOptions::first_derivative(),
)?;
Ok(basis.as_ref().clone())
}
2 => {
let (basis, _) = create_basis::<Dense>(
data,
KnotSource::Provided(knots_owned.view()),
degree,
BasisOptions::second_derivative(),
)?;
Ok(basis.as_ref().clone())
}
order => {
let mut out = Array2::<f64>::zeros((data.len(), num_cols));
let mut workspace = BsplineDerivativeWorkspace::new();
for (row_index, &x) in data.iter().enumerate() {
let row = out.slice_mut(s![row_index, ..]).into_slice().ok_or_else(|| {
BasisError::InvalidInput("B-spline derivative row is not contiguous".into())
})?;
evaluate_bspline_derivative_recurrence_into(
order,
x,
knots_owned.view(),
degree,
row,
&mut workspace,
0,
)?;
}
Ok(out)
}
}
}
#[inline]
fn ramp_padding(bs_degree: usize) -> usize {
bs_degree + 1
}
fn pad_knots(knot_vector: ArrayView1<'_, f64>, pad: usize) -> Result<Array1<f64>, BasisError> {
let len = knot_vector.len();
if len < 2 {
return Err(BasisError::InvalidKnotVector(
"I-spline ramp padding needs at least two knots".to_string(),
));
}
let first_gap = (1..len)
.map(|i| knot_vector[i] - knot_vector[i - 1])
.find(|gap| *gap > 0.0);
let last_gap = (1..len)
.rev()
.map(|i| knot_vector[i] - knot_vector[i - 1])
.find(|gap| *gap > 0.0);
let (Some(low_gap), Some(high_gap)) = (first_gap, last_gap) else {
return Err(BasisError::InvalidKnotVector(
"I-spline ramp padding needs a knot vector with a positive span".to_string(),
));
};
let mut padded = Vec::with_capacity(len + 2 * pad);
for step in (1..=pad).rev() {
padded.push(knot_vector[0] - low_gap * step as f64);
}
padded.extend(knot_vector.iter().copied());
for step in 1..=pad {
padded.push(knot_vector[len - 1] + high_gap * step as f64);
}
Ok(Array1::from(padded))
}
pub fn ispline_ramp_basis_dense(
data: ArrayView1<'_, f64>,
knot_vector: ArrayView1<'_, f64>,
ispline_degree: usize,
derivative_order: usize,
) -> Result<Array2<f64>, BasisError> {
if ispline_degree < 1 {
return Err(BasisError::InvalidDegree(ispline_degree));
}
let bs_degree = ispline_degree
.checked_add(1)
.ok_or_else(|| BasisError::InvalidInput("I-spline degree overflow".to_string()))?;
validate_knots_for_degree(knot_vector, bs_degree)?;
let num_bspline = knot_vector.len() - bs_degree - 1;
let num_ramps = num_bspline.saturating_sub(1);
if num_ramps == 0 {
return Ok(Array2::zeros((data.len(), 0)));
}
let pad = ramp_padding(bs_degree);
let padded = pad_knots(knot_vector, pad)?;
let derivatives =
bspline_derivative_dense_any_order(data, padded.view(), bs_degree, derivative_order)?;
let padded_cols = padded.len() - bs_degree - 1;
if derivatives.ncols() != padded_cols {
return Err(BasisError::InvalidInput(format!(
"padded B-spline evaluation produced {} columns, expected {padded_cols}",
derivatives.ncols()
)));
}
let top = knot_vector[knot_vector.len() - 1];
let top_is_repeated = knot_vector.len() >= 2 && knot_vector[knot_vector.len() - 2] == top;
let left_limit_rows: Vec<usize> = if top_is_repeated && derivative_order >= 1 {
(0..data.len()).filter(|&row| data[row] == top).collect()
} else {
Vec::new()
};
let left_limit_table = if left_limit_rows.is_empty() {
None
} else {
let points = Array1::from_elem(1, top);
Some(bspline_derivative_dense_any_order(
points.view(),
knot_vector,
bs_degree,
derivative_order,
)?)
};
let mut out = Array2::<f64>::zeros((data.len(), num_ramps));
let mut sum_at = vec![0.0_f64; num_bspline];
for row in 0..data.len() {
let x = data[row];
if !x.is_finite() {
continue;
}
let mut running = 0.0_f64;
for value in sum_at.iter_mut() {
*value = 0.0;
}
for column in (0..padded_cols).rev() {
let term = derivatives[[row, column]];
if term.is_finite() {
running += term;
}
if column >= pad {
let original = column - pad;
if original < num_bspline {
sum_at[original] = running;
}
}
}
for ramp in 0..num_ramps {
let index = ramp + 1;
let support_start = knot_vector[index];
let support_end = knot_vector[index + bs_degree];
out[[row, ramp]] = if x < support_start {
0.0
} else if x > support_end {
if derivative_order == 0 { 1.0 } else { 0.0 }
} else {
sum_at[index]
};
}
}
if let Some(table) = left_limit_table.as_ref() {
for &row in &left_limit_rows {
let mut running = 0.0_f64;
for column in (1..num_bspline).rev() {
let term = table[[0, column]];
if term.is_finite() {
running += term;
}
out[[row, column - 1]] = running;
}
}
}
Ok(out)
}
pub fn monotone_warp_knots(
low: f64,
high: f64,
degree: usize,
num_internal_knots: usize,
) -> Result<Array1<f64>, String> {
if !(low.is_finite() && high.is_finite() && high > low) {
return Err(format!(
"monotone warp knots need a finite non-degenerate range, got [{low}, {high}]"
));
}
if degree < 1 {
return Err("monotone warp knots need degree >= 1".to_string());
}
let spans = num_internal_knots + 1;
let width = (high - low) / spans as f64;
if !(width > 0.0) {
return Err(format!(
"monotone warp knot spacing is not positive for range [{low}, {high}] and \
{num_internal_knots} internal knots"
));
}
let total = spans + 2 * degree;
let mut knots = Vec::with_capacity(total + 1);
for step in 0..=total {
knots.push(low + width * (step as f64 - degree as f64));
}
Ok(Array1::from(knots))
}
pub fn monotone_warp_knots_from_seed(
seed: ArrayView1<'_, f64>,
degree: usize,
num_internal_knots: usize,
) -> Result<Array1<f64>, String> {
let mut low = seed.iter().copied().fold(f64::INFINITY, f64::min);
let mut high = seed.iter().copied().fold(f64::NEG_INFINITY, f64::max);
if !low.is_finite() || !high.is_finite() {
return Err("non-finite seed for monotone warp knot initialization".to_string());
}
if (high - low).abs() < MIN_WARP_SEED_SPAN {
let center = 0.5 * (low + high);
low = center - DEFAULT_WARP_HALF_RANGE;
high = center + DEFAULT_WARP_HALF_RANGE;
}
monotone_warp_knots(low, high, degree, num_internal_knots)
}
const MIN_WARP_SEED_SPAN: f64 = 1e-8;
const DEFAULT_WARP_HALF_RANGE: f64 = 3.0;
#[cfg(test)]
mod tests {
use super::*;
use ndarray::{Array1, array};
fn clamped(degree: usize, internal: &[f64], low: f64, high: f64) -> Array1<f64> {
let mut knots = vec![low; degree + 1];
knots.extend_from_slice(internal);
knots.extend(std::iter::repeat_n(high, degree + 1));
Array1::from(knots)
}
#[test]
fn the_ramp_reproduces_the_clamped_convention() {
for ispline_degree in 1..=4usize {
let knots = clamped(ispline_degree + 1, &[0.0, 1.0], -1.0, 2.0);
let x = Array1::linspace(-3.0, 4.0, 71);
let ramp = ispline_ramp_basis_dense(x.view(), knots.view(), ispline_degree, 0)
.expect("ramp value");
let legacy = create_ispline_derivative_dense(x.view(), &knots, ispline_degree, 0)
.expect("legacy value");
assert_eq!(ramp.dim(), legacy.dim());
for ((row, col), value) in ramp.indexed_iter() {
assert!(
(value - legacy[[row, col]]).abs() <= 1e-12,
"value column {col} at x={} : ramp {value:.12e} vs clamped {:.12e}",
x[row],
legacy[[row, col]],
);
}
}
}
#[test]
fn a_clamped_top_knot_still_reports_its_interior_one_sided_slope() {
for ispline_degree in 1..=4usize {
let knots = clamped(ispline_degree + 1, &[0.0, 1.0], -1.0, 2.0);
let at_top = array![2.0];
let inside = array![2.0 - 1.0e-9];
let ramp_at_top =
ispline_ramp_basis_dense(at_top.view(), knots.view(), ispline_degree, 1)
.expect("ramp slope at the top");
let ramp_inside =
ispline_ramp_basis_dense(inside.view(), knots.view(), ispline_degree, 1)
.expect("ramp slope inside");
let worst = (0..ramp_at_top.ncols())
.map(|c| (ramp_at_top[[0, c]] - ramp_inside[[0, c]]).abs())
.fold(0.0_f64, f64::max);
let scale = (0..ramp_at_top.ncols())
.map(|c| ramp_at_top[[0, c]].abs())
.fold(0.0_f64, f64::max);
assert!(
scale > 0.1,
"the clamped top must carry a real slope for this pin to bite; got {scale:.3e}"
);
assert!(
worst <= 1.0e-6 * (1.0 + scale),
"the clamped top slope must be the interior one-sided value: at-top vs \
inside differ by {worst:.3e}"
);
}
}
#[test]
fn a_simple_top_knot_reports_zero_because_the_ramp_has_settled() {
for degree in 2..=5usize {
let knots = monotone_warp_knots(-1.0, 2.0, degree, 2).expect("warp knots");
let at_top = array![knots[knots.len() - 1]];
let slope = ispline_ramp_basis_dense(at_top.view(), knots.view(), degree - 1, 1)
.expect("ramp slope at the top");
for c in 0..slope.ncols() {
assert_eq!(
slope[[0, c]],
0.0,
"column {c} at the simple top knot must be exactly zero"
);
}
}
}
#[test]
fn every_column_is_a_zero_to_one_ramp_on_a_simple_knot_vector() {
for degree in 2..=5usize {
let knots = monotone_warp_knots(-1.0, 2.0, degree, 2).expect("warp knots");
let x = Array1::linspace(-8.0, 9.0, 341);
let values = ispline_ramp_basis_dense(x.view(), knots.view(), degree - 1, 0)
.expect("ramp value");
for col in 0..values.ncols() {
assert!(
values[[0, col]] == 0.0 && values[[values.nrows() - 1, col]] == 1.0,
"column {col} must run 0 -> 1, got {} -> {}",
values[[0, col]],
values[[values.nrows() - 1, col]],
);
for row in 1..values.nrows() {
assert!(
values[[row, col]] >= values[[row - 1, col]] - 1e-12,
"column {col} decreases between x={} and x={}",
x[row - 1],
x[row],
);
}
}
}
}
#[test]
fn a_simple_ended_warp_basis_is_continuous_to_one_below_its_degree() {
for degree in 2..=5usize {
let knots = monotone_warp_knots(-1.0, 2.0, degree, 2).expect("warp knots");
let probes: Vec<f64> = knots.iter().copied().collect();
for order in 0..degree {
for &knot in &probes {
let gap = |h: f64| -> f64 {
let x = array![knot - h, knot + h];
let table =
ispline_ramp_basis_dense(x.view(), knots.view(), degree - 1, order)
.expect("ramp derivative");
(0..table.ncols())
.map(|c| (table[[0, c]] - table[[1, c]]).abs())
.fold(0.0_f64, f64::max)
};
let coarse = gap(1.0e-3);
let fine = gap(1.0e-6);
assert!(
fine <= coarse / 100.0 + 1.0e-12,
"degree {degree} order {order} steps at knot {knot}: gap {coarse:.3e} \
at h=1e-3 and {fine:.3e} at h=1e-6, a ratio of {:.2e} against the \
1000x a continuous derivative must give",
coarse / fine.max(f64::MIN_POSITIVE),
);
}
}
}
}
#[test]
fn the_clamped_vector_steps_at_order_two_at_every_degree() {
for degree in 2..=5usize {
let knots = clamped(degree, &[0.0, 1.0], -1.0, 2.0);
let gap = |h: f64| -> f64 {
let x = array![2.0 - h, 2.0 + h];
let table = ispline_ramp_basis_dense(x.view(), knots.view(), degree - 1, 2)
.expect("ramp derivative");
(0..table.ncols())
.map(|c| (table[[0, c]] - table[[1, c]]).abs())
.fold(0.0_f64, f64::max)
};
let coarse = gap(1.0e-3);
let fine = gap(1.0e-6);
assert!(
fine > coarse / 10.0,
"degree {degree}: the clamped right edge must STEP at order 2 for the \
simple-ended pin to be measuring something; got {coarse:.3e} -> {fine:.3e}"
);
}
}
#[test]
fn the_warp_knot_vector_keeps_the_clamped_column_count() {
for degree in 2..=5usize {
for internal in 0..=4usize {
let internal_knots: Vec<f64> = (1..=internal)
.map(|i| -1.0 + 3.0 * (i as f64) / ((internal + 1) as f64))
.collect();
let clamped_knots = clamped(degree, &internal_knots, -1.0, 2.0);
let warp_knots = monotone_warp_knots(-1.0, 2.0, degree, internal).expect("knots");
let cols = |knots: &Array1<f64>| knots.len() - degree - 2;
assert_eq!(
cols(&clamped_knots),
cols(&warp_knots),
"degree {degree}, {internal} internal knots"
);
assert_eq!(cols(&warp_knots), internal + degree);
}
}
}
}