pub fn build_duchon_basiswithworkspace(
data: ArrayView2<'_, f64>,
spec: &DuchonBasisSpec,
workspace: &mut BasisWorkspace,
) -> Result<BasisBuildResult, BasisError> {
if let Some((start, end, _period)) = spec.boundary.period() {
return build_cyclic_duchon_basis_1dwithworkspace(data, spec, start, end);
}
let centers = select_centers_by_strategy(data, &spec.center_strategy)?;
assert_spatial_centers_below_large_scale_cap(data.ncols(), centers.view())?;
if spec.periodic.is_some() {
return build_periodic_duchon_basis_1d(data, spec, centers, workspace);
}
let effective_nullspace_order =
duchon_effective_nullspace_order(centers.view(), spec.nullspace_order);
let p_order = duchon_p_from_nullspace_order(effective_nullspace_order);
let aniso = auto_seed_aniso_contrasts(centers.view(), spec.aniso_log_scales.as_deref());
let validation_power = if spec.length_scale.is_some() {
spec.power_as_usize() as f64
} else {
spec.power
};
validate_duchon_kernel_orders(spec.length_scale, p_order, validation_power, data.ncols())?;
let kernel_transform = kernel_constraint_nullspace(
centers.view(),
effective_nullspace_order,
&mut workspace.cache,
)?;
let poly_cols = polynomial_block_from_order(data, effective_nullspace_order).ncols();
let base_cols = kernel_transform.ncols() + poly_cols;
let dense_bytes = dense_design_bytes(data.nrows(), base_cols);
let use_lazy = should_use_lazy_spatial_design(data.nrows(), base_cols, workspace.policy());
let (design, identifiability_transform) = if use_lazy {
log::info!(
"Duchon basis switching to lazy chunked design: n={} p={} ({:.1} MiB dense)",
data.nrows(),
base_cols,
dense_bytes as f64 / (1024.0 * 1024.0),
);
let d = data.ncols();
let shared_data = shared_owned_data_matrix(data, &workspace.cache);
let p_order = duchon_p_from_nullspace_order(effective_nullspace_order);
let s_order: f64 = spec.power;
let length_scale = spec.length_scale;
let s_order_int = length_scale.map(|_| duchon_power_to_usize(s_order));
let coeffs = length_scale.map(|ls| {
duchon_partial_fraction_coeffs(
p_order,
s_order_int.expect("hybrid Duchon requires integer power"),
1.0 / ls.max(1e-300),
)
});
let pure_poly_coeff = if length_scale.is_none() {
Some(PolyharmonicBlockCoeff::new(
pure_duchon_block_order(p_order, s_order),
d,
))
} else {
None
};
let poly_block = polynomial_block_from_order(data, effective_nullspace_order);
let kernel_amp = duchon_kernel_amplification(
centers.view(),
length_scale,
p_order,
duchon_power_to_usize(s_order),
d,
aniso.as_deref(),
coeffs.as_ref(),
pure_poly_coeff.as_ref(),
);
let base_design = if let Some(eta) = aniso.as_ref() {
let metric_weights = eta.iter().map(|&v| (2.0 * v).exp()).collect::<Vec<_>>();
let coeffs = coeffs.clone();
let kernel = move |data_row: &[f64], center_row: &[f64]| -> f64 {
let mut q = 0.0f64;
for axis in 0..data_row.len() {
let delta = data_row[axis] - center_row[axis];
q += metric_weights[axis] * delta * delta;
}
let r = q.sqrt();
let raw = if let Some(ppc) = pure_poly_coeff {
ppc.eval(r)
} else {
duchon_matern_kernel_general_from_distance(
r,
length_scale,
p_order,
s_order_int.expect("hybrid Duchon requires integer power"),
d,
coeffs.as_ref(),
)
.expect("validated Duchon inputs should not fail")
};
raw * kernel_amp
};
let base_op = ChunkedKernelDesignOperator::new(
shared_data.clone(),
Arc::new(centers.clone()),
kernel,
Some(Arc::new(kernel_transform.clone())),
Some(Arc::new(poly_block.clone())),
)
.map_err(BasisError::InvalidInput)?;
DesignMatrix::Dense(crate::matrix::DenseDesignMatrix::from(Arc::new(base_op)))
} else {
let coeffs = coeffs.clone();
let kernel = move |data_row: &[f64], center_row: &[f64]| -> f64 {
let r = stable_euclidean_norm((0..d).map(|axis| data_row[axis] - center_row[axis]));
let raw = if let Some(ppc) = pure_poly_coeff {
ppc.eval(r)
} else {
duchon_matern_kernel_general_from_distance(
r,
length_scale,
p_order,
s_order_int.expect("hybrid Duchon requires integer power"),
d,
coeffs.as_ref(),
)
.expect("validated Duchon inputs should not fail")
};
raw * kernel_amp
};
let base_op = ChunkedKernelDesignOperator::new(
shared_data,
Arc::new(centers.clone()),
kernel,
Some(Arc::new(kernel_transform.clone())),
Some(Arc::new(poly_block)),
)
.map_err(BasisError::InvalidInput)?;
DesignMatrix::Dense(crate::matrix::DenseDesignMatrix::from(Arc::new(base_op)))
};
let identifiability_transform = spatial_identifiability_transform_from_design_matrix(
data,
&base_design,
&spec.identifiability,
"Duchon",
)?;
let design = if let Some(transform) = identifiability_transform.as_ref() {
wrap_dense_design_with_transform(base_design, transform, "Duchon")?
} else {
base_design
};
(design, identifiability_transform)
} else {
let d = build_duchon_basis_designwithworkspace(
data,
centers.view(),
spec.length_scale,
spec.power,
effective_nullspace_order,
aniso.as_deref(),
workspace,
)?;
let basis = d.basis;
let identifiability_transform = spatial_identifiability_transform_from_design(
data,
basis.view(),
&spec.identifiability,
"Duchon",
)?;
let design = if let Some(z) = identifiability_transform.as_ref() {
DesignMatrix::Dense(crate::matrix::DenseDesignMatrix::from(fast_ab(&basis, z)))
} else {
DesignMatrix::Dense(crate::matrix::DenseDesignMatrix::from(basis))
};
(design, identifiability_transform)
};
let operator_collocation_points = {
let any_operator = matches!(
spec.operator_penalties.mass,
OperatorPenaltySpec::Active { .. }
) || matches!(
spec.operator_penalties.tension,
OperatorPenaltySpec::Active { .. }
) || matches!(
spec.operator_penalties.stiffness,
OperatorPenaltySpec::Active { .. }
);
if any_operator {
let m = (DUCHON_COLLOCATION_OVERSAMPLE * centers.nrows()).min(data.nrows());
Some(select_thin_plate_knots(data, m)?)
} else {
None
}
};
let mut candidates = duchon_native_penalty_candidates(
centers.view(),
spec.length_scale,
spec.power,
effective_nullspace_order,
aniso.as_deref(),
&kernel_transform,
identifiability_transform.as_ref(),
poly_cols,
)?;
if let Some(points) = operator_collocation_points.as_ref() {
candidates.extend(duchon_operator_penalty_candidates(
points.view(),
centers.view(),
&spec.operator_penalties,
spec.length_scale,
spec.power,
effective_nullspace_order,
aniso.is_some(),
identifiability_transform.as_ref(),
workspace,
)?);
}
let (penalties, nullspace_dims, penaltyinfo, null_eigenvectors, ops) =
filter_active_penalty_candidates_with_ops(candidates)?;
Ok(BasisBuildResult {
design,
penalties,
nullspace_dims,
penaltyinfo,
ops,
null_eigenvectors,
joint_null_rotation: None,
metadata: BasisMetadata::Duchon {
centers,
length_scale: spec.length_scale,
periodic: spec.periodic.clone(),
power: spec.power,
nullspace_order: effective_nullspace_order,
identifiability_transform,
input_scales: None,
aniso_log_scales: aniso,
operator_collocation_points,
},
kronecker_factored: None,
})
}
fn polynomial_block_from_order(
points: ArrayView2<'_, f64>,
order: DuchonNullspaceOrder,
) -> Array2<f64> {
let n = points.nrows();
let d = points.ncols();
match order {
DuchonNullspaceOrder::Zero => Array2::<f64>::ones((n, 1)),
DuchonNullspaceOrder::Linear => {
let mut poly = Array2::<f64>::zeros((n, d + 1));
poly.column_mut(0).fill(1.0);
for c in 0..d {
poly.column_mut(c + 1).assign(&points.column(c));
}
poly
}
DuchonNullspaceOrder::Degree(degree) => monomial_basis_block(points, degree),
}
}
pub fn monomial_exponents(dimension: usize, max_total_degree: usize) -> Vec<Vec<usize>> {
fn recurse(
axis: usize,
remaining_degree: usize,
current: &mut [usize],
out: &mut Vec<Vec<usize>>,
) {
if axis + 1 == current.len() {
current[axis] = remaining_degree;
out.push(current.to_vec());
return;
}
for exponent in (0..=remaining_degree).rev() {
current[axis] = exponent;
recurse(axis + 1, remaining_degree - exponent, current, out);
}
}
if dimension == 0 {
return vec![Vec::new()];
}
let mut out = Vec::new();
let mut current = vec![0usize; dimension];
for total_degree in 0..=max_total_degree {
recurse(0, total_degree, &mut current, &mut out);
}
out
}
pub fn duchon_nullspace_dimension(dimension: usize, max_total_degree: usize) -> usize {
monomial_exponents(dimension, max_total_degree).len()
}
fn monomial_basis_block(points: ArrayView2<'_, f64>, max_total_degree: usize) -> Array2<f64> {
let n = points.nrows();
let exponents = monomial_exponents(points.ncols(), max_total_degree);
let mut block = Array2::<f64>::zeros((n, exponents.len()));
for (col, exponents) in exponents.iter().enumerate() {
for row in 0..n {
let mut value = 1.0;
for axis in 0..points.ncols() {
let exponent = exponents[axis];
if exponent != 0 {
value *= points[[row, axis]].powi(exponent as i32);
}
}
block[[row, col]] = value;
}
}
block
}
#[inline(always)]
fn thin_plate_polynomial_degree(dimension: usize) -> usize {
thin_plate_penalty_order(dimension).saturating_sub(1)
}
fn thin_plate_polynomial_block(points: ArrayView2<'_, f64>) -> Array2<f64> {
monomial_basis_block(points, thin_plate_polynomial_degree(points.ncols()))
}
pub fn thin_plate_polynomial_basis_dimension(dimension: usize) -> usize {
monomial_exponents(dimension, thin_plate_polynomial_degree(dimension)).len()
}
fn kernel_constraint_nullspace_from_matrix(
constraint_matrix: ArrayView2<'_, f64>,
) -> Result<Array2<f64>, BasisError> {
let k = constraint_matrix.nrows();
let q = constraint_matrix.ncols();
if q == 0 {
return Ok(Array2::<f64>::eye(k));
}
let (z, _) = rrqr_nullspace_basis(&constraint_matrix, default_rrqr_rank_alpha())
.map_err(BasisError::LinalgError)?;
Ok(z)
}
pub fn select_thin_plate_knots(
data: ArrayView2<f64>,
num_knots: usize,
) -> Result<Array2<f64>, BasisError> {
let n = data.nrows();
let d = data.ncols();
if d == 0 {
crate::bail_invalid_basis!("thin-plate spline requires at least one covariate dimension");
}
if n == 0 {
crate::bail_invalid_basis!("cannot select thin-plate knots from empty data");
}
if data.iter().any(|v| !v.is_finite()) {
crate::bail_invalid_basis!("thin-plate spline knot selection requires finite data");
}
if num_knots == 0 {
crate::bail_invalid_basis!("thin-plate spline knot count must be positive");
}
if num_knots > n {
crate::bail_invalid_basis!(
"requested {} knots but only {} rows are available",
num_knots,
n
);
}
let mut seed_idx = 0usize;
for i in 1..n {
let mut choose_i = false;
for c in 0..d {
let ai = data[[i, c]];
let as_ = data[[seed_idx, c]];
if ai < as_ {
choose_i = true;
break;
}
if ai > as_ {
break;
}
}
if choose_i {
seed_idx = i;
}
}
let mut selected = Vec::with_capacity(num_knots);
let mut chosen = vec![false; n];
let mut min_dist2 = vec![f64::INFINITY; n];
selected.push(seed_idx);
chosen[seed_idx] = true;
min_dist2.par_iter_mut().enumerate().for_each(|(i, slot)| {
let mut d2 = 0.0;
for c in 0..d {
let delta = data[[i, c]] - data[[seed_idx, c]];
d2 += delta * delta;
}
*slot = d2;
});
min_dist2[seed_idx] = 0.0;
while selected.len() < num_knots {
let best_idx = min_dist2
.par_iter()
.enumerate()
.filter(|(i, _)| !chosen[*i])
.map(|(i, &cand)| (i, cand))
.reduce_with(|a, b| {
if b.1 > a.1 || (b.1 == a.1 && b.0 < a.0) {
b
} else {
a
}
})
.map(|(i, _)| i);
let next_idx = match best_idx {
Some(i) => i,
None => break,
};
selected.push(next_idx);
chosen[next_idx] = true;
min_dist2.par_iter_mut().enumerate().for_each(|(i, slot)| {
if chosen[i] {
return;
}
let mut d2 = 0.0;
for c in 0..d {
let delta = data[[i, c]] - data[[next_idx, c]];
d2 += delta * delta;
}
if d2 < *slot {
*slot = d2;
}
});
}
let mut knots = Array2::<f64>::zeros((selected.len(), d));
for (r, &idx) in selected.iter().enumerate() {
knots.row_mut(r).assign(&data.row(idx));
}
Ok(knots)
}
#[inline(always)]
fn thin_plate_kernel_from_dist2(dist2: f64, dimension: usize) -> Result<f64, BasisError> {
if !dist2.is_finite() || dist2 < 0.0 {
crate::bail_invalid_basis!("thin-plate kernel distance must be finite and non-negative");
}
if dist2 == 0.0 {
return Ok(0.0);
}
match dimension {
1 => Ok(dist2 * dist2.sqrt()),
2 => Ok(0.5 * dist2 * dist2.ln()),
3 => Ok(-dist2.sqrt()),
_ => {
let m = dimension / 2 + 1;
let r = dist2.sqrt();
Ok(polyharmonic_kernel(r, (m) as f64, dimension))
}
}
}
#[inline(always)]
fn thin_plate_penalty_order(dimension: usize) -> usize {
match dimension {
1..=3 => 2,
_ => dimension / 2 + 1,
}
}
#[inline(always)]
fn d_canonical_tps_infeasible(dimension: usize, num_centers: usize) -> bool {
num_centers < thin_plate_polynomial_basis_dimension(dimension)
}
fn duchon_thin_plate_fallback_params(
dimension: usize,
num_centers: usize,
) -> Option<(DuchonNullspaceOrder, usize)> {
let d = dimension;
let max_op = 2usize; for (order, p, m_poly) in [
(DuchonNullspaceOrder::Linear, 2usize, d + 1),
(DuchonNullspaceOrder::Zero, 1usize, 1usize),
] {
if num_centers < m_poly {
continue;
}
let target = d + max_op;
let s_min = if 2 * p > target {
0
} else {
(target - 2 * p) / 2 + 1
};
return Some((order, s_min));
}
None
}
fn hybrid_duchon_promotion_length_scale(
centers: ArrayView2<'_, f64>,
requested_length_scale: f64,
) -> f64 {
match pairwise_distance_bounds_sampled(centers) {
Some((r_min, r_max)) => {
(r_min * r_max).sqrt()
}
None => {
if requested_length_scale.is_finite() && requested_length_scale > 0.0 {
requested_length_scale
} else {
1.0
}
}
}
}
#[inline(always)]
fn thin_plate_kernel_triplet_from_scaled_distance(
scaled_distance: f64,
dimension: usize,
) -> Result<(f64, f64, f64), BasisError> {
if !scaled_distance.is_finite() || scaled_distance < 0.0 {
crate::bail_invalid_basis!("thin-plate scaled distance must be finite and non-negative");
}
if scaled_distance == 0.0 {
return Ok((0.0, 0.0, 0.0));
}
match dimension {
1 => {
let value = scaled_distance.powi(3);
let first = 3.0 * scaled_distance.powi(2);
let second = 6.0 * scaled_distance;
Ok((value, first, second))
}
2 => {
let log_r = scaled_distance.max(1e-300).ln();
let value = scaled_distance.powi(2) * log_r;
let first = 2.0 * scaled_distance * log_r + scaled_distance;
let second = 2.0 * log_r + 3.0;
Ok((value, first, second))
}
3 => Ok((-scaled_distance, -1.0, 0.0)),
_ => polyharmonic_kernel_triplet(
scaled_distance,
thin_plate_penalty_order(dimension) as f64,
dimension,
),
}
}
#[inline(always)]
fn thin_plate_kernel_psi_triplet_from_distance(
distance: f64,
length_scale: f64,
dimension: usize,
) -> Result<(f64, f64, f64), BasisError> {
if !distance.is_finite() || distance < 0.0 {
crate::bail_invalid_basis!("thin-plate kernel distance must be finite and non-negative");
}
if !length_scale.is_finite() || length_scale <= 0.0 {
crate::bail_invalid_basis!("thin-plate length_scale must be finite and positive");
}
let scaled_distance = distance / length_scale;
let (value, radial_first, radial_second) =
thin_plate_kernel_triplet_from_scaled_distance(scaled_distance, dimension)?;
let psi = radial_first * scaled_distance;
let psi_psi = radial_second * scaled_distance * scaled_distance + psi;
Ok((value, psi, psi_psi))
}
pub fn create_thin_plate_spline_basis(
data: ArrayView2<f64>,
knots: ArrayView2<f64>,
) -> Result<ThinPlateSplineBasis, BasisError> {
let mut workspace = BasisWorkspace::default();
create_thin_plate_spline_basiswithworkspace(data, knots, &mut workspace)
}
pub fn create_thin_plate_spline_basiswithworkspace(
data: ArrayView2<f64>,
knots: ArrayView2<f64>,
workspace: &mut BasisWorkspace,
) -> Result<ThinPlateSplineBasis, BasisError> {
create_thin_plate_spline_basis_scaledwithworkspace(data, knots, 1.0, None, workspace)
}
fn create_thin_plate_spline_basis_scaledwithworkspace(
data: ArrayView2<f64>,
knots: ArrayView2<f64>,
length_scale: f64,
frozen_radial_reparam: Option<&Array2<f64>>,
workspace: &mut BasisWorkspace,
) -> Result<ThinPlateSplineBasis, BasisError> {
let n = data.nrows();
let k = knots.nrows();
let d = data.ncols();
if d == 0 {
crate::bail_invalid_basis!("thin-plate spline requires at least one covariate dimension");
}
if d != knots.ncols() {
crate::bail_dim_basis!(
"thin-plate spline dimension mismatch: data has {} columns, knots have {} columns",
d,
knots.ncols()
);
}
let poly_cols = thin_plate_polynomial_basis_dimension(d);
if k < poly_cols {
crate::bail_invalid_basis!(
"thin-plate spline requires at least {} knots to span the degree-{} polynomial null space in dimension {}; got {}",
poly_cols,
thin_plate_polynomial_degree(d),
d,
k
);
}
if data.iter().any(|v| !v.is_finite()) || knots.iter().any(|v| !v.is_finite()) {
crate::bail_invalid_basis!("thin-plate spline requires finite data and knot values");
}
if !length_scale.is_finite() || length_scale <= 0.0 {
crate::bail_invalid_basis!("thin-plate length_scale must be finite and positive");
}
let mut kernel_block = Array2::<f64>::zeros((n, k));
let kernel_result: Result<(), BasisError> = kernel_block
.axis_iter_mut(Axis(0))
.into_par_iter()
.enumerate()
.try_for_each(|(i, mut row)| {
for j in 0..k {
let mut dist2 = 0.0;
for c in 0..d {
let delta = data[[i, c]] - knots[[j, c]];
dist2 += delta * delta;
}
row[j] = thin_plate_kernel_from_dist2(dist2 / (length_scale * length_scale), d)?;
}
Ok(())
});
kernel_result?;
let poly_block = thin_plate_polynomial_block(data);
let mut omega = Array2::<f64>::zeros((k, k));
let length_scale_sq = length_scale * length_scale;
fill_symmetric_from_row_kernel(&mut omega, |i, j| {
let mut dist2 = 0.0;
for c in 0..d {
let delta = knots[[i, c]] - knots[[j, c]];
dist2 += delta * delta;
}
thin_plate_kernel_from_dist2(dist2 / length_scale_sq, d)
})?;
let z = thin_plate_kernel_constraint_nullspace(knots, &mut workspace.cache)?;
let kernel_constrained = fast_ab(&kernel_block, &z);
let omega_constrained = {
let zt_o = fast_atb(&z, &omega);
symmetrize_penalty(&fast_ab(&zt_o, &z))
};
let omega_psd = validate_psd_penalty(
&omega_constrained,
&format!("thin_plate bending penalty (dimension={d})"),
"thin-plate kernel and side-constraint assembly must yield a PSD penalty on the constrained subspace",
)?;
assert!(
omega_psd.min_eigenvalue >= -omega_psd.tolerance,
"thin-plate constrained penalty PSD validation violated tolerance after validation: min_eigenvalue={}, tolerance={}",
omega_psd.min_eigenvalue,
omega_psd.tolerance
);
assert!(
omega_psd.max_abs_eigenvalue.is_finite(),
"thin-plate constrained penalty has non-finite max eigenvalue after validation: max_abs_eigenvalue={}",
omega_psd.max_abs_eigenvalue
);
assert!(
omega_psd.effective_rank <= omega_constrained.nrows(),
"thin-plate constrained penalty rank exceeds constrained rows: effective_rank={}, rows={}",
omega_psd.effective_rank,
omega_constrained.nrows()
);
let kernel_cols = kernel_constrained.ncols();
let total_cols = kernel_cols + poly_cols;
let (radial_reparam, radial_eigvals): (Array2<f64>, Array1<f64>) = if let Some(frozen) =
frozen_radial_reparam
{
if frozen.nrows() != kernel_cols || frozen.ncols() != kernel_cols {
crate::bail_dim_basis!(
"thin-plate frozen radial reparam shape {:?} does not match radial dimension {}",
frozen.dim(),
kernel_cols
);
}
let v = frozen.to_owned();
let vt_omega_v = fast_atb(&v, &omega_constrained);
let lambda_diag = fast_ab(&vt_omega_v, &v);
let mut evals = Array1::<f64>::zeros(kernel_cols);
for i in 0..kernel_cols {
evals[i] = lambda_diag[[i, i]].max(0.0);
}
(v, evals)
} else if kernel_cols == 0 {
(Array2::<f64>::zeros((0, 0)), Array1::<f64>::zeros(0))
} else {
let sym = symmetrize_penalty(&omega_constrained);
let (mut evals, evecs) =
FaerEigh::eigh(&sym, Side::Lower).map_err(BasisError::LinalgError)?;
for v in evals.iter_mut() {
if *v < 0.0 {
*v = 0.0;
}
}
(evecs, evals)
};
let kernel_rotated = if kernel_cols == 0 {
kernel_constrained.clone()
} else {
fast_ab(&kernel_constrained, &radial_reparam)
};
let mut basis = Array2::<f64>::zeros((n, total_cols));
basis
.slice_mut(s![.., 0..kernel_cols])
.assign(&kernel_rotated);
basis.slice_mut(s![.., kernel_cols..]).assign(&poly_block);
let mut penalty_bending = Array2::<f64>::zeros((total_cols, total_cols));
for i in 0..kernel_cols {
penalty_bending[[i, i]] = radial_eigvals[i];
}
let penalty_ridge = build_nullspace_shrinkage_penalty(&penalty_bending)?
.map(|block| block.sym_penalty)
.unwrap_or_else(|| Array2::<f64>::zeros((total_cols, total_cols)));
Ok(ThinPlateSplineBasis {
basis,
penalty_bending,
penalty_ridge,
num_kernel_basis: kernel_cols,
num_polynomial_basis: poly_cols,
dimension: d,
radial_reparam,
})
}
fn active_thin_plate_penalty_derivatives(
penaltyinfo: &[PenaltyInfo],
primary_derivative: &Array2<f64>,
) -> Result<Vec<Array2<f64>>, BasisError> {
penaltyinfo
.iter()
.filter(|info| info.active)
.map(|info| match &info.source {
PenaltySource::Primary => Ok(primary_derivative.clone()),
PenaltySource::DoublePenaltyNullspace => {
Ok(Array2::<f64>::zeros(primary_derivative.raw_dim()))
}
other => Err(BasisError::InvalidInput(format!(
"unexpected ThinPlate penalty source in psi-derivative path: {other:?}"
))),
})
.collect()
}
fn build_thin_plate_penalty_psi_derivativeswithworkspace(
centers: ArrayView2<'_, f64>,
spec: &ThinPlateBasisSpec,
identifiability_transform: Option<&Array2<f64>>,
workspace: &mut BasisWorkspace,
) -> Result<(Array2<f64>, Array2<f64>), BasisError> {
let z_kernel = thin_plate_kernel_constraint_nullspace(centers, &mut workspace.cache)?;
let kernel_cols = z_kernel.ncols();
let poly_cols = thin_plate_polynomial_basis_dimension(centers.ncols());
let total_cols = kernel_cols + poly_cols;
let k = centers.nrows();
let d = centers.ncols();
let mut omega = Array2::<f64>::zeros((k, k));
let mut omega_psi = Array2::<f64>::zeros((k, k));
let mut omega_psi_psi = Array2::<f64>::zeros((k, k));
struct ThinPlatePsiTileEntry {
i: usize,
j: usize,
phi: f64,
phi_psi: f64,
phi_psi_psi: f64,
}
let n_tiles = k.div_ceil(THIN_PLATE_PENALTY_PSI_TILE_ROWS);
let omega_tiles: Result<Vec<Vec<ThinPlatePsiTileEntry>>, BasisError> = (0..n_tiles)
.into_par_iter()
.map(|tile_idx| {
let row_start = tile_idx * THIN_PLATE_PENALTY_PSI_TILE_ROWS;
let row_end = (row_start + THIN_PLATE_PENALTY_PSI_TILE_ROWS).min(k);
let tile_pairs = (row_start..row_end).map(|i| i + 1).sum::<usize>();
let mut entries = Vec::with_capacity(tile_pairs);
for i in row_start..row_end {
for j in 0..=i {
let mut dist2 = 0.0;
for axis in 0..d {
let delta = centers[[i, axis]] - centers[[j, axis]];
dist2 += delta * delta;
}
let (phi, phi_psi, phi_psi_psi) = thin_plate_kernel_psi_triplet_from_distance(
dist2.sqrt(),
spec.length_scale,
d,
)?;
entries.push(ThinPlatePsiTileEntry {
i,
j,
phi,
phi_psi,
phi_psi_psi,
});
}
}
Ok(entries)
})
.collect();
for tile in omega_tiles? {
for entry in tile {
omega[[entry.i, entry.j]] = entry.phi;
omega_psi[[entry.i, entry.j]] = entry.phi_psi;
omega_psi_psi[[entry.i, entry.j]] = entry.phi_psi_psi;
if entry.i != entry.j {
omega[[entry.j, entry.i]] = entry.phi;
omega_psi[[entry.j, entry.i]] = entry.phi_psi;
omega_psi_psi[[entry.j, entry.i]] = entry.phi_psi_psi;
}
}
}
let m_constrained = symmetrize_penalty(&z_kernel.t().dot(&omega).dot(&z_kernel));
let m_psi_constrained = symmetrize_penalty(&z_kernel.t().dot(&omega_psi).dot(&z_kernel));
let m_pp_constrained = symmetrize_penalty(&z_kernel.t().dot(&omega_psi_psi).dot(&z_kernel));
let (v, lambda) = if let Some(frozen) = spec.radial_reparam.as_ref() {
if frozen.nrows() != kernel_cols || frozen.ncols() != kernel_cols {
crate::bail_dim_basis!(
"thin-plate frozen radial reparam shape {:?} does not match radial dimension {}",
frozen.dim(),
kernel_cols
);
}
let v_owned = frozen.to_owned();
let lambda_diag = fast_ab(&fast_atb(&v_owned, &m_constrained), &v_owned);
let mut evals = Array1::<f64>::zeros(kernel_cols);
for i in 0..kernel_cols {
evals[i] = lambda_diag[[i, i]].max(0.0);
}
(v_owned, evals)
} else if kernel_cols == 0 {
(Array2::<f64>::zeros((0, 0)), Array1::<f64>::zeros(0))
} else {
let (mut evals, evecs) =
FaerEigh::eigh(&m_constrained, Side::Lower).map_err(BasisError::LinalgError)?;
for ev in evals.iter_mut() {
if *ev < 0.0 {
*ev = 0.0;
}
}
(evecs, evals)
};
let v_is_frozen = spec.radial_reparam.is_some();
let a_psi = if kernel_cols > 0 {
v.t().dot(&m_psi_constrained).dot(&v)
} else {
Array2::<f64>::zeros((0, 0))
};
let a_pp = if kernel_cols > 0 {
v.t().dot(&m_pp_constrained).dot(&v)
} else {
Array2::<f64>::zeros((0, 0))
};
let s_raw_kernel = Array2::from_diag(&lambda);
let s_raw_psi_kernel = if v_is_frozen {
a_psi.clone()
} else {
let mut diag = Array2::<f64>::zeros((kernel_cols, kernel_cols));
for i in 0..kernel_cols {
diag[[i, i]] = a_psi[[i, i]];
}
diag
};
let s_raw_pp_kernel = if v_is_frozen {
a_pp.clone()
} else {
let mut diag = Array2::<f64>::zeros((kernel_cols, kernel_cols));
for i in 0..kernel_cols {
let mut acc = a_pp[[i, i]];
for k_idx in 0..kernel_cols {
if k_idx == i {
continue;
}
let denom = lambda[i] - lambda[k_idx];
if denom.abs() > 1e-14 {
acc += 2.0 * a_psi[[i, k_idx]].powi(2) / denom;
}
}
diag[[i, i]] = acc;
}
diag
};
let pad = |kernel_block: &Array2<f64>| -> Array2<f64> {
let mut s = Array2::<f64>::zeros((total_cols, total_cols));
if kernel_cols > 0 {
s.slice_mut(s![0..kernel_cols, 0..kernel_cols])
.assign(kernel_block);
}
s
};
let s_raw = pad(&s_raw_kernel);
let s_raw_psi = pad(&s_raw_psi_kernel);
let s_raw_pp = pad(&s_raw_pp_kernel);
let (_, s_norm_psi, s_norm_pp, _c) =
normalize_penaltywith_psi_derivatives(&s_raw, &s_raw_psi, &s_raw_pp);
let s_psi_out = project_penalty_matrix(&s_norm_psi, identifiability_transform);
let s_psi_psi_out = project_penalty_matrix(&s_norm_pp, identifiability_transform);
Ok((s_psi_out, s_psi_psi_out))
}
fn build_thin_plate_scalar_design_psi_derivatives(
data: ArrayView2<'_, f64>,
centers: ArrayView2<'_, f64>,
spec: &ThinPlateBasisSpec,
identifiability_transform: Option<&Array2<f64>>,
workspace: &mut BasisWorkspace,
) -> Result<ScalarDesignPsiDerivatives, BasisError> {
let z_kernel = thin_plate_kernel_constraint_nullspace(centers, &mut workspace.cache)?;
let kernel_cols = z_kernel.ncols();
let kernel_transform = if let Some(v) = spec.radial_reparam.as_ref() {
if v.nrows() != kernel_cols || v.ncols() != kernel_cols {
crate::bail_dim_basis!(
"thin-plate radial reparam shape {:?} does not match radial dimension {}",
v.dim(),
kernel_cols
);
}
fast_ab(&z_kernel, v)
} else {
z_kernel
};
let poly_cols = thin_plate_polynomial_basis_dimension(data.ncols());
let p_after_pad = kernel_cols + poly_cols;
let p_final = identifiability_transform
.map(|zf| zf.ncols())
.unwrap_or(p_after_pad);
build_scalar_design_psi_derivatives_shared(
data,
centers,
None,
p_final,
Some(kernel_transform),
identifiability_transform.cloned(),
poly_cols,
RadialScalarKind::ThinPlate {
length_scale: spec.length_scale,
dim: data.ncols(),
},
0.0,
)
}
pub fn build_thin_plate_basis_log_kappa_derivative(
data: ArrayView2<'_, f64>,
spec: &ThinPlateBasisSpec,
) -> Result<BasisPsiDerivativeResult, BasisError> {
let mut workspace = BasisWorkspace::default();
build_thin_plate_basis_log_kappa_derivativewithworkspace(data, spec, &mut workspace)
}
pub fn build_thin_plate_basis_log_kappa_derivativewithworkspace(
data: ArrayView2<'_, f64>,
spec: &ThinPlateBasisSpec,
workspace: &mut BasisWorkspace,
) -> Result<BasisPsiDerivativeResult, BasisError> {
let mut bundle =
build_thin_plate_basis_log_kappa_derivativeswithworkspace(data, spec, workspace)?;
bundle.first.implicit_operator = bundle.implicit_operator;
Ok(bundle.first)
}
pub fn build_thin_plate_basis_log_kappa_derivatives(
data: ArrayView2<'_, f64>,
spec: &ThinPlateBasisSpec,
) -> Result<BasisPsiDerivativeBundle, BasisError> {
let mut workspace = BasisWorkspace::default();
build_thin_plate_basis_log_kappa_derivativeswithworkspace(data, spec, &mut workspace)
}
pub fn build_thin_plate_basis_log_kappa_derivativeswithworkspace(
data: ArrayView2<'_, f64>,
spec: &ThinPlateBasisSpec,
workspace: &mut BasisWorkspace,
) -> Result<BasisPsiDerivativeBundle, BasisError> {
let base = build_thin_plate_basiswithworkspace(data, spec, workspace)?;
let (centers, identifiability_transform, radial_reparam) = match &base.metadata {
BasisMetadata::ThinPlate {
centers,
identifiability_transform,
radial_reparam,
..
} => (
centers.clone(),
identifiability_transform.clone(),
radial_reparam.clone(),
),
_ => {
crate::bail_invalid_basis!("ThinPlate derivative path expected ThinPlate metadata");
}
};
let mut derivative_spec = spec.clone();
if derivative_spec.radial_reparam.is_none() {
derivative_spec.radial_reparam = radial_reparam;
}
let scalar = build_thin_plate_scalar_design_psi_derivatives(
data,
centers.view(),
&derivative_spec,
identifiability_transform.as_ref(),
workspace,
)?;
let (primary_derivative_opt, primarysecond_derivative_opt) =
build_thin_plate_penalty_psi_derivativeswithworkspace(
centers.view(),
&derivative_spec,
identifiability_transform.as_ref(),
workspace,
)?;
let primary_derivative = primary_derivative_opt;
let primarysecond_derivative = primarysecond_derivative_opt;
let penalties_derivative =
active_thin_plate_penalty_derivatives(&base.penaltyinfo, &primary_derivative)?;
let penaltiessecond_derivative =
active_thin_plate_penalty_derivatives(&base.penaltyinfo, &primarysecond_derivative)?;
Ok(BasisPsiDerivativeBundle {
first: BasisPsiDerivativeResult {
design_derivative: scalar.design_first,
penalties_derivative,
implicit_operator: None,
},
second: BasisPsiSecondDerivativeResult {
designsecond_derivative: scalar.design_second_diag,
penaltiessecond_derivative,
implicit_operator: None,
},
implicit_operator: scalar.implicit_operator,
})
}
pub fn build_thin_plate_basis_log_kappasecond_derivative(
data: ArrayView2<'_, f64>,
spec: &ThinPlateBasisSpec,
) -> Result<BasisPsiSecondDerivativeResult, BasisError> {
let mut workspace = BasisWorkspace::default();
build_thin_plate_basis_log_kappasecond_derivativewithworkspace(data, spec, &mut workspace)
}
pub fn build_thin_plate_basis_log_kappasecond_derivativewithworkspace(
data: ArrayView2<'_, f64>,
spec: &ThinPlateBasisSpec,
workspace: &mut BasisWorkspace,
) -> Result<BasisPsiSecondDerivativeResult, BasisError> {
let mut bundle =
build_thin_plate_basis_log_kappa_derivativeswithworkspace(data, spec, workspace)?;
bundle.second.implicit_operator = bundle.implicit_operator;
Ok(bundle.second)
}
pub fn create_thin_plate_spline_basis_with_knot_count(
data: ArrayView2<f64>,
num_knots: usize,
) -> Result<(ThinPlateSplineBasis, Array2<f64>), BasisError> {
let mut workspace = BasisWorkspace::default();
create_thin_plate_spline_basis_with_knot_count_andworkspace(data, num_knots, &mut workspace)
}
pub fn create_thin_plate_spline_basis_with_knot_count_andworkspace(
data: ArrayView2<f64>,
num_knots: usize,
workspace: &mut BasisWorkspace,
) -> Result<(ThinPlateSplineBasis, Array2<f64>), BasisError> {
let knots = select_thin_plate_knots(data, num_knots)?;
let basis = create_thin_plate_spline_basiswithworkspace(data, knots.view(), workspace)?;
Ok((basis, knots))
}
pub fn apply_sum_to_zero_constraint(
basis_matrix: ArrayView2<f64>,
weights: Option<ArrayView1<f64>>,
) -> Result<(Array2<f64>, Array2<f64>), BasisError> {
let n = basis_matrix.nrows();
let k = basis_matrix.ncols();
if k < 2 {
return Err(BasisError::InsufficientColumnsForConstraint { found: k });
}
let constraintvector = match weights {
Some(w) => {
if w.len() != n {
return Err(BasisError::WeightsDimensionMismatch {
expected: n,
found: w.len(),
});
}
w.to_owned()
}
None => Array1::<f64>::ones(n),
};
let c = basis_matrix.t().dot(&constraintvector);
let mut c_mat = Array2::<f64>::zeros((k, 1));
c_mat.column_mut(0).assign(&c);
let (z, rank) =
rrqr_nullspace_basis(&c_mat, default_rrqr_rank_alpha()).map_err(BasisError::LinalgError)?;
if rank >= k {
return Err(BasisError::ConstraintNullspaceCollapsed {
site: "apply_sum_to_zero_constraint",
cross_rank: rank,
coeff_dim: k,
cross_frobenius: c.iter().map(|v| v * v).sum::<f64>().sqrt(),
constrained_gram_max_eigenvalue: f64::NAN,
constrained_gram_min_eigenvalue: f64::NAN,
spectral_tolerance: f64::NAN,
});
}
if rank == 0 {
return Ok((basis_matrix.to_owned(), Array2::eye(k)));
}
let constrained = fast_ab(&basis_matrix, &z);
Ok((constrained, z))
}
pub fn apply_sum_to_zero_constraint_sparse(
basis_matrix: &SparseColMat<usize, f64>,
weights: Option<ArrayView1<f64>>,
) -> Result<(Array2<f64>, Array2<f64>), BasisError> {
let n = basis_matrix.nrows();
let k = basis_matrix.ncols();
if k < 2 {
return Err(BasisError::InsufficientColumnsForConstraint { found: k });
}
let constraint_weights = match weights {
Some(w) => {
if w.len() != n {
return Err(BasisError::WeightsDimensionMismatch {
expected: n,
found: w.len(),
});
}
w.to_owned()
}
None => Array1::<f64>::ones(n),
};
let mut c = Array1::<f64>::zeros(k);
let (symbolic, values) = basis_matrix.parts();
let col_ptr = symbolic.col_ptr();
let row_idx = symbolic.row_idx();
for col in 0..k {
let mut sum = 0.0;
for idx in col_ptr[col]..col_ptr[col + 1] {
sum += values[idx] * constraint_weights[row_idx[idx]];
}
c[col] = sum;
}
let mut c_mat = Array2::<f64>::zeros((k, 1));
c_mat.column_mut(0).assign(&c);
let (z, rank) =
rrqr_nullspace_basis(&c_mat, default_rrqr_rank_alpha()).map_err(BasisError::LinalgError)?;
if rank >= k {
return Err(BasisError::ConstraintNullspaceCollapsed {
site: "apply_sum_to_zero_constraint_sparse",
cross_rank: rank,
coeff_dim: k,
cross_frobenius: c.iter().map(|v| v * v).sum::<f64>().sqrt(),
constrained_gram_max_eigenvalue: f64::NAN,
constrained_gram_min_eigenvalue: f64::NAN,
spectral_tolerance: f64::NAN,
});
}
if rank == 0 {
let mut dense_b = Array2::<f64>::zeros((n, k));
for col in 0..k {
for idx in col_ptr[col]..col_ptr[col + 1] {
dense_b[[row_idx[idx], col]] = values[idx];
}
}
return Ok((dense_b, Array2::eye(k)));
}
let kc = z.ncols();
let mut constrained = Array2::<f64>::zeros((n, kc));
for out_col in 0..kc {
let z_col = z.column(out_col);
let mut dst = constrained.column_mut(out_col);
for src_col in 0..k {
let coeff = z_col[src_col];
if coeff == 0.0 {
continue;
}
for idx in col_ptr[src_col]..col_ptr[src_col + 1] {
dst[row_idx[idx]] += coeff * values[idx];
}
}
}
Ok((constrained, z))
}
pub fn applyweighted_orthogonality_constraint(
basis_matrix: ArrayView2<f64>,
constraint_matrix: ArrayView2<f64>,
weights: Option<ArrayView1<f64>>,
) -> Result<(Array2<f64>, Array2<f64>), BasisError> {
let n = basis_matrix.nrows();
let k = basis_matrix.ncols();
if constraint_matrix.nrows() != n {
return Err(BasisError::ConstraintMatrixRowMismatch {
basisrows: n,
constraintrows: constraint_matrix.nrows(),
});
}
if k == 0 {
return Err(BasisError::InsufficientColumnsForConstraint { found: 0 });
}
let q = constraint_matrix.ncols();
if q == 0 {
return Ok((basis_matrix.to_owned(), Array2::eye(k)));
}
let mut weighted_constraints = constraint_matrix.to_owned();
if let Some(w) = weights {
if w.len() != n {
return Err(BasisError::WeightsDimensionMismatch {
expected: n,
found: w.len(),
});
}
for (mut row, &weight) in weighted_constraints.axis_iter_mut(Axis(0)).zip(w.iter()) {
row *= weight;
}
}
let constraint_cross = basis_matrix.t().dot(&weighted_constraints); let gram = fast_ata(&basis_matrix);
let transform = orthogonality_transform_from_cross_and_gram(&constraint_cross, &gram)?;
let basis_orthonormal = fast_ab(&basis_matrix, &transform);
Ok((basis_orthonormal, transform))
}
pub fn compute_greville_abscissae(
knot_vector: &Array1<f64>,
degree: usize,
) -> Result<Array1<f64>, BasisError> {
let n_knots = knot_vector.len();
if degree == 0 {
let n_basis = n_knots.saturating_sub(1);
if n_basis == 0 {
return Err(BasisError::InsufficientColumnsForConstraint { found: 0 });
}
let mut g = Array1::<f64>::zeros(n_basis);
for j in 0..n_basis {
g[j] = 0.5 * (knot_vector[j] + knot_vector[j + 1]);
}
return Ok(g);
}
if n_knots <= degree + 1 {
return Err(BasisError::InsufficientColumnsForConstraint {
found: n_knots.saturating_sub(degree + 1),
});
}
let n_basis = n_knots - degree - 1;
let mut g = Array1::<f64>::zeros(n_basis);
let d_inv = 1.0 / (degree as f64);
for j in 0..n_basis {
let mut sum = 0.0;
for k in 1..=degree {
sum += knot_vector[j + k];
}
g[j] = sum * d_inv;
}
let g_min = g.iter().cloned().fold(f64::INFINITY, f64::min);
let g_max = g.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
if (g_max - g_min) < 1e-10 {
return Err(BasisError::DegenerateKnots);
}
Ok(g)
}
pub fn compute_geometric_constraint_transform(
knot_vector: &Array1<f64>,
degree: usize,
penalty_order: usize,
) -> Result<(Array2<f64>, Array2<f64>), BasisError> {
let g = compute_greville_abscissae(knot_vector, degree)?;
let k = g.len();
if k < 3 {
return Err(BasisError::InsufficientColumnsForConstraint { found: k });
}
let mut c_geom = Array2::<f64>::zeros((2, k));
for j in 0..k {
c_geom[[0, j]] = 1.0;
c_geom[[1, j]] = g[j];
}
let g_mean = g.mean().unwrap_or(0.0);
let gvar = g.iter().map(|&x| (x - g_mean).powi(2)).sum::<f64>() / (k as f64);
let g_std = gvar.sqrt().max(1e-10);
for j in 0..k {
c_geom[[1, j]] = (c_geom[[1, j]] - g_mean) / g_std;
}
let (z, rank) = rrqr_nullspace_basis(&c_geom.t(), default_rrqr_rank_alpha())
.map_err(BasisError::LinalgError)?;
if rank >= k {
return Err(BasisError::ConstraintNullspaceCollapsed {
site: "compute_geometric_constraint_transform",
cross_rank: rank,
coeff_dim: k,
cross_frobenius: f64::NAN,
constrained_gram_max_eigenvalue: f64::NAN,
constrained_gram_min_eigenvalue: f64::NAN,
spectral_tolerance: f64::NAN,
});
}
if z.ncols() == 0 {
return Err(BasisError::ConstraintNullspaceCollapsed {
site: "compute_geometric_constraint_transform",
cross_rank: 0,
coeff_dim: k,
cross_frobenius: f64::NAN,
constrained_gram_max_eigenvalue: f64::NAN,
constrained_gram_min_eigenvalue: f64::NAN,
spectral_tolerance: f64::NAN,
});
}
let s_raw = create_difference_penalty_matrix(k, penalty_order, Some(g.view()))?;
let s_constrained = {
let zt_s = fast_atb(&z, &s_raw);
fast_ab(&zt_s, &z)
};
Ok((z, s_constrained))
}
#[derive(Debug, Clone)]
pub struct AutoBSplineKnots {
pub knots: Array1<f64>,
pub degree: usize,
pub num_internal_knots: usize,
pub shrunk: bool,
}
pub fn auto_knot_vector_1d_quantile(
data: ArrayView1<'_, f64>,
num_internal_knots: usize,
degree: usize,
) -> Result<AutoBSplineKnots, BasisError> {
let n = data.len();
let Some((eff_knots, eff_degree, shrunk)) =
auto_shrink_bspline_config(n, num_internal_knots, degree)
else {
crate::bail_invalid_basis!(
"auto-knot placement needs at least 2 finite evaluation points (got n={n}); \
cannot fit even a linear B-spline",
);
};
let knots = internal::generate_full_knot_vector_quantile(data, eff_knots, eff_degree)?;
Ok(AutoBSplineKnots {
knots,
degree: eff_degree,
num_internal_knots: eff_knots,
shrunk,
})
}
pub fn clamped_knot_vector_from_internal_positions(
data_range: (f64, f64),
internal_positions: &[f64],
degree: usize,
) -> Result<Array1<f64>, BasisError> {
let (minval, maxval) = data_range;
if !(minval.is_finite() && maxval.is_finite()) {
crate::bail_invalid_basis!(
"explicit knots require a finite data range, got ({minval:.6e}, {maxval:.6e})"
);
}
if minval >= maxval {
return Err(BasisError::InvalidRange(minval, maxval));
}
let scale = (maxval - minval).abs().max(1.0);
let tol = 1e-12 * scale;
let mut interior: Vec<f64> = Vec::with_capacity(internal_positions.len());
for &k in internal_positions {
if !k.is_finite() {
crate::bail_invalid_basis!("explicit knot position {k:.6e} is not finite");
}
if k <= minval + tol || k >= maxval - tol {
crate::bail_invalid_basis!(
"explicit internal knot {k:.6e} must lie strictly inside the data range \
({minval:.6e}, {maxval:.6e}); boundary knots are added automatically"
);
}
interior.push(k);
}
interior.sort_by(f64::total_cmp);
for w in interior.windows(2) {
if (w[1] - w[0]).abs() <= tol {
crate::bail_invalid_basis!(
"explicit internal knots must be strictly increasing; \
found a duplicate/near-duplicate near {:.6e}",
w[0]
);
}
}
let total_knots = interior.len() + 2 * (degree + 1);
let mut knots = Vec::with_capacity(total_knots);
for _ in 0..=degree {
knots.push(minval);
}
knots.extend_from_slice(&interior);
for _ in 0..=degree {
knots.push(maxval);
}
Ok(Array::from_vec(knots))
}
pub fn auto_centers_1d_equal_mass(
data: ArrayView1<'_, f64>,
num_centers: usize,
) -> Result<Array1<f64>, BasisError> {
let column = data.to_owned().insert_axis(Axis(1));
let centers = select_equal_mass_centers(column.view(), num_centers)?;
let mut flat: Vec<f64> = centers.column(0).iter().copied().collect();
flat.sort_by(f64::total_cmp);
Ok(Array1::from_vec(flat))
}
pub(crate) mod internal {
use super::*;
#[derive(Clone, Debug, Default)]
pub struct BsplineScratch {
left: Vec<f64>,
right: Vec<f64>,
n: Vec<f64>,
}
impl BsplineScratch {
#[inline]
pub fn new(degree: usize) -> Self {
let len = degree + 1;
Self {
left: vec![0.0; len],
right: vec![0.0; len],
n: vec![0.0; len],
}
}
#[inline]
pub(super) fn ensure_degree(&mut self, degree: usize) {
let len = degree + 1;
if self.left.len() != len {
self.left.resize(len, 0.0);
self.right.resize(len, 0.0);
self.n.resize(len, 0.0);
}
}
}
pub(super) fn generate_full_knot_vector(
data_range: (f64, f64),
num_internal_knots: usize,
degree: usize,
) -> Result<Array1<f64>, BasisError> {
let (minval, maxval) = data_range;
if minval == maxval {
return Err(BasisError::DegenerateRange(num_internal_knots));
}
let h = (maxval - minval) / (num_internal_knots as f64 + 1.0);
let total_knots = num_internal_knots + 2 * (degree + 1);
let mut knots = Vec::with_capacity(total_knots);
for _ in 0..=degree {
knots.push(minval);
}
for i in 1..=num_internal_knots {
knots.push(minval + i as f64 * h);
}
for _ in 0..=degree {
knots.push(maxval);
}
Ok(Array::from_vec(knots))
}
pub(super) fn generate_full_knot_vector_quantile(
data: ArrayView1<'_, f64>,
num_internal_knots: usize,
degree: usize,
) -> Result<Array1<f64>, BasisError> {
if data.is_empty() {
crate::bail_invalid_basis!("cannot generate quantile knots from empty data");
}
if data.iter().any(|x| !x.is_finite()) {
crate::bail_invalid_basis!("quantile knot placement requires finite data");
}
let min_required_for_interior = num_internal_knots.saturating_add(2);
let min_required_for_degree = degree.saturating_add(1);
let min_required = min_required_for_interior.max(min_required_for_degree);
if data.len() < min_required {
crate::bail_invalid_basis!(
"auto-knot placement requires at least {min} evaluation point(s) for \
degree={deg} with {ki} interior knot(s) (got n={n}). Either provide \
more points, reduce the requested interior-knot count, or supply an \
explicit clamped knot vector.",
min = min_required,
deg = degree,
ki = num_internal_knots,
n = data.len(),
);
}
let mut sorted: Vec<f64> = data.iter().copied().collect();
sorted.sort_by(f64::total_cmp);
let minval = sorted[0];
let maxval = *sorted.last().unwrap_or(&minval);
if minval == maxval {
return Err(BasisError::DegenerateRange(num_internal_knots));
}
let scale = (maxval - minval).abs().max(1.0);
let tol = 1e-12 * scale;
let total_knots = num_internal_knots + 2 * (degree + 1);
let mut knots = Vec::with_capacity(total_knots);
for _ in 0..=degree {
knots.push(minval);
}
if num_internal_knots > 0 {
let mut support = Vec::with_capacity(sorted.len());
let mut last: Option<f64> = None;
for &x in &sorted {
if x <= minval + tol || x >= maxval - tol {
continue;
}
if last.map(|prev| (x - prev).abs() <= tol).unwrap_or(false) {
continue;
}
support.push(x);
last = Some(x);
}
if support.is_empty() {
crate::bail_invalid_basis!(
"quantile knot placement requires distinct interior support between {:.6e} and {:.6e}",
minval,
maxval
);
}
let n = support.len();
let mut prev_q = minval;
for j in 1..=num_internal_knots {
let p = j as f64 / (num_internal_knots + 1) as f64;
let pos = p * (n.saturating_sub(1) as f64);
let lo = pos.floor() as usize;
let hi = pos.ceil() as usize;
let frac = pos - lo as f64;
let q = if lo == hi {
support[lo]
} else {
support[lo] * (1.0 - frac) + support[hi] * frac
};
let q = q.clamp(minval, maxval);
if q <= prev_q + tol || q >= maxval - tol {
crate::bail_invalid_basis!(
"quantile knot placement produced a non-interior knot at index {}: {:.6e}",
j - 1,
q
);
}
knots.push(q);
prev_q = q;
}
}
for _ in 0..=degree {
knots.push(maxval);
}
Ok(Array::from_vec(knots))
}
#[inline]
pub(super) fn evaluate_splines_at_point_into(
x: f64,
degree: usize,
knots: ArrayView1<f64>,
basisvalues: &mut [f64],
scratch: &mut BsplineScratch,
) {
match degree {
3 => evaluate_splines_at_point_fixed::<3>(x, knots, basisvalues, scratch),
2 => evaluate_splines_at_point_fixed::<2>(x, knots, basisvalues, scratch),
1 => evaluate_splines_at_point_fixed::<1>(x, knots, basisvalues, scratch),
_ => evaluate_splines_at_point_dynamic(x, degree, knots, basisvalues, scratch),
}
}
#[inline]
fn evaluate_spline_local_values(
x: f64,
degree: usize,
knots: ArrayView1<f64>,
scratch: &mut BsplineScratch,
) -> (usize, usize) {
let num_knots = knots.len();
let num_basis = num_knots - degree - 1;
scratch.ensure_degree(degree);
scratch.n.fill(0.0);
scratch.left.fill(0.0);
scratch.right.fill(0.0);
let x_eval = x.clamp(knots[degree], knots[num_basis]);
let mu = {
if x_eval >= knots[num_basis] {
num_basis - 1
} else if x_eval < knots[degree] {
degree
} else {
let slice = knots
.as_slice()
.expect("B-spline knot vector is contiguous");
degree + slice[degree + 1..=num_basis].partition_point(|&k| k <= x_eval)
}
};
let left = &mut scratch.left;
let right = &mut scratch.right;
let n = &mut scratch.n;
n[0] = 1.0;
for d in 1..=degree {
left[d] = x_eval - knots[mu + 1 - d];
right[d] = knots[mu + d] - x_eval;
let mut saved = 0.0;
for r in 0..d {
let den = right[r + 1] + left[d - r];
let temp = if den.abs() > 1e-12 { n[r] / den } else { 0.0 };
n[r] = saved + right[r + 1] * temp;
saved = left[d - r] * temp;
}
n[d] = saved;
}
(mu, num_basis)
}
#[inline]
fn evaluate_splines_at_point_fixed<const DEGREE: usize>(
x: f64,
knots: ArrayView1<f64>,
basisvalues: &mut [f64],
scratch: &mut BsplineScratch,
) {
let (mu, num_basis) = evaluate_spline_local_values(x, DEGREE, knots, scratch);
assert_eq!(basisvalues.len(), num_basis);
let n = &scratch.n;
basisvalues.fill(0.0);
for i in 0..=DEGREE {
let gi = mu as isize + i as isize - DEGREE as isize;
if gi >= 0 {
let global_idx = gi as usize;
if global_idx < num_basis {
basisvalues[global_idx] = n[i];
}
}
}
}
#[inline]
fn evaluate_splines_at_point_dynamic(
x: f64,
degree: usize,
knots: ArrayView1<f64>,
basisvalues: &mut [f64],
scratch: &mut BsplineScratch,
) {
let (mu, num_basis) = evaluate_spline_local_values(x, degree, knots, scratch);
assert_eq!(basisvalues.len(), num_basis);
let n = &scratch.n;
basisvalues.fill(0.0);
for i in 0..=degree {
let gi = mu as isize + i as isize - degree as isize;
if gi >= 0 {
let global_idx = gi as usize;
if global_idx < num_basis {
basisvalues[global_idx] = n[i];
}
}
}
}
#[inline]
pub(super) fn evaluate_splines_sparse_into(
x: f64,
degree: usize,
knots: ArrayView1<f64>,
values: &mut [f64],
scratch: &mut BsplineScratch,
) -> usize {
let (mu, _) = evaluate_spline_local_values(x, degree, knots, scratch);
assert_eq!(values.len(), degree + 1);
let n = &scratch.n;
for i in 0..=degree {
values[i] = n[i];
}
mu.saturating_sub(degree)
}
}
pub struct SplineScratch {
inner: internal::BsplineScratch,
local: Vec<f64>,
left_inner: internal::BsplineScratch,
left_local: Vec<f64>,
left_offsets: Vec<f64>,
}
impl SplineScratch {
pub fn new(degree: usize) -> Self {
Self {
inner: internal::BsplineScratch::new(degree),
local: Vec::new(),
left_inner: internal::BsplineScratch::new(degree),
left_local: Vec::new(),
left_offsets: Vec::new(),
}
}
}
pub fn evaluate_bspline_basis_scalar(
x: f64,
knot_vector: ArrayView1<f64>,
degree: usize,
out: &mut [f64],
scratch: &mut SplineScratch,
) -> Result<(), BasisError> {
validate_knots_for_degree(knot_vector, degree)?;
let num_basis = knot_vector.len() - degree - 1;
if out.len() != num_basis {
return Err(BasisError::InvalidKnotVector(format!(
"Output buffer length {} does not match number of basis functions {}",
out.len(),
num_basis
)));
}
internal::evaluate_splines_at_point_into(x, degree, knot_vector, out, &mut scratch.inner);
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeriodicBSplineBasisSpec {
pub degree: usize,
pub num_basis: usize,
pub period: f64,
pub origin: f64,
pub penalty_order: usize,
}
impl PeriodicBSplineBasisSpec {
pub fn new(
degree: usize,
num_basis: usize,
period: f64,
origin: f64,
penalty_order: usize,
) -> Self {
Self {
degree,
num_basis,
period,
origin,
penalty_order,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeriodicSplineCurve {
pub spec: PeriodicBSplineBasisSpec,
pub coefficients: Array2<f64>,
}
impl PeriodicSplineCurve {
pub fn ambient_dim(&self) -> usize {
self.coefficients.ncols()
}
pub fn evaluate(&self, u: ArrayView1<'_, f64>) -> Result<Array2<f64>, BasisError> {
if self.coefficients.nrows() != self.spec.num_basis {
crate::bail_dim_basis!(
"curve coefficient rows ({}) must equal periodic basis size ({})",
self.coefficients.nrows(),
self.spec.num_basis
);
}
let basis = build_periodic_bspline_basis_1d(u, &self.spec)?;
Ok(basis.dot(&self.coefficients))
}
pub fn evaluate_derivative(&self, u: ArrayView1<'_, f64>) -> Result<Array2<f64>, BasisError> {
if self.coefficients.nrows() != self.spec.num_basis {
crate::bail_dim_basis!(
"curve coefficient rows ({}) must equal periodic basis size ({})",
self.coefficients.nrows(),
self.spec.num_basis
);
}
let t = u.to_owned().insert_axis(Axis(1));
let derivative = periodic_bspline_first_derivative_nd(
t.view(),
(self.spec.origin, self.spec.origin + self.spec.period),
self.spec.degree,
self.spec.num_basis,
)?
.index_axis(Axis(2), 0)
.to_owned();
Ok(derivative.dot(&self.coefficients))
}
}
fn validate_periodic_bspline_spec(spec: &PeriodicBSplineBasisSpec) -> Result<(), BasisError> {
if spec.degree < 1 {
return Err(BasisError::InvalidDegree(spec.degree));
}
if spec.num_basis < spec.degree + 1 {
crate::bail_invalid_basis!(
"periodic B-spline basis requires num_basis >= degree + 1 (got num_basis={}, degree={})",
spec.num_basis,
spec.degree
);
}
if !spec.period.is_finite() || spec.period <= 0.0 {
crate::bail_invalid_basis!(
"periodic B-spline period must be finite and positive, got {}",
spec.period
);
}
if !spec.origin.is_finite() {
crate::bail_invalid_basis!(
"periodic B-spline origin must be finite, got {}",
spec.origin
);
}
if spec.penalty_order == 0 || spec.penalty_order >= spec.num_basis {
return Err(BasisError::InvalidPenaltyOrder {
order: spec.penalty_order,
num_basis: spec.num_basis,
});
}
Ok(())
}
#[inline]
fn wrap_periodic_phase(u: f64, origin: f64, period: f64) -> f64 {
let wrapped = (u - origin).rem_euclid(period);
if wrapped >= period { 0.0 } else { wrapped }
}
fn cardinal_bspline_value(x: f64, degree: usize) -> f64 {
if degree == 0 {
return if (0.0..1.0).contains(&x) { 1.0 } else { 0.0 };
}
if x <= 0.0 || x >= (degree + 1) as f64 {
return 0.0;
}
let p = degree as f64;
(x / p) * cardinal_bspline_value(x, degree - 1)
+ (((degree + 1) as f64 - x) / p) * cardinal_bspline_value(x - 1.0, degree - 1)
}
fn fill_periodic_bspline_unnormalized_value_row(
u: f64,
origin: f64,
period: f64,
degree: usize,
row: &mut [f64],
) -> f64 {
let m = row.len();
let m_f = m as f64;
let h = period / m_f;
let t = wrap_periodic_phase(u, origin, period) / h;
let mut rowsum = 0.0_f64;
for (col, value_slot) in row.iter_mut().enumerate() {
let base = t - col as f64;
let k_min = ((-base) / m_f).floor() as isize - 1;
let k_max = (((degree + 1) as f64 - base) / m_f).ceil() as isize + 1;
let mut value = 0.0_f64;
for k in k_min..=k_max {
value += cardinal_bspline_value(base + (k as f64) * m_f, degree);
}
*value_slot = value;
rowsum += value;
}
rowsum
}
fn fill_periodic_bspline_unnormalized_derivative_row(
u: f64,
origin: f64,
period: f64,
degree: usize,
row: &mut [f64],
) -> f64 {
let m = row.len();
let m_f = m as f64;
let h = period / m_f;
let tau = wrap_periodic_phase(u, origin, period) / h;
let mut rowsum_derivative = 0.0_f64;
for (col, value_slot) in row.iter_mut().enumerate() {
let base = tau - col as f64;
let k_min = ((-base) / m_f).floor() as isize - 1;
let k_max = (((degree + 1) as f64 - base) / m_f).ceil() as isize + 1;
let mut value = 0.0_f64;
for k in k_min..=k_max {
let x_arg = base + (k as f64) * m_f;
value += cardinal_bspline_value(x_arg, degree - 1)
- cardinal_bspline_value(x_arg - 1.0, degree - 1);
}
let derivative = value / h;
*value_slot = derivative;
rowsum_derivative += derivative;
}
rowsum_derivative
}
pub fn build_periodic_bspline_basis_1d(
u: ArrayView1<'_, f64>,
spec: &PeriodicBSplineBasisSpec,
) -> Result<Array2<f64>, BasisError> {
validate_periodic_bspline_spec(spec)?;
if u.iter().any(|v| !v.is_finite()) {
crate::bail_invalid_basis!("periodic B-spline inputs must all be finite");
}
let n = u.len();
let m = spec.num_basis;
let mut out = Array2::<f64>::zeros((n, m));
let mut value_row = vec![0.0_f64; m];
for (row_idx, &ui) in u.iter().enumerate() {
let rowsum = fill_periodic_bspline_unnormalized_value_row(
ui,
spec.origin,
spec.period,
spec.degree,
&mut value_row,
);
if !rowsum.is_finite() || rowsum <= 0.0 {
crate::bail_invalid_basis!(
"periodic B-spline row has non-positive rowsum at row {row_idx}: {rowsum}"
);
}
for col in 0..m {
out[[row_idx, col]] = value_row[col] / rowsum;
}
}
Ok(out)
}
fn solve_spd_cholesky(a: Array2<f64>, b: &Array2<f64>) -> Result<Array2<f64>, BasisError> {
let n = a.nrows();
if a.ncols() != n || b.nrows() != n {
crate::bail_dim_basis!(
"normal-equation solve shape mismatch: A is {}x{}, B is {}x{}",
a.nrows(),
a.ncols(),
b.nrows(),
b.ncols()
);
}
let mut jitter = 0.0_f64;
for attempt in 0..8 {
let mut l = a.clone();
if jitter > 0.0 {
for i in 0..n {
l[[i, i]] += jitter;
}
}
let mut ok = true;
for i in 0..n {
for j in 0..=i {
let mut sum = l[[i, j]];
for k in 0..j {
sum -= l[[i, k]] * l[[j, k]];
}
if i == j {
if sum <= 0.0 || !sum.is_finite() {
ok = false;
break;
}
l[[i, j]] = sum.sqrt();
} else {
l[[i, j]] = sum / l[[j, j]];
}
}
if !ok {
break;
}
for j in (i + 1)..n {
l[[i, j]] = 0.0;
}
}
if ok {
let mut y = Array2::<f64>::zeros(b.raw_dim());
for i in 0..n {
for rhs in 0..b.ncols() {
let mut sum = b[[i, rhs]];
for k in 0..i {
sum -= l[[i, k]] * y[[k, rhs]];
}
y[[i, rhs]] = sum / l[[i, i]];
}
}
let mut x = Array2::<f64>::zeros(b.raw_dim());
for i_rev in 0..n {
let i = n - 1 - i_rev;
for rhs in 0..b.ncols() {
let mut sum = y[[i, rhs]];
for k in (i + 1)..n {
sum -= l[[k, i]] * x[[k, rhs]];
}
x[[i, rhs]] = sum / l[[i, i]];
}
}
return Ok(x);
}
let diag_scale = (0..n)
.map(|i| a[[i, i]].abs())
.fold(0.0_f64, f64::max)
.max(1.0);
jitter = if attempt == 0 {
1e-12 * diag_scale
} else {
jitter * 10.0
};
}
Err(BasisError::InvalidInput(
"periodic spline normal equations were not positive definite even after jitter".to_string(),
))
}
pub fn fit_periodic_bspline_curve(
u: ArrayView1<'_, f64>,
y: ArrayView2<'_, f64>,
spec: &PeriodicBSplineBasisSpec,
smoothing_lambda: f64,
) -> Result<PeriodicSplineCurve, BasisError> {
validate_periodic_bspline_spec(spec)?;
if y.nrows() != u.len() {
crate::bail_dim_basis!(
"periodic curve fit requires y rows ({}) to match u length ({})",
y.nrows(),
u.len()
);
}
if y.ncols() == 0 {
crate::bail_invalid_basis!(
"periodic curve fit requires at least one ambient output column"
);
}
if !smoothing_lambda.is_finite() || smoothing_lambda < 0.0 {
crate::bail_invalid_basis!(
"smoothing_lambda must be finite and nonnegative, got {smoothing_lambda}"
);
}
if y.iter().any(|v| !v.is_finite()) {
crate::bail_invalid_basis!("periodic curve outputs must all be finite");
}
let basis = build_periodic_bspline_basis_1d(u, spec)?;
let mut lhs = basis.t().dot(&basis);
if smoothing_lambda > 0.0 {
let penalty = create_cyclic_difference_penalty_matrix(spec.num_basis, spec.penalty_order)?;
lhs = lhs + smoothing_lambda * penalty;
}
let diag_scale = (0..lhs.nrows())
.map(|i| lhs[[i, i]].abs())
.fold(0.0_f64, f64::max)
.max(1.0);
for i in 0..lhs.nrows() {
lhs[[i, i]] += 1e-12 * diag_scale;
}
let rhs = basis.t().dot(&y);
let coefficients = solve_spd_cholesky(lhs, &rhs)?;
Ok(PeriodicSplineCurve {
spec: spec.clone(),
coefficients,
})
}
pub fn evaluate_mspline_scalar(
x: f64,
knot_vector: ArrayView1<f64>,
degree: usize,
out: &mut [f64],
scratch: &mut SplineScratch,
) -> Result<(), BasisError> {
validate_knots_for_degree(knot_vector, degree)?;
validate_mspline_normalization_spans(knot_vector, degree)?;
let num_basis = knot_vector.len() - degree - 1;
if out.len() != num_basis {
crate::bail_dim_basis!(
"M-spline output buffer length {} does not match basis size {}",
out.len(),
num_basis
);
}
let left = knot_vector[degree];
let right = knot_vector[num_basis];
if x < left || x > right {
out.fill(0.0);
return Ok(());
}
out.fill(0.0);
if scratch.local.len() < degree + 1 {
scratch.local.resize(degree + 1, 0.0);
}
let local = &mut scratch.local[..degree + 1];
local.fill(0.0);
let start =
internal::evaluate_splines_sparse_into(x, degree, knot_vector, local, &mut scratch.inner);
let order = (degree + 1) as f64;
for (offset, &b) in local.iter().enumerate() {
let i = start + offset;
if i >= num_basis {
continue;
}
let span = knot_vector[i + degree + 1] - knot_vector[i];
out[i] = b * (order / span);
}
Ok(())
}
pub fn evaluate_ispline_scalarwith_scratch(
x: f64,
knot_vector: ArrayView1<f64>,
degree: usize,
out: &mut [f64],
scratch: &mut SplineScratch,
) -> Result<(), BasisError> {
let bs_degree = 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_basis = knot_vector.len() - bs_degree - 1;
let num_ispline_basis = num_bspline_basis.saturating_sub(1);
if out.len() != num_ispline_basis {
crate::bail_dim_basis!(
"I-spline output buffer length {} does not match basis size {}",
out.len(),
num_ispline_basis
);
}
let left = knot_vector[bs_degree];
let right = knot_vector[num_bspline_basis];
let support = bs_degree + 1;
if x < left {
out.fill(0.0);
return Ok(());
}
if x >= right {
if scratch.left_local.len() < support {
scratch.left_local.resize(support, 0.0);
}
if scratch.left_offsets.len() < num_bspline_basis {
scratch.left_offsets.resize(num_bspline_basis, 0.0);
}
scratch.left_offsets[..num_bspline_basis].fill(0.0);
let left_local = &mut scratch.left_local[..support];
left_local.fill(0.0);
scratch.left_inner.ensure_degree(bs_degree);
let left_start = internal::evaluate_splines_sparse_into(
left,
bs_degree,
knot_vector,
left_local,
&mut scratch.left_inner,
);
let left_offsets = &mut scratch.left_offsets[..num_bspline_basis];
let mut left_running = 0.0_f64;
for offset in (0..support).rev() {
let j = left_start + offset;
if j >= num_bspline_basis {
continue;
}
left_running += left_local[offset];
left_offsets[j] = left_running;
}
for j in 1..num_bspline_basis {
let value = 1.0 - left_offsets[j];
out[j - 1] = if value.abs() <= 1e-15 { 0.0 } else { value };
}
return Ok(());
}
out.fill(0.0);
if scratch.local.len() < support {
scratch.local.resize(support, 0.0);
}
scratch.local[..support].fill(0.0);
scratch.inner.ensure_degree(bs_degree);
let local = &mut scratch.local[..support];
let start = internal::evaluate_splines_sparse_into(
x,
bs_degree,
knot_vector,
local,
&mut scratch.inner,
);
let total = local.iter().copied().sum::<f64>();
let lead_end = start.min(num_bspline_basis);
if lead_end > 1 {
out[..(lead_end - 1)].fill(total);
}
let mut running = 0.0f64;
for offset in (0..support).rev() {
let j = start + offset;
if j >= num_bspline_basis {
continue;
}
running += local[offset];
if j > 0 {
out[j - 1] = running;
}
}
if scratch.left_local.len() < support {
scratch.left_local.resize(support, 0.0);
}
if scratch.left_offsets.len() < num_bspline_basis {
scratch.left_offsets.resize(num_bspline_basis, 0.0);
}
scratch.left_offsets[..num_bspline_basis].fill(0.0);
let left_local = &mut scratch.left_local[..support];
left_local.fill(0.0);
scratch.left_inner.ensure_degree(bs_degree);
let left_start = internal::evaluate_splines_sparse_into(
left,
bs_degree,
knot_vector,
left_local,
&mut scratch.left_inner,
);
let left_offsets = &mut scratch.left_offsets[..num_bspline_basis];
let mut left_running = 0.0_f64;
for offset in (0..support).rev() {
let j = left_start + offset;
if j >= num_bspline_basis {
continue;
}
left_running += left_local[offset];
left_offsets[j] = left_running;
}
for j in 1..num_bspline_basis {
let out_idx = j - 1;
out[out_idx] -= left_offsets[j];
if out[out_idx].abs() <= 1e-15 {
out[out_idx] = 0.0;
}
}
Ok(())
}
pub fn create_ispline_derivative_dense(
data: ArrayView1<'_, f64>,
knot_vector: &Array1<f64>,
degree: usize,
derivative_order: usize,
) -> Result<Array2<f64>, BasisError> {
if derivative_order == 0 {
let (basis_arc, _) = create_basis::<Dense>(
data,
KnotSource::Provided(knot_vector.view()),
degree,
BasisOptions::i_spline(),
)?;
return Ok(basis_arc.as_ref().clone());
}
let bs_degree = degree
.checked_add(1)
.ok_or_else(|| BasisError::InvalidInput("I-spline degree overflow".to_string()))?;
if derivative_order > bs_degree {
let num_bspline_basis = knot_vector.len().saturating_sub(bs_degree + 1);
let num_ispline_basis = num_bspline_basis.saturating_sub(1);
return Ok(Array2::zeros((data.len(), num_ispline_basis)));
}
let num_bspline_cols = knot_vector.len().saturating_sub(bs_degree + 1);
let db = match derivative_order {
1 => {
let (db_arc, _) = create_basis::<Dense>(
data,
KnotSource::Provided(knot_vector.view()),
bs_degree,
BasisOptions::first_derivative(),
)?;
db_arc.as_ref().clone()
}
2 => {
let (db_arc, _) = create_basis::<Dense>(
data,
KnotSource::Provided(knot_vector.view()),
bs_degree,
BasisOptions::second_derivative(),
)?;
db_arc.as_ref().clone()
}
3 => {
let mut db = Array2::<f64>::zeros((data.len(), num_bspline_cols));
for (row_idx, &x) in data.iter().enumerate() {
let row = db.slice_mut(s![row_idx, ..]).into_slice().ok_or_else(|| {
BasisError::InvalidInput(
"I-spline derivative row is not contiguous".to_string(),
)
})?;
evaluate_bsplinethird_derivative_scalar(x, knot_vector.view(), bs_degree, row)?;
}
db
}
4 => {
let mut db = Array2::<f64>::zeros((data.len(), num_bspline_cols));
for (row_idx, &x) in data.iter().enumerate() {
let row = db.slice_mut(s![row_idx, ..]).into_slice().ok_or_else(|| {
BasisError::InvalidInput(
"I-spline derivative row is not contiguous".to_string(),
)
})?;
evaluate_bspline_fourth_derivative_scalar(x, knot_vector.view(), bs_degree, row)?;
}
db
}
other => {
crate::bail_invalid_basis!(
"I-spline derivative supports orders 1..=4; got order={other}"
);
}
};
let num_ispline_cols = num_bspline_cols.saturating_sub(1);
if num_ispline_cols == 0 {
return Ok(Array2::zeros((data.len(), 0)));
}
let mut out = Array2::<f64>::zeros((data.len(), num_ispline_cols));
for i in 0..data.len() {
let mut running = 0.0_f64;
for j in (1..num_bspline_cols).rev() {
let term = db[[i, j]];
if term.is_finite() {
running += term;
}
out[[i, j - 1]] = running;
}
}
for val in out.iter_mut() {
if val.abs() <= 1e-12 {
*val = 0.0;
}
}
Ok(out)
}
pub fn evaluate_ispline_scalar(
x: f64,
knot_vector: ArrayView1<f64>,
degree: usize,
out: &mut [f64],
) -> Result<(), BasisError> {
let bs_degree = degree
.checked_add(1)
.ok_or_else(|| BasisError::InvalidInput("I-spline degree overflow".to_string()))?;
let mut scratch = SplineScratch::new(bs_degree);
evaluate_ispline_scalarwith_scratch(x, knot_vector, degree, out, &mut scratch)
}
pub fn evaluate_bspline_derivative_scalar(
x: f64,
knot_vector: ArrayView1<f64>,
degree: usize,
out: &mut [f64],
) -> Result<(), BasisError> {
if degree < 1 {
return Err(BasisError::InvalidDegree(degree));
}
let num_basis_lower = knot_vector.len().saturating_sub(degree);
let mut lower_basis = vec![0.0; num_basis_lower];
let mut lower_scratch = internal::BsplineScratch::new(degree.saturating_sub(1));
evaluate_bspline_derivative_scalar_into(
x,
knot_vector,
degree,
out,
&mut lower_basis,
&mut lower_scratch,
)
}
pub fn evaluate_bspline_derivative_scalar_into(
x: f64,
knot_vector: ArrayView1<f64>,
degree: usize,
out: &mut [f64],
lower_basis: &mut [f64],
lower_scratch: &mut internal::BsplineScratch,
) -> Result<(), BasisError> {
validate_knots_for_degree(knot_vector, degree)?;
let num_basis = knot_vector.len() - degree - 1;
if out.len() != num_basis {
return Err(BasisError::InvalidKnotVector(format!(
"Output buffer length {} does not match number of basis functions {}",
out.len(),
num_basis
)));
}
let num_basis_lower = knot_vector.len() - degree;
if lower_basis.len() < num_basis_lower {
return Err(BasisError::InvalidKnotVector(format!(
"lower_basis buffer too small: {} < {}",
lower_basis.len(),
num_basis_lower
)));
}
for v in lower_basis.iter_mut().take(num_basis_lower) {
*v = 0.0;
}
let x_eval = one_sided_derivative_eval_point(x, knot_vector, degree);
internal::evaluate_splines_at_point_into(
x_eval,
degree - 1,
knot_vector,
&mut lower_basis[..num_basis_lower],
lower_scratch,
);
let k = degree as f64;
for i in 0..num_basis {
let denom_left = knot_vector[i + degree] - knot_vector[i];
let denom_right = knot_vector[i + degree + 1] - knot_vector[i + 1];
let left_term = if denom_left.abs() > KNOT_SPAN_DEGENERACY_FLOOR && i < num_basis_lower {
lower_basis[i] / denom_left
} else {
0.0
};
let right_term =
if denom_right.abs() > KNOT_SPAN_DEGENERACY_FLOOR && (i + 1) < num_basis_lower {
lower_basis[i + 1] / denom_right
} else {
0.0
};
out[i] = k * (left_term - right_term);
}
Ok(())
}
fn create_mspline_dense(
data: ArrayView1<f64>,
knot_vector: ArrayView1<f64>,
degree: usize,
) -> Result<Array2<f64>, BasisError> {
validate_knots_for_degree(knot_vector, degree)?;
validate_mspline_normalization_spans(knot_vector, degree)?;
let num_basis = knot_vector.len() - degree - 1;
let mut out = Array2::<f64>::zeros((data.len(), num_basis));
let mut scratch = internal::BsplineScratch::new(degree);
let support = degree + 1;
let mut local = vec![0.0; support];
let left = knot_vector[degree];
let right = knot_vector[num_basis];
let order = (degree + 1) as f64;
let mut scales = vec![0.0; num_basis];
for i in 0..num_basis {
let span = knot_vector[i + degree + 1] - knot_vector[i];
scales[i] = order / span;
}
for (row_i, &x) in data.iter().enumerate() {
if x < left || x > right {
continue;
}
let start = internal::evaluate_splines_sparse_into(
x,
degree,
knot_vector,
&mut local,
&mut scratch,
);
for (offset, &b) in local.iter().enumerate() {
let j = start + offset;
if j < num_basis {
out[[row_i, j]] = b * scales[j];
}
}
}
Ok(out)
}
fn create_mspline_sparse(
data: ArrayView1<f64>,
knot_vector: ArrayView1<f64>,
degree: usize,
) -> Result<SparseColMat<usize, f64>, BasisError> {
validate_knots_for_degree(knot_vector, degree)?;
validate_mspline_normalization_spans(knot_vector, degree)?;
let nrows = data.len();
let ncols = knot_vector.len() - degree - 1;
let mut scratch = internal::BsplineScratch::new(degree);
let support = degree + 1;
let mut local = vec![0.0; support];
let left = knot_vector[degree];
let right = knot_vector[ncols];
let order = (degree + 1) as f64;
let mut scales = vec![0.0; ncols];
for i in 0..ncols {
let span = knot_vector[i + degree + 1] - knot_vector[i];
scales[i] = order / span;
}
let mut triplets: Vec<Triplet<usize, usize, f64>> =
Vec::with_capacity(nrows.saturating_mul(support));
for (row_i, &x) in data.iter().enumerate() {
if x < left || x > right {
continue;
}
let start = internal::evaluate_splines_sparse_into(
x,
degree,
knot_vector,
&mut local,
&mut scratch,
);
for (offset, &b) in local.iter().enumerate() {
let col = start + offset;
if col >= ncols {
continue;
}
let v = b * scales[col];
if v.abs() > 0.0 {
triplets.push(Triplet::new(row_i, col, v));
}
}
}
SparseColMat::try_new_from_triplets(nrows, ncols, &triplets)
.map_err(|e| BasisError::SparseCreation(format!("{e:?}")))
}
fn validate_mspline_normalization_spans(
knot_vector: ArrayView1<f64>,
degree: usize,
) -> Result<(), BasisError> {
let num_basis = knot_vector.len().saturating_sub(degree + 1);
for i in 0..num_basis {
let span = knot_vector[i + degree + 1] - knot_vector[i];
if span <= KNOT_SPAN_DEGENERACY_FLOOR {
crate::bail_invalid_basis!(
"invalid M-spline normalization span at i={i}: t[i+degree+1]-t[i]={span:.3e} must be > 0"
);
}
}
Ok(())
}
fn create_ispline_dense(
data: ArrayView1<f64>,
knot_vector: ArrayView1<f64>,
degree: usize,
) -> Result<Array2<f64>, BasisError> {
let bs_degree = 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_basis = knot_vector.len() - bs_degree - 1;
let num_ispline_basis = num_bspline_basis.saturating_sub(1);
let mut out = Array2::<f64>::zeros((data.len(), num_ispline_basis));
let mut scratch = internal::BsplineScratch::new(bs_degree);
let support = bs_degree + 1;
let mut local = vec![0.0; support];
let left = knot_vector[bs_degree];
let right = knot_vector[num_bspline_basis];
let mut left_local = vec![0.0_f64; support];
let mut left_scratch = internal::BsplineScratch::new(bs_degree);
let left_start = internal::evaluate_splines_sparse_into(
left,
bs_degree,
knot_vector,
&mut left_local,
&mut left_scratch,
);
let mut left_offsets = vec![0.0_f64; num_bspline_basis];
let mut left_running = 0.0_f64;
for offset in (0..support).rev() {
let j = left_start + offset;
if j >= num_bspline_basis {
continue;
}
left_running += left_local[offset];
left_offsets[j] = left_running;
}
for (row_i, &x) in data.iter().enumerate() {
if x < left {
continue;
}
if x >= right {
for j in 1..num_bspline_basis {
let value = 1.0 - left_offsets[j];
out[[row_i, j - 1]] = if value.abs() <= 1e-15 { 0.0 } else { value };
}
continue;
}
let start = internal::evaluate_splines_sparse_into(
x,
bs_degree,
knot_vector,
&mut local,
&mut scratch,
);
let total = local.iter().copied().sum::<f64>();
let lead_end = start.min(num_bspline_basis);
if lead_end > 1 {
out.slice_mut(s![row_i, 0..(lead_end - 1)]).fill(total);
}
let mut running = 0.0f64;
for offset in (0..support).rev() {
let j = start + offset;
if j >= num_bspline_basis {
continue;
}
running += local[offset];
if j > 0 {
let value = running - left_offsets[j];
out[[row_i, j - 1]] = if value.abs() <= 1e-15 { 0.0 } else { value };
}
}
}
Ok(out)
}
#[derive(Default)]
pub struct BsplineDerivativeWorkspace {
chain: Vec<Vec<f64>>,
lower_basis: Vec<f64>,
lower_scratch: internal::BsplineScratch,
}
impl BsplineDerivativeWorkspace {
#[inline]
pub fn new() -> Self {
Self::default()
}
#[inline]
fn chain_buffer(&mut self, depth: usize, len: usize) -> &mut [f64] {
if self.chain.len() <= depth {
self.chain.resize_with(depth + 1, Vec::new);
}
let buf = &mut self.chain[depth];
if buf.len() != len {
buf.resize(len, 0.0);
}
for v in buf.iter_mut() {
*v = 0.0;
}
buf
}
}
fn evaluate_bspline_derivative_recurrence_into(
derivative_order: usize,
x: f64,
knot_vector: ArrayView1<f64>,
degree: usize,
out: &mut [f64],
workspace: &mut BsplineDerivativeWorkspace,
depth: usize,
) -> Result<(), BasisError> {
if degree < derivative_order {
return Err(BasisError::InsufficientDegreeForDerivative {
degree,
derivative_order,
minimum_degree: derivative_order,
});
}
if derivative_order <= 1 {
let num_basis_lower = knot_vector.len().saturating_sub(degree);
if workspace.lower_basis.len() < num_basis_lower {
workspace.lower_basis.resize(num_basis_lower, 0.0);
}
return evaluate_bspline_derivative_scalar_into(
x,
knot_vector,
degree,
out,
&mut workspace.lower_basis,
&mut workspace.lower_scratch,
);
}
validate_knots_for_degree(knot_vector, degree)?;
let num_basis = knot_vector.len() - degree - 1;
if out.len() != num_basis {
return Err(BasisError::InvalidKnotVector(format!(
"Output buffer length {} does not match number of basis functions {}",
out.len(),
num_basis
)));
}
if num_basis > 0 {
let left = knot_vector[degree];
let right = knot_vector[num_basis];
if x < left || x > right {
out.fill(0.0);
return Ok(());
}
}
let num_basis_lower = knot_vector.len() - degree;
workspace.chain_buffer(depth, num_basis_lower);
let mut lower = std::mem::take(&mut workspace.chain[depth]);
let recurse = evaluate_bspline_derivative_recurrence_into(
derivative_order - 1,
x,
knot_vector,
degree - 1,
&mut lower,
workspace,
depth + 1,
);
workspace.chain[depth] = lower;
recurse?;
let lower = &workspace.chain[depth];
let k = degree as f64;
for i in 0..num_basis {
let denom1 = knot_vector[i + degree] - knot_vector[i];
let denom2 = knot_vector[i + degree + 1] - knot_vector[i + 1];
let term1 = if denom1.abs() > KNOT_SPAN_DEGENERACY_FLOOR {
k * lower[i] / denom1
} else {
0.0
};
let term2 = if denom2.abs() > KNOT_SPAN_DEGENERACY_FLOOR {
k * lower[i + 1] / denom2
} else {
0.0
};
out[i] = term1 - term2;
}
Ok(())
}
pub fn evaluate_bsplinesecond_derivative_scalar(
x: f64,
knot_vector: ArrayView1<f64>,
degree: usize,
out: &mut [f64],
) -> Result<(), BasisError> {
let mut workspace = BsplineDerivativeWorkspace::new();
evaluate_bspline_derivative_recurrence_into(2, x, knot_vector, degree, out, &mut workspace, 0)
}
pub fn evaluate_bsplinethird_derivative_scalar(
x: f64,
knot_vector: ArrayView1<f64>,
degree: usize,
out: &mut [f64],
) -> Result<(), BasisError> {
let mut workspace = BsplineDerivativeWorkspace::new();
evaluate_bspline_derivative_recurrence_into(3, x, knot_vector, degree, out, &mut workspace, 0)
}
pub fn evaluate_bspline_fourth_derivative_scalar(
x: f64,
knot_vector: ArrayView1<f64>,
degree: usize,
out: &mut [f64],
) -> Result<(), BasisError> {
let mut workspace = BsplineDerivativeWorkspace::new();
evaluate_bspline_derivative_recurrence_into(4, x, knot_vector, degree, out, &mut workspace, 0)
}