use super::*;
type DuchonBasisCacheKey = (u64, u64);
#[derive(Clone)]
struct CachedDuchonBasis(BasisBuildResult);
impl crate::resource::ResidentBytes for CachedDuchonBasis {
fn resident_bytes(&self) -> usize {
let design_bytes = self
.0
.design
.nrows()
.saturating_mul(self.0.design.ncols())
.saturating_mul(std::mem::size_of::<f64>());
let penalty_bytes: usize = self
.0
.penalties
.iter()
.map(|s| s.len().saturating_mul(std::mem::size_of::<f64>()))
.sum();
design_bytes
.saturating_add(penalty_bytes)
.saturating_add(4096)
}
}
fn duchon_basis_cache()
-> &'static crate::resource::ByteLruCache<DuchonBasisCacheKey, CachedDuchonBasis> {
static CACHE: std::sync::OnceLock<
crate::resource::ByteLruCache<DuchonBasisCacheKey, CachedDuchonBasis>,
> = std::sync::OnceLock::new();
CACHE.get_or_init(|| crate::resource::ByteLruCache::new(1 << 30))
}
fn duchon_basis_fingerprint(
data: ArrayView2<'_, f64>,
spec: &DuchonBasisSpec,
) -> Option<DuchonBasisCacheKey> {
let spec_bytes = serde_json::to_vec(spec).ok()?;
let mut lo = DefaultHasher::new();
let mut hi = DefaultHasher::new();
0x9E37_79B9_7F4A_7C15u64.hash(&mut hi);
let (nrows, ncols) = data.dim();
for h in [&mut lo, &mut hi] {
nrows.hash(h);
ncols.hash(h);
}
for row in data.rows() {
for &v in row {
let bits = v.to_bits();
bits.hash(&mut lo);
bits.hash(&mut hi);
}
}
for h in [&mut lo, &mut hi] {
spec_bytes.len().hash(h);
spec_bytes.hash(h);
}
Some((lo.finish(), hi.finish()))
}
pub fn build_duchon_basiswithworkspace(
data: ArrayView2<'_, f64>,
spec: &DuchonBasisSpec,
workspace: &mut BasisWorkspace,
) -> Result<BasisBuildResult, BasisError> {
if let Some(key) = duchon_basis_fingerprint(data, spec) {
if let Some(hit) = duchon_basis_cache().get(&key) {
return Ok(hit.0);
}
let result = build_duchon_basis_uncached(data, spec, workspace)?;
duchon_basis_cache().insert(key, CachedDuchonBasis(result.clone()));
return Ok(result);
}
build_duchon_basis_uncached(data, spec, workspace)
}
fn build_duchon_basis_uncached(
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 let Some(periodic) = spec.periodic.as_ref() {
if periodic.len() != data.ncols() {
crate::bail_invalid_basis!(
"periodic must have length d={}, got {}",
data.ncols(),
periodic.len()
);
}
if data.ncols() > 1 && periodic.iter().any(Option::is_some) {
let flags = periodic.iter().map(Option::is_some).collect::<Vec<_>>();
let periods = periodic
.iter()
.map(|axis| axis.unwrap_or(1.0))
.collect::<Vec<_>>();
return build_duchon_basis_mixed_periodicity_auto(data, spec, &flags, Some(&periods));
}
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 mut 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 mut frozen_radial_reparam: Option<Array2<f64>> = None;
if let Some(v) = spec.radial_reparam.as_ref() {
if v.nrows() != kernel_transform.ncols() {
crate::bail_dim_basis!(
"Duchon frozen radial reparam shape {:?} does not match constrained kernel dimension {}",
v.dim(),
kernel_transform.ncols()
);
}
kernel_transform = fast_ab(&kernel_transform, v);
frozen_radial_reparam = Some(v.clone());
}
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 center_mean: Vec<f64> = (0..d)
.map(|c| centers.column(c).sum() / (centers.nrows().max(1) as f64))
.collect();
let mut data_centered = data.to_owned();
for c in 0..d {
let mu = center_mean[c];
data_centered.column_mut(c).mapv_inplace(|v| v - mu);
}
let poly_block =
polynomial_block_from_order(data_centered.view(), 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 kernel_gauge = Arc::new(crate::solver::gauge::Gauge::from_block_transforms(&[
kernel_transform.clone(),
]));
let base_op = ChunkedKernelDesignOperator::new(
shared_data.clone(),
Arc::new(centers.clone()),
kernel,
Some(kernel_gauge),
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 kernel_gauge = Arc::new(crate::solver::gauge::Gauge::from_block_transforms(&[
kernel_transform.clone(),
]));
let base_op = ChunkedKernelDesignOperator::new(
shared_data,
Arc::new(centers.clone()),
kernel,
Some(kernel_gauge),
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 operators_active = matches!(
spec.operator_penalties.mass,
OperatorPenaltySpec::Active { .. }
) || matches!(
spec.operator_penalties.tension,
OperatorPenaltySpec::Active { .. }
) || matches!(
spec.operator_penalties.stiffness,
OperatorPenaltySpec::Active { .. }
);
if frozen_radial_reparam.is_none() && !operators_active {
let kernel_cols = kernel_transform.ncols();
if kernel_cols > 0 {
let raw = build_duchon_basis_designwithworkspace(
data,
centers.view(),
spec.length_scale,
spec.power,
effective_nullspace_order,
aniso.as_deref(),
None,
workspace,
)?;
let kernel_block = raw.basis.slice(s![.., 0..kernel_cols]);
let design_gram = symmetrize_penalty(&fast_atb(&kernel_block, &kernel_block));
let omega_constrained = duchon_constrained_bending_penalty(
centers.view(),
spec.length_scale,
spec.power,
effective_nullspace_order,
aniso.as_deref(),
&kernel_transform,
)?;
let (v, _mu) =
thin_plate_radial_reparam_data_metric(&omega_constrained, &design_gram)?;
if v.ncols() > 0 {
kernel_transform = fast_ab(&kernel_transform, &v);
frozen_radial_reparam = Some(v);
}
}
}
let d = build_duchon_basis_designwithworkspace(
data,
centers.view(),
spec.length_scale,
spec.power,
effective_nullspace_order,
aniso.as_deref(),
frozen_radial_reparam.as_ref(),
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,
radial_reparam: frozen_radial_reparam,
},
kronecker_factored: None,
})
}
pub(crate) fn duchon_penalties_at_length_scale(
centers: ArrayView2<'_, f64>,
identifiability_transform: Option<&Array2<f64>>,
operator_collocation_points: Option<ArrayView2<'_, f64>>,
operator_penalties: &DuchonOperatorPenaltySpec,
power: f64,
nullspace_order: DuchonNullspaceOrder,
aniso_log_scales: Option<&[f64]>,
radial_reparam: Option<&Array2<f64>>,
length_scale: Option<f64>,
workspace: &mut BasisWorkspace,
) -> Result<(Vec<Array2<f64>>, Vec<usize>), BasisError> {
let effective_nullspace_order = duchon_effective_nullspace_order(centers, nullspace_order);
let aniso = auto_seed_aniso_contrasts(centers, aniso_log_scales);
let mut kernel_transform =
kernel_constraint_nullspace(centers, effective_nullspace_order, &mut workspace.cache)?;
if let Some(v) = radial_reparam {
if v.nrows() != kernel_transform.ncols() {
crate::bail_dim_basis!(
"Duchon frozen radial reparam shape {:?} does not match constrained kernel dimension {}",
v.dim(),
kernel_transform.ncols()
);
}
kernel_transform = fast_ab(&kernel_transform, v);
}
let poly_cols = polynomial_block_from_order(centers, effective_nullspace_order).ncols();
let mut candidates = duchon_native_penalty_candidates(
centers,
length_scale,
power,
effective_nullspace_order,
aniso.as_deref(),
&kernel_transform,
identifiability_transform,
poly_cols,
)?;
if let Some(points) = operator_collocation_points {
candidates.extend(duchon_operator_penalty_candidates(
points,
centers,
operator_penalties,
length_scale,
power,
effective_nullspace_order,
aniso.is_some(),
identifiability_transform,
workspace,
)?);
}
let (penalties, nullspace_dims, _info, _eig, _ops) =
filter_active_penalty_candidates_with_ops(candidates)?;
Ok((penalties, nullspace_dims))
}
pub(crate) 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()
}
pub(crate) 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)]
pub(crate) fn thin_plate_polynomial_degree(dimension: usize) -> usize {
thin_plate_penalty_order(dimension).saturating_sub(1)
}
pub(crate) 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 thin_plate_retained_radial_indices(evals: &Array1<f64>) -> Vec<usize> {
let k = evals.len();
if k == 0 {
return Vec::new();
}
let max_eval = evals
.iter()
.copied()
.fold(0.0_f64, |acc, value| acc.max(value.abs()));
if !max_eval.is_finite() || max_eval <= 0.0 {
return Vec::new();
}
let num_floor = (k as f64) * f64::EPSILON * max_eval;
evals
.iter()
.enumerate()
.filter_map(|(idx, &value)| (value.abs() > num_floor).then_some(idx))
.collect()
}
pub(crate) fn thin_plate_radial_reparam_from_constrained_penalty(
omega_constrained: &Array2<f64>,
) -> Result<(Array2<f64>, Array1<f64>), BasisError> {
let kernel_cols = omega_constrained.nrows();
if kernel_cols != omega_constrained.ncols() {
crate::bail_dim_basis!(
"thin-plate constrained radial penalty must be square: got {:?}",
omega_constrained.dim()
);
}
if kernel_cols == 0 {
return Ok((Array2::<f64>::zeros((0, 0)), Array1::<f64>::zeros(0)));
}
let sym = symmetrize_penalty(omega_constrained);
let (mut evals, evecs) = FaerEigh::eigh(&sym, Side::Lower).map_err(BasisError::LinalgError)?;
for value in evals.iter_mut() {
if *value < 0.0 {
*value = 0.0;
}
}
let keep = thin_plate_retained_radial_indices(&evals);
Ok((evecs.select(Axis(1), &keep), evals.select(Axis(0), &keep)))
}
pub(crate) fn thin_plate_radial_reparam_data_metric(
omega_constrained: &Array2<f64>,
design_gram: &Array2<f64>,
) -> Result<(Array2<f64>, Array1<f64>), BasisError> {
let k = omega_constrained.nrows();
if k != omega_constrained.ncols() || design_gram.nrows() != k || design_gram.ncols() != k {
crate::bail_dim_basis!(
"thin-plate data-metric reparam requires square k×k Ω_c and G_c: Ω_c={:?}, G_c={:?}",
omega_constrained.dim(),
design_gram.dim()
);
}
if k == 0 {
return Ok((Array2::<f64>::zeros((0, 0)), Array1::<f64>::zeros(0)));
}
let g_sym = symmetrize_penalty(design_gram);
let (g_evals, g_evecs) =
FaerEigh::eigh(&g_sym, Side::Lower).map_err(BasisError::LinalgError)?;
let gmax = g_evals.iter().copied().fold(0.0_f64, |a, b| a.max(b.abs()));
if !gmax.is_finite() || gmax <= 0.0 {
return thin_plate_radial_reparam_from_constrained_penalty(omega_constrained);
}
let g_floor = (k as f64) * f64::EPSILON * gmax;
let mut cols: Vec<usize> = Vec::with_capacity(k);
for j in 0..k {
if g_evals[j] > g_floor {
cols.push(j);
}
}
let m = cols.len();
if m == 0 {
return thin_plate_radial_reparam_from_constrained_penalty(omega_constrained);
}
let mut w = Array2::<f64>::zeros((k, m));
for (c, &j) in cols.iter().enumerate() {
let inv_sqrt = 1.0 / g_evals[j].sqrt();
for i in 0..k {
w[[i, c]] = g_evecs[[i, j]] * inv_sqrt;
}
}
let omega_sym = symmetrize_penalty(omega_constrained);
let wt_omega = fast_atb(&w, &omega_sym);
let m_mat = symmetrize_penalty(&fast_ab(&wt_omega, &w));
let (mut mu, p_mat) = FaerEigh::eigh(&m_mat, Side::Lower).map_err(BasisError::LinalgError)?;
for value in mu.iter_mut() {
if *value < 0.0 {
*value = 0.0;
}
}
let v_full = fast_ab(&w, &p_mat); let keep = thin_plate_retained_radial_indices(&mu);
Ok((v_full.select(Axis(1), &keep), mu.select(Axis(0), &keep)))
}
pub(crate) fn thin_plate_radial_reparam_from_centers(
centers: ArrayView2<'_, f64>,
length_scale: f64,
kernel_transform: &Array2<f64>,
) -> Result<(Array2<f64>, Array1<f64>), BasisError> {
let k = centers.nrows();
let d = centers.ncols();
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 = centers[[i, c]] - centers[[j, c]];
dist2 += delta * delta;
}
thin_plate_kernel_from_dist2(dist2 / length_scale_sq, d)
})?;
let kernel_gauge =
crate::solver::gauge::Gauge::from_block_transforms(&[kernel_transform.clone()]);
let omega_constrained = symmetrize_penalty(&kernel_gauge.restrict_penalty(&omega));
thin_plate_radial_reparam_from_constrained_penalty(&omega_constrained)
}
pub(crate) 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 centroid: Vec<f64> = (0..d)
.map(|c| {
let mut s = 0.0;
for i in 0..n {
s += data[[i, c]];
}
s / n as f64
})
.collect();
let dist2_to_centroid: Vec<f64> = (0..n)
.into_par_iter()
.map(|i| {
let mut d2 = 0.0;
for c in 0..d {
let delta = data[[i, c]] - centroid[c];
d2 += delta * delta;
}
d2
})
.collect();
let value_less = |i: usize, j: usize| -> bool {
for c in 0..d {
let vi = data[[i, c]];
let vj = data[[j, c]];
if vi < vj {
return true;
}
if vi > vj {
return false;
}
}
i < j
};
let seed_idx = (0..n)
.into_par_iter()
.map(|i| (i, dist2_to_centroid[i]))
.reduce_with(|a, b| {
if b.1 < a.1 || (b.1 == a.1 && value_less(b.0, a.0)) {
b
} else {
a
}
})
.map(|(i, _)| i)
.unwrap_or(0);
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| {
let pick_b = b.1 > a.1
|| (b.1 == a.1
&& (dist2_to_centroid[b.0] > dist2_to_centroid[a.0]
|| (dist2_to_centroid[b.0] == dist2_to_centroid[a.0]
&& value_less(b.0, a.0))));
if pick_b { 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)]
pub(crate) 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)]
pub(crate) fn thin_plate_penalty_order(dimension: usize) -> usize {
match dimension {
1..=3 => 2,
_ => dimension / 2 + 1,
}
}
#[inline(always)]
pub(crate) fn d_canonical_tps_infeasible(dimension: usize, num_centers: usize) -> bool {
num_centers < thin_plate_polynomial_basis_dimension(dimension)
}
pub(crate) 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
}
pub(crate) 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)]
pub(crate) 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)]
pub(crate) 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)
}
pub(crate) 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 knot_mean: Vec<f64> = (0..d)
.map(|c| knots.column(c).sum() / (k.max(1) as f64))
.collect();
let mut data_centered = data.to_owned();
let mut knots_centered = knots.to_owned();
for c in 0..d {
let mu = knot_mean[c];
data_centered.column_mut(c).mapv_inplace(|v| v - mu);
knots_centered.column_mut(c).mapv_inplace(|v| v - mu);
}
let data = data_centered.view();
let knots = knots_centered.view();
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 constrained_kernel_cols = kernel_constrained.ncols();
let (radial_reparam, radial_eigvals): (Array2<f64>, Array1<f64>) = if let Some(frozen) =
frozen_radial_reparam
{
if frozen.nrows() != constrained_kernel_cols {
crate::bail_dim_basis!(
"thin-plate frozen radial reparam shape {:?} does not match constrained radial dimension {}",
frozen.dim(),
constrained_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(v.ncols());
for i in 0..v.ncols() {
evals[i] = lambda_diag[[i, i]].max(0.0);
}
(v, evals)
} else if constrained_kernel_cols == 0 {
(Array2::<f64>::zeros((0, 0)), Array1::<f64>::zeros(0))
} else {
let design_gram = symmetrize_penalty(&fast_atb(&kernel_constrained, &kernel_constrained));
thin_plate_radial_reparam_data_metric(&omega_constrained, &design_gram)?
};
let kernel_cols = radial_eigvals.len();
let total_cols = kernel_cols + poly_cols;
let kernel_rotated = if kernel_cols == 0 {
Array2::<f64>::zeros((n, 0))
} 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,
})
}
pub(crate) 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()
}
pub(crate) 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 constrained_kernel_cols = z_kernel.ncols();
let poly_cols = thin_plate_polynomial_basis_dimension(centers.ncols());
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 {
pub(crate) i: usize,
pub(crate) j: usize,
pub(crate) phi: f64,
pub(crate) phi_psi: f64,
pub(crate) 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() != constrained_kernel_cols {
crate::bail_dim_basis!(
"thin-plate frozen radial reparam shape {:?} does not match constrained radial dimension {}",
frozen.dim(),
constrained_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(v_owned.ncols());
for i in 0..v_owned.ncols() {
evals[i] = lambda_diag[[i, i]].max(0.0);
}
(v_owned, evals)
} else if constrained_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;
}
}
let keep = thin_plate_retained_radial_indices(&evals);
(evecs.select(Axis(1), &keep), evals.select(Axis(0), &keep))
};
let kernel_cols = lambda.len();
let total_cols = kernel_cols + poly_cols;
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))
}
pub(crate) 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 constrained_kernel_cols = z_kernel.ncols();
let kernel_transform = if let Some(v) = spec.radial_reparam.as_ref() {
if v.nrows() != constrained_kernel_cols {
crate::bail_dim_basis!(
"thin-plate radial reparam shape {:?} does not match constrained radial dimension {}",
v.dim(),
constrained_kernel_cols
);
}
fast_ab(&z_kernel, v)
} else {
z_kernel
};
let kernel_cols = kernel_transform.ncols();
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(),
gram_spectrum: "not computed (structural rank collapse before Gram eigendecomposition)"
.to_string(),
});
}
if rank == 0 {
return Ok((basis_matrix.to_owned(), Array2::eye(k)));
}
let gauge = crate::solver::gauge::Gauge::sum_to_zero(z);
let constrained = gauge.restrict_design(&basis_matrix);
let z = gauge.block_transform(0);
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(),
gram_spectrum: "not computed (structural rank collapse before Gram eigendecomposition)"
.to_string(),
});
}
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,
gram_spectrum: "not computed (structural rank collapse before Gram eigendecomposition)"
.to_string(),
});
}
if z.ncols() == 0 {
return Err(BasisError::ConstraintNullspaceCollapsed {
site: "compute_geometric_constraint_transform",
cross_rank: 0,
coeff_dim: k,
cross_frobenius: f64::NAN,
gram_spectrum: "not computed (structural rank collapse before Gram eigendecomposition)"
.to_string(),
});
}
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))
}
#[cfg(test)]
mod knot_selection_invariance_tests {
use super::select_thin_plate_knots;
use ndarray::Array2;
fn sample_cloud() -> Array2<f64> {
let pts: Vec<[f64; 2]> = vec![
[0.10, 0.20],
[1.30, 0.05],
[2.10, 1.40],
[0.40, 2.30],
[1.90, 2.80],
[3.20, 0.70],
[2.70, 3.10],
[0.90, 1.10],
[3.50, 2.20],
[1.60, 3.60],
[0.05, 3.05],
[2.40, 0.30],
];
let mut a = Array2::<f64>::zeros((pts.len(), 2));
for (i, p) in pts.iter().enumerate() {
a[[i, 0]] = p[0];
a[[i, 1]] = p[1];
}
a
}
fn canonical(knots: &Array2<f64>) -> Vec<(u64, u64)> {
let mut rows: Vec<(u64, u64)> = (0..knots.nrows())
.map(|r| (knots[[r, 0]].to_bits(), knots[[r, 1]].to_bits()))
.collect();
rows.sort_unstable();
rows
}
fn data_centroid_2d(data: &Array2<f64>) -> (f64, f64) {
let n = data.nrows();
let cx = (0..n).map(|i| data[[i, 0]]).sum::<f64>() / n as f64;
let cz = (0..n).map(|i| data[[i, 1]]).sum::<f64>() / n as f64;
(cx, cz)
}
fn rotate_90_about(data: &Array2<f64>, cx: f64, cz: f64) -> Array2<f64> {
let n = data.nrows();
let mut out = Array2::<f64>::zeros((n, 2));
for i in 0..n {
let dx = data[[i, 0]] - cx;
let dz = data[[i, 1]] - cz;
out[[i, 0]] = cx - dz;
out[[i, 1]] = cz + dx;
}
out
}
#[test]
fn knot_set_is_rotation_invariant_gh1456() {
let data = sample_cloud();
let n = data.nrows();
let num_knots = 5;
assert!(num_knots < n, "must exercise the farthest-point selector");
let knots = select_thin_plate_knots(data.view(), num_knots).expect("select knots");
assert_eq!(knots.nrows(), num_knots);
let (cx, cz) = data_centroid_2d(&data);
let rotated = rotate_90_about(&data, cx, cz);
let knots_rot = select_thin_plate_knots(rotated.view(), num_knots).expect("select rotated");
let knots_then_rotate = rotate_90_about(&knots, cx, cz);
assert_eq!(
canonical(&knots_then_rotate),
canonical(&knots_rot),
"rotating-then-selecting must equal selecting-then-rotating (gh#1456); \
the OLD lexicographic seed picks a different physical point after rotation"
);
}
#[test]
fn knot_set_is_row_permutation_invariant_gh1378() {
let data = sample_cloud();
let n = data.nrows();
let num_knots = 5;
assert!(num_knots < n, "must exercise the farthest-point selector");
let knots = select_thin_plate_knots(data.view(), num_knots).expect("select knots");
let perm: Vec<usize> = vec![7, 0, 11, 3, 9, 1, 5, 10, 2, 8, 4, 6];
assert_eq!(perm.len(), n);
let mut permuted = Array2::<f64>::zeros((n, 2));
for (new_row, &old_row) in perm.iter().enumerate() {
permuted[[new_row, 0]] = data[[old_row, 0]];
permuted[[new_row, 1]] = data[[old_row, 1]];
}
let knots_perm =
select_thin_plate_knots(permuted.view(), num_knots).expect("select permuted");
assert_eq!(
canonical(&knots),
canonical(&knots_perm),
"reordering rows must not change the selected knot set (gh#1378)"
);
}
}
#[cfg(test)]
mod retained_radial_indices_tests {
use super::thin_plate_retained_radial_indices;
use ndarray::Array1;
#[test]
fn linear_data_spectrum_keeps_every_mode() {
let evals = Array1::from_vec(vec![
885.4, 119.98, 26.287, 10.030, 5.066, 2.330, 1.3953, 0.67709, 0.46814, 0.34210,
0.26488, 0.17895, 0.14514,
]);
let keep = thin_plate_retained_radial_indices(&evals);
assert_eq!(
keep.len(),
evals.len(),
"all numerically real modes must be retained"
);
}
#[test]
fn lidar_spectrum_keeps_every_real_mode() {
let evals = Array1::from_vec(vec![
1212.2, 144.94, 37.270, 15.529, 6.0768, 3.5845, 1.8094, 1.1058, 0.73002, 0.43701,
0.33814, 0.23136, 0.18267, 0.15702, 0.13654, 0.044936, 0.041844, 0.038235,
]);
let keep = thin_plate_retained_radial_indices(&evals);
assert_eq!(keep.len(), evals.len(), "every above-floor mode is kept");
}
#[test]
fn pure_roundoff_modes_are_dropped() {
let big = 1.0e3;
let dust = 0.1 * 5.0 * f64::EPSILON * big; let evals = Array1::from_vec(vec![big, 100.0, 10.0, 1.0, dust]);
let keep = thin_plate_retained_radial_indices(&evals);
assert_eq!(keep.len(), 4, "the sub-floor roundoff mode must be pruned");
assert!(!keep.contains(&4));
}
#[test]
fn empty_and_singleton_spectra_are_handled() {
assert!(thin_plate_retained_radial_indices(&Array1::from_vec(vec![])).is_empty());
assert_eq!(
thin_plate_retained_radial_indices(&Array1::from_vec(vec![5.0])),
vec![0]
);
}
}