1use super::*;
2
3pub struct SplineScratch {
5 pub(crate) inner: internal::BsplineScratch,
6 pub(crate) local: Vec<f64>,
7 pub(crate) left_inner: internal::BsplineScratch,
8 pub(crate) left_local: Vec<f64>,
9 pub(crate) left_offsets: Vec<f64>,
10}
11
12impl SplineScratch {
13 pub fn new(degree: usize) -> Self {
14 Self {
15 inner: internal::BsplineScratch::new(degree),
16 local: Vec::new(),
17 left_inner: internal::BsplineScratch::new(degree),
18 left_local: Vec::new(),
19 left_offsets: Vec::new(),
20 }
21 }
22}
23
24pub fn evaluate_bspline_basis_scalar(
28 x: f64,
29 knot_vector: ArrayView1<f64>,
30 degree: usize,
31 out: &mut [f64],
32 scratch: &mut SplineScratch,
33) -> Result<(), BasisError> {
34 validate_knots_for_degree(knot_vector, degree)?;
35
36 let num_basis = knot_vector.len() - degree - 1;
37 if out.len() != num_basis {
38 return Err(BasisError::InvalidKnotVector(format!(
39 "Output buffer length {} does not match number of basis functions {}",
40 out.len(),
41 num_basis
42 )));
43 }
44
45 internal::evaluate_splines_at_point_into(x, degree, knot_vector, out, &mut scratch.inner);
46
47 Ok(())
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct PeriodicBSplineBasisSpec {
59 pub degree: usize,
61 pub num_basis: usize,
63 pub period: f64,
65 pub origin: f64,
67 pub penalty_order: usize,
70}
71
72impl PeriodicBSplineBasisSpec {
73 pub fn new(
76 degree: usize,
77 num_basis: usize,
78 period: f64,
79 origin: f64,
80 penalty_order: usize,
81 ) -> Self {
82 Self {
83 degree,
84 num_basis,
85 period,
86 origin,
87 penalty_order,
88 }
89 }
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct PeriodicSplineCurve {
100 pub spec: PeriodicBSplineBasisSpec,
101 pub coefficients: Array2<f64>,
102}
103
104impl PeriodicSplineCurve {
105 pub fn ambient_dim(&self) -> usize {
107 self.coefficients.ncols()
108 }
109
110 pub fn evaluate(&self, u: ArrayView1<'_, f64>) -> Result<Array2<f64>, BasisError> {
113 if self.coefficients.nrows() != self.spec.num_basis {
114 crate::bail_dim_basis!(
115 "curve coefficient rows ({}) must equal periodic basis size ({})",
116 self.coefficients.nrows(),
117 self.spec.num_basis
118 );
119 }
120 let basis = build_periodic_bspline_basis_1d(u, &self.spec)?;
121 Ok(basis.dot(&self.coefficients))
122 }
123
124 pub fn evaluate_derivative(&self, u: ArrayView1<'_, f64>) -> Result<Array2<f64>, BasisError> {
127 if self.coefficients.nrows() != self.spec.num_basis {
128 crate::bail_dim_basis!(
129 "curve coefficient rows ({}) must equal periodic basis size ({})",
130 self.coefficients.nrows(),
131 self.spec.num_basis
132 );
133 }
134 let t = u.to_owned().insert_axis(Axis(1));
135 let derivative = periodic_bspline_first_derivative_nd(
136 t.view(),
137 (self.spec.origin, self.spec.origin + self.spec.period),
138 self.spec.degree,
139 self.spec.num_basis,
140 )?
141 .index_axis(Axis(2), 0)
142 .to_owned();
143 Ok(derivative.dot(&self.coefficients))
144 }
145}
146
147pub(crate) fn validate_periodic_bspline_spec(
148 spec: &PeriodicBSplineBasisSpec,
149) -> Result<(), BasisError> {
150 if spec.degree < 1 {
151 return Err(BasisError::InvalidDegree(spec.degree));
152 }
153 if spec.num_basis < spec.degree + 1 {
154 crate::bail_invalid_basis!(
155 "periodic B-spline basis requires num_basis >= degree + 1 (got num_basis={}, degree={})",
156 spec.num_basis,
157 spec.degree
158 );
159 }
160 if !spec.period.is_finite() || spec.period <= 0.0 {
161 crate::bail_invalid_basis!(
162 "periodic B-spline period must be finite and positive, got {}",
163 spec.period
164 );
165 }
166 if !spec.origin.is_finite() {
167 crate::bail_invalid_basis!(
168 "periodic B-spline origin must be finite, got {}",
169 spec.origin
170 );
171 }
172 if spec.penalty_order == 0 || spec.penalty_order >= spec.num_basis {
173 return Err(BasisError::InvalidPenaltyOrder {
174 order: spec.penalty_order,
175 num_basis: spec.num_basis,
176 });
177 }
178 if spec.penalty_order > spec.degree {
179 return Err(BasisError::InsufficientDegreeForDerivative {
180 degree: spec.degree,
181 derivative_order: spec.penalty_order,
182 minimum_degree: spec.penalty_order,
183 });
184 }
185 Ok(())
186}
187
188#[inline]
189pub(crate) fn wrap_periodic_phase(u: f64, origin: f64, period: f64) -> f64 {
190 let wrapped = (u - origin).rem_euclid(period);
191 if wrapped >= period { 0.0 } else { wrapped }
194}
195
196pub(crate) fn cardinal_bspline_value(x: f64, degree: usize) -> f64 {
197 if degree == 0 {
198 return if (0.0..1.0).contains(&x) { 1.0 } else { 0.0 };
199 }
200 if x <= 0.0 || x >= (degree + 1) as f64 {
201 return 0.0;
202 }
203 let p = degree as f64;
204 (x / p) * cardinal_bspline_value(x, degree - 1)
205 + (((degree + 1) as f64 - x) / p) * cardinal_bspline_value(x - 1.0, degree - 1)
206}
207
208pub(crate) fn fill_periodic_bspline_unnormalized_value_row(
209 u: f64,
210 origin: f64,
211 period: f64,
212 degree: usize,
213 row: &mut [f64],
214) -> f64 {
215 let m = row.len();
216 let m_f = m as f64;
217 let h = period / m_f;
218 let t = wrap_periodic_phase(u, origin, period) / h;
219 let mut rowsum = 0.0_f64;
220 for (col, value_slot) in row.iter_mut().enumerate() {
221 let base = t - col as f64;
222 let k_min = ((-base) / m_f).floor() as isize - 1;
223 let k_max = (((degree + 1) as f64 - base) / m_f).ceil() as isize + 1;
224 let mut value = 0.0_f64;
225 for k in k_min..=k_max {
226 value += cardinal_bspline_value(base + (k as f64) * m_f, degree);
227 }
228 *value_slot = value;
229 rowsum += value;
230 }
231 rowsum
232}
233
234pub(crate) fn fill_periodic_bspline_unnormalized_derivative_row(
235 u: f64,
236 origin: f64,
237 period: f64,
238 degree: usize,
239 row: &mut [f64],
240) -> f64 {
241 let m = row.len();
242 let m_f = m as f64;
243 let h = period / m_f;
244 let tau = wrap_periodic_phase(u, origin, period) / h;
245 let mut rowsum_derivative = 0.0_f64;
246 for (col, value_slot) in row.iter_mut().enumerate() {
247 let base = tau - col as f64;
248 let k_min = ((-base) / m_f).floor() as isize - 1;
249 let k_max = (((degree + 1) as f64 - base) / m_f).ceil() as isize + 1;
250 let mut value = 0.0_f64;
251 for k in k_min..=k_max {
252 let x_arg = base + (k as f64) * m_f;
253 value += cardinal_bspline_value(x_arg, degree - 1)
254 - cardinal_bspline_value(x_arg - 1.0, degree - 1);
255 }
256 let derivative = value / h;
257 *value_slot = derivative;
258 rowsum_derivative += derivative;
259 }
260 rowsum_derivative
261}
262
263pub fn build_periodic_bspline_basis_1d(
271 u: ArrayView1<'_, f64>,
272 spec: &PeriodicBSplineBasisSpec,
273) -> Result<Array2<f64>, BasisError> {
274 validate_periodic_bspline_spec(spec)?;
275 if u.iter().any(|v| !v.is_finite()) {
276 crate::bail_invalid_basis!("periodic B-spline inputs must all be finite");
277 }
278
279 let n = u.len();
280 let m = spec.num_basis;
281 let mut out = Array2::<f64>::zeros((n, m));
282 let mut value_row = vec![0.0_f64; m];
283 for (row_idx, &ui) in u.iter().enumerate() {
284 let rowsum = fill_periodic_bspline_unnormalized_value_row(
285 ui,
286 spec.origin,
287 spec.period,
288 spec.degree,
289 &mut value_row,
290 );
291 if !rowsum.is_finite() || rowsum <= 0.0 {
292 crate::bail_invalid_basis!(
293 "periodic B-spline row has non-positive rowsum at row {row_idx}: {rowsum}"
294 );
295 }
296 for col in 0..m {
297 out[[row_idx, col]] = value_row[col] / rowsum;
298 }
299 }
300 Ok(out)
301}
302
303fn distinct_periodic_phase_count(u: ArrayView1<'_, f64>, origin: f64, period: f64) -> usize {
304 let mut phases = u
305 .iter()
306 .map(|&value| wrap_periodic_phase(value, origin, period))
307 .collect::<Vec<_>>();
308 phases.sort_by(f64::total_cmp);
309 let tol = 1.0e-12 * period.abs().max(1.0);
310 let mut count = 0usize;
311 let mut previous: Option<f64> = None;
312 for phase in phases {
313 if previous
314 .map(|prev| (phase - prev).abs() <= tol)
315 .unwrap_or(false)
316 {
317 continue;
318 }
319 count += 1;
320 previous = Some(phase);
321 }
322 count
323}
324
325pub(crate) fn solve_spd_cholesky(
326 a: Array2<f64>,
327 b: &Array2<f64>,
328) -> Result<Array2<f64>, BasisError> {
329 let n = a.nrows();
330 if a.ncols() != n || b.nrows() != n {
331 crate::bail_dim_basis!(
332 "normal-equation solve shape mismatch: A is {}x{}, B is {}x{}",
333 a.nrows(),
334 a.ncols(),
335 b.nrows(),
336 b.ncols()
337 );
338 }
339 let mut jitter = 0.0_f64;
340 for attempt in 0..8 {
341 let mut l = a.clone();
342 if jitter > 0.0 {
343 for i in 0..n {
344 l[[i, i]] += jitter;
345 }
346 }
347 let mut ok = true;
348 for i in 0..n {
349 for j in 0..=i {
350 let mut sum = l[[i, j]];
351 for k in 0..j {
352 sum -= l[[i, k]] * l[[j, k]];
353 }
354 if i == j {
355 if sum <= 0.0 || !sum.is_finite() {
356 ok = false;
357 break;
358 }
359 l[[i, j]] = sum.sqrt();
360 } else {
361 l[[i, j]] = sum / l[[j, j]];
362 }
363 }
364 if !ok {
365 break;
366 }
367 for j in (i + 1)..n {
368 l[[i, j]] = 0.0;
369 }
370 }
371 if ok {
372 let mut y = Array2::<f64>::zeros(b.raw_dim());
373 for i in 0..n {
374 for rhs in 0..b.ncols() {
375 let mut sum = b[[i, rhs]];
376 for k in 0..i {
377 sum -= l[[i, k]] * y[[k, rhs]];
378 }
379 y[[i, rhs]] = sum / l[[i, i]];
380 }
381 }
382 let mut x = Array2::<f64>::zeros(b.raw_dim());
383 for i_rev in 0..n {
384 let i = n - 1 - i_rev;
385 for rhs in 0..b.ncols() {
386 let mut sum = y[[i, rhs]];
387 for k in (i + 1)..n {
388 sum -= l[[k, i]] * x[[k, rhs]];
389 }
390 x[[i, rhs]] = sum / l[[i, i]];
391 }
392 }
393 return Ok(x);
394 }
395 let diag_scale = (0..n)
396 .map(|i| a[[i, i]].abs())
397 .fold(0.0_f64, f64::max)
398 .max(1.0);
399 jitter = if attempt == 0 {
400 1e-12 * diag_scale
401 } else {
402 jitter * 10.0
403 };
404 }
405 Err(BasisError::InvalidInput(
406 "periodic spline normal equations were not positive definite even after jitter".to_string(),
407 ))
408}
409
410pub fn fit_periodic_bspline_curve(
418 u: ArrayView1<'_, f64>,
419 y: ArrayView2<'_, f64>,
420 spec: &PeriodicBSplineBasisSpec,
421 smoothing_lambda: f64,
422) -> Result<PeriodicSplineCurve, BasisError> {
423 validate_periodic_bspline_spec(spec)?;
424 if y.nrows() != u.len() {
425 crate::bail_dim_basis!(
426 "periodic curve fit requires y rows ({}) to match u length ({})",
427 y.nrows(),
428 u.len()
429 );
430 }
431 if y.ncols() == 0 {
432 crate::bail_invalid_basis!(
433 "periodic curve fit requires at least one ambient output column"
434 );
435 }
436 if !smoothing_lambda.is_finite() || smoothing_lambda < 0.0 {
437 crate::bail_invalid_basis!(
438 "smoothing_lambda must be finite and nonnegative, got {smoothing_lambda}"
439 );
440 }
441 if y.iter().any(|v| !v.is_finite()) {
442 crate::bail_invalid_basis!("periodic curve outputs must all be finite");
443 }
444 let distinct_phases = distinct_periodic_phase_count(u, spec.origin, spec.period);
445 if distinct_phases < spec.num_basis {
446 crate::bail_invalid_basis!(
447 "periodic curve fit needs at least {} distinct wrapped sample positions for {} basis functions; got {}",
448 spec.num_basis,
449 spec.num_basis,
450 distinct_phases
451 );
452 }
453
454 let basis = build_periodic_bspline_basis_1d(u, spec)?;
455 let mut lhs = basis.t().dot(&basis);
456 if smoothing_lambda > 0.0 {
457 let penalty = cyclic_bspline_derivative_penalty_matrix(
458 spec.degree,
459 spec.num_basis,
460 spec.period,
461 spec.penalty_order,
462 )?;
463 lhs = lhs + smoothing_lambda * penalty;
464 }
465 let rhs = basis.t().dot(&y);
466 let coefficients = solve_spd_cholesky(lhs, &rhs)?;
467 Ok(PeriodicSplineCurve {
468 spec: spec.clone(),
469 coefficients,
470 })
471}
472
473pub fn evaluate_mspline_scalar(
480 x: f64,
481 knot_vector: ArrayView1<f64>,
482 degree: usize,
483 out: &mut [f64],
484 scratch: &mut SplineScratch,
485) -> Result<(), BasisError> {
486 validate_knots_for_degree(knot_vector, degree)?;
487 validate_mspline_normalization_spans(knot_vector, degree)?;
488 let num_basis = knot_vector.len() - degree - 1;
489 if out.len() != num_basis {
490 crate::bail_dim_basis!(
491 "M-spline output buffer length {} does not match basis size {}",
492 out.len(),
493 num_basis
494 );
495 }
496
497 let left = knot_vector[degree];
498 let right = knot_vector[num_basis];
499 if x < left || x > right {
500 out.fill(0.0);
501 return Ok(());
502 }
503
504 out.fill(0.0);
507 if scratch.local.len() < degree + 1 {
508 scratch.local.resize(degree + 1, 0.0);
509 }
510 let local = &mut scratch.local[..degree + 1];
511 local.fill(0.0);
512 let start =
513 internal::evaluate_splines_sparse_into(x, degree, knot_vector, local, &mut scratch.inner);
514 let order = (degree + 1) as f64;
515 for (offset, &b) in local.iter().enumerate() {
516 let i = start + offset;
517 if i >= num_basis {
518 continue;
519 }
520 let span = knot_vector[i + degree + 1] - knot_vector[i];
521 out[i] = b * (order / span);
522 }
523 Ok(())
524}
525
526pub fn evaluate_ispline_scalarwith_scratch(
535 x: f64,
536 knot_vector: ArrayView1<f64>,
537 degree: usize,
538 out: &mut [f64],
539 scratch: &mut SplineScratch,
540) -> Result<(), BasisError> {
541 let bs_degree = degree
542 .checked_add(1)
543 .ok_or_else(|| BasisError::InvalidInput("I-spline degree overflow".to_string()))?;
544 validate_knots_for_degree(knot_vector, bs_degree)?;
545 let num_bspline_basis = knot_vector.len() - bs_degree - 1;
546 let num_ispline_basis = num_bspline_basis.saturating_sub(1);
547 if out.len() != num_ispline_basis {
548 crate::bail_dim_basis!(
549 "I-spline output buffer length {} does not match basis size {}",
550 out.len(),
551 num_ispline_basis
552 );
553 }
554
555 let left = knot_vector[bs_degree];
557 let right = knot_vector[num_bspline_basis];
558 let support = bs_degree + 1;
559 if x < left {
560 out.fill(0.0);
561 return Ok(());
562 }
563 if x >= right {
564 if scratch.left_local.len() < support {
565 scratch.left_local.resize(support, 0.0);
566 }
567 if scratch.left_offsets.len() < num_bspline_basis {
568 scratch.left_offsets.resize(num_bspline_basis, 0.0);
569 }
570 scratch.left_offsets[..num_bspline_basis].fill(0.0);
571 let left_local = &mut scratch.left_local[..support];
572 left_local.fill(0.0);
573 scratch.left_inner.ensure_degree(bs_degree);
574 let left_offsets = &mut scratch.left_offsets[..num_bspline_basis];
575 internal::cumulative_bspline_offsets_into(
576 left,
577 bs_degree,
578 knot_vector,
579 left_local,
580 &mut scratch.left_inner,
581 left_offsets,
582 );
583 for j in 1..num_bspline_basis {
584 let value = 1.0 - left_offsets[j];
585 out[j - 1] = if value.abs() <= 1e-15 { 0.0 } else { value };
586 }
587 return Ok(());
588 }
589
590 out.fill(0.0);
596 if scratch.local.len() < support {
597 scratch.local.resize(support, 0.0);
598 }
599 scratch.local[..support].fill(0.0);
600 scratch.inner.ensure_degree(bs_degree);
601 let local = &mut scratch.local[..support];
602 let start = internal::evaluate_splines_sparse_into(
603 x,
604 bs_degree,
605 knot_vector,
606 local,
607 &mut scratch.inner,
608 );
609
610 let total = local.iter().copied().sum::<f64>();
611 let lead_end = start.min(num_bspline_basis);
612 if lead_end > 1 {
613 out[..(lead_end - 1)].fill(total);
614 }
615
616 let mut running = 0.0f64;
617 for offset in (0..support).rev() {
618 let j = start + offset;
619 if j >= num_bspline_basis {
620 continue;
621 }
622 running += local[offset];
623 if j > 0 {
624 out[j - 1] = running;
625 }
626 }
627
628 if scratch.left_local.len() < support {
630 scratch.left_local.resize(support, 0.0);
631 }
632 if scratch.left_offsets.len() < num_bspline_basis {
633 scratch.left_offsets.resize(num_bspline_basis, 0.0);
634 }
635 scratch.left_offsets[..num_bspline_basis].fill(0.0);
636 let left_local = &mut scratch.left_local[..support];
637 left_local.fill(0.0);
638 scratch.left_inner.ensure_degree(bs_degree);
639 let left_offsets = &mut scratch.left_offsets[..num_bspline_basis];
640 internal::cumulative_bspline_offsets_into(
641 left,
642 bs_degree,
643 knot_vector,
644 left_local,
645 &mut scratch.left_inner,
646 left_offsets,
647 );
648 for j in 1..num_bspline_basis {
649 let out_idx = j - 1;
650 out[out_idx] -= left_offsets[j];
651 if out[out_idx].abs() <= 1e-15 {
652 out[out_idx] = 0.0;
653 }
654 }
655 Ok(())
656}
657
658pub fn create_ispline_derivative_dense(
667 data: ArrayView1<'_, f64>,
668 knot_vector: &Array1<f64>,
669 degree: usize,
670 derivative_order: usize,
671) -> Result<Array2<f64>, BasisError> {
672 if derivative_order == 0 {
673 let (basis_arc, _) = create_basis::<Dense>(
675 data,
676 KnotSource::Provided(knot_vector.view()),
677 degree,
678 BasisOptions::i_spline(),
679 )?;
680 return Ok(basis_arc.as_ref().clone());
681 }
682 let bs_degree = degree
683 .checked_add(1)
684 .ok_or_else(|| BasisError::InvalidInput("I-spline degree overflow".to_string()))?;
685 if derivative_order > bs_degree {
686 let num_bspline_basis = knot_vector.len().saturating_sub(bs_degree + 1);
688 let num_ispline_basis = num_bspline_basis.saturating_sub(1);
689 return Ok(Array2::zeros((data.len(), num_ispline_basis)));
690 }
691 let num_bspline_cols = knot_vector.len().saturating_sub(bs_degree + 1);
692 let db = match derivative_order {
693 1 => {
694 let (db_arc, _) = create_basis::<Dense>(
695 data,
696 KnotSource::Provided(knot_vector.view()),
697 bs_degree,
698 BasisOptions::first_derivative(),
699 )?;
700 db_arc.as_ref().clone()
701 }
702 2 => {
703 let (db_arc, _) = create_basis::<Dense>(
704 data,
705 KnotSource::Provided(knot_vector.view()),
706 bs_degree,
707 BasisOptions::second_derivative(),
708 )?;
709 db_arc.as_ref().clone()
710 }
711 3 => {
712 let mut db = Array2::<f64>::zeros((data.len(), num_bspline_cols));
713 for (row_idx, &x) in data.iter().enumerate() {
714 let row = db.slice_mut(s![row_idx, ..]).into_slice().ok_or_else(|| {
715 BasisError::InvalidInput(
716 "I-spline derivative row is not contiguous".to_string(),
717 )
718 })?;
719 evaluate_bsplinethird_derivative_scalar(x, knot_vector.view(), bs_degree, row)?;
720 }
721 db
722 }
723 4 => {
724 let mut db = Array2::<f64>::zeros((data.len(), num_bspline_cols));
725 for (row_idx, &x) in data.iter().enumerate() {
726 let row = db.slice_mut(s![row_idx, ..]).into_slice().ok_or_else(|| {
727 BasisError::InvalidInput(
728 "I-spline derivative row is not contiguous".to_string(),
729 )
730 })?;
731 evaluate_bspline_fourth_derivative_scalar(x, knot_vector.view(), bs_degree, row)?;
732 }
733 db
734 }
735 other => {
736 crate::bail_invalid_basis!(
737 "I-spline derivative supports orders 1..=4; got order={other}"
738 );
739 }
740 };
741 let num_ispline_cols = num_bspline_cols.saturating_sub(1);
742 if num_ispline_cols == 0 {
743 return Ok(Array2::zeros((data.len(), 0)));
744 }
745 let left = knot_vector[bs_degree];
778 let right = knot_vector[num_bspline_cols];
779 let interval_is_usable = left.is_finite() && right.is_finite() && left < right;
780
781 let mut out = Array2::<f64>::zeros((data.len(), num_ispline_cols));
784 for i in 0..data.len() {
785 if interval_is_usable && !(data[i] >= left && data[i] <= right) {
793 continue;
794 }
795 let mut running = 0.0_f64;
796 for j in (1..num_bspline_cols).rev() {
797 let term = db[[i, j]];
798 if term.is_finite() {
799 running += term;
800 }
801 out[[i, j - 1]] = running;
802 }
803 }
804 Ok(out)
805}
806
807pub fn evaluate_ispline_scalar(
808 x: f64,
809 knot_vector: ArrayView1<f64>,
810 degree: usize,
811 out: &mut [f64],
812) -> Result<(), BasisError> {
813 let bs_degree = degree
814 .checked_add(1)
815 .ok_or_else(|| BasisError::InvalidInput("I-spline degree overflow".to_string()))?;
816 let mut scratch = SplineScratch::new(bs_degree);
817 evaluate_ispline_scalarwith_scratch(x, knot_vector, degree, out, &mut scratch)
818}
819
820pub fn evaluate_bspline_derivative_scalar(
832 x: f64,
833 knot_vector: ArrayView1<f64>,
834 degree: usize,
835 out: &mut [f64],
836) -> Result<(), BasisError> {
837 if degree < 1 {
838 return Err(BasisError::InvalidDegree(degree));
839 }
840 let num_basis_lower = knot_vector.len().saturating_sub(degree);
841 let mut lower_basis = vec![0.0; num_basis_lower];
842 let mut lower_scratch = internal::BsplineScratch::new(degree.saturating_sub(1));
843 evaluate_bspline_derivative_scalar_into(
844 x,
845 knot_vector,
846 degree,
847 out,
848 &mut lower_basis,
849 &mut lower_scratch,
850 )
851}
852
853pub fn evaluate_bspline_derivative_scalar_into(
857 x: f64,
858 knot_vector: ArrayView1<f64>,
859 degree: usize,
860 out: &mut [f64],
861 lower_basis: &mut [f64],
862 lower_scratch: &mut internal::BsplineScratch,
863) -> Result<(), BasisError> {
864 validate_knots_for_degree(knot_vector, degree)?;
865
866 let num_basis = knot_vector.len() - degree - 1;
867 if out.len() != num_basis {
868 return Err(BasisError::InvalidKnotVector(format!(
869 "Output buffer length {} does not match number of basis functions {}",
870 out.len(),
871 num_basis
872 )));
873 }
874
875 let num_basis_lower = knot_vector.len() - degree;
876 if lower_basis.len() < num_basis_lower {
877 return Err(BasisError::InvalidKnotVector(format!(
878 "lower_basis buffer too small: {} < {}",
879 lower_basis.len(),
880 num_basis_lower
881 )));
882 }
883
884 for v in lower_basis.iter_mut().take(num_basis_lower) {
886 *v = 0.0;
887 }
888
889 if open_knot_derivative_exterior_is_zero(x, knot_vector, degree) {
902 out.fill(0.0);
903 return Ok(());
904 }
905 let x_clamped = clamp_eval_point_to_modeling_interval(x, knot_vector, degree);
906 let x_eval = one_sided_derivative_eval_point(x_clamped, knot_vector, degree);
907
908 internal::evaluate_splines_at_point_full_support_into(
910 x_eval,
911 degree - 1,
912 knot_vector,
913 &mut lower_basis[..num_basis_lower],
914 lower_scratch,
915 );
916
917 let k = degree as f64;
919 for i in 0..num_basis {
920 let denom_left = knot_vector[i + degree] - knot_vector[i];
921 let denom_right = knot_vector[i + degree + 1] - knot_vector[i + 1];
922
923 let left_term = if !knot_span_is_degenerate(denom_left) && i < num_basis_lower {
924 lower_basis[i] / denom_left
925 } else {
926 0.0
927 };
928
929 let right_term = if !knot_span_is_degenerate(denom_right) && (i + 1) < num_basis_lower {
930 lower_basis[i + 1] / denom_right
931 } else {
932 0.0
933 };
934
935 out[i] = k * (left_term - right_term);
936 }
937
938 Ok(())
939}
940
941fn mspline_scales(knot_vector: ArrayView1<f64>, degree: usize, num_basis: usize) -> Vec<f64> {
947 let order = (degree + 1) as f64;
948 (0..num_basis)
949 .map(|i| order / (knot_vector[i + degree + 1] - knot_vector[i]))
950 .collect()
951}
952
953pub(crate) fn create_mspline_dense(
954 data: ArrayView1<f64>,
955 knot_vector: ArrayView1<f64>,
956 degree: usize,
957) -> Result<Array2<f64>, BasisError> {
958 validate_knots_for_degree(knot_vector, degree)?;
959 validate_mspline_normalization_spans(knot_vector, degree)?;
960 let num_basis = knot_vector.len() - degree - 1;
961 let mut out = Array2::<f64>::zeros((data.len(), num_basis));
962 let mut scratch = internal::BsplineScratch::new(degree);
963 let support = degree + 1;
964 let mut local = vec![0.0; support];
965 let left = knot_vector[degree];
966 let right = knot_vector[num_basis];
967 let scales = mspline_scales(knot_vector, degree, num_basis);
968
969 for (row_i, &x) in data.iter().enumerate() {
970 if x < left || x > right {
971 continue;
972 }
973 let start = internal::evaluate_splines_sparse_into(
974 x,
975 degree,
976 knot_vector,
977 &mut local,
978 &mut scratch,
979 );
980 for (offset, &b) in local.iter().enumerate() {
981 let j = start + offset;
982 if j < num_basis {
983 out[[row_i, j]] = b * scales[j];
984 }
985 }
986 }
987 Ok(out)
988}
989
990pub(crate) fn create_mspline_sparse(
991 data: ArrayView1<f64>,
992 knot_vector: ArrayView1<f64>,
993 degree: usize,
994) -> Result<SparseColMat<usize, f64>, BasisError> {
995 validate_knots_for_degree(knot_vector, degree)?;
996 validate_mspline_normalization_spans(knot_vector, degree)?;
997 let nrows = data.len();
998 let ncols = knot_vector.len() - degree - 1;
999 let mut scratch = internal::BsplineScratch::new(degree);
1000 let support = degree + 1;
1001 let mut local = vec![0.0; support];
1002 let left = knot_vector[degree];
1003 let right = knot_vector[ncols];
1004 let scales = mspline_scales(knot_vector, degree, ncols);
1005
1006 let mut triplets: Vec<Triplet<usize, usize, f64>> =
1007 Vec::with_capacity(nrows.saturating_mul(support));
1008 for (row_i, &x) in data.iter().enumerate() {
1009 if x < left || x > right {
1010 continue;
1011 }
1012 let start = internal::evaluate_splines_sparse_into(
1013 x,
1014 degree,
1015 knot_vector,
1016 &mut local,
1017 &mut scratch,
1018 );
1019 for (offset, &b) in local.iter().enumerate() {
1020 let col = start + offset;
1021 if col >= ncols {
1022 continue;
1023 }
1024 let v = b * scales[col];
1025 if v.abs() > 0.0 {
1026 triplets.push(Triplet::new(row_i, col, v));
1027 }
1028 }
1029 }
1030
1031 SparseColMat::try_new_from_triplets(nrows, ncols, &triplets)
1032 .map_err(|e| BasisError::SparseCreation(format!("{e:?}")))
1033}
1034
1035pub(crate) fn validate_mspline_normalization_spans(
1036 knot_vector: ArrayView1<f64>,
1037 degree: usize,
1038) -> Result<(), BasisError> {
1039 let num_basis = knot_vector.len().saturating_sub(degree + 1);
1040 for i in 0..num_basis {
1041 let span = knot_vector[i + degree + 1] - knot_vector[i];
1042 if span <= 0.0 {
1043 crate::bail_invalid_basis!(
1044 "invalid M-spline normalization span at i={i}: t[i+degree+1]-t[i]={span:.3e} must be > 0"
1045 );
1046 }
1047 }
1048 Ok(())
1049}
1050
1051pub(crate) fn create_ispline_dense(
1052 data: ArrayView1<f64>,
1053 knot_vector: ArrayView1<f64>,
1054 degree: usize,
1055) -> Result<Array2<f64>, BasisError> {
1056 let bs_degree = degree
1057 .checked_add(1)
1058 .ok_or_else(|| BasisError::InvalidInput("I-spline degree overflow".to_string()))?;
1059 validate_knots_for_degree(knot_vector, bs_degree)?;
1060 let num_bspline_basis = knot_vector.len() - bs_degree - 1;
1061 let num_ispline_basis = num_bspline_basis.saturating_sub(1);
1062 let mut out = Array2::<f64>::zeros((data.len(), num_ispline_basis));
1063 let mut scratch = internal::BsplineScratch::new(bs_degree);
1064 let support = bs_degree + 1;
1065 let mut local = vec![0.0; support];
1066 let left = knot_vector[bs_degree];
1067 let right = knot_vector[num_bspline_basis];
1068
1069 let mut left_local = vec![0.0_f64; support];
1071 let mut left_scratch = internal::BsplineScratch::new(bs_degree);
1072 let mut left_offsets = vec![0.0_f64; num_bspline_basis];
1073 internal::cumulative_bspline_offsets_into(
1074 left,
1075 bs_degree,
1076 knot_vector,
1077 &mut left_local,
1078 &mut left_scratch,
1079 &mut left_offsets,
1080 );
1081
1082 for (row_i, &x) in data.iter().enumerate() {
1095 if x < left {
1096 continue;
1098 }
1099 if x >= right {
1100 for j in 1..num_bspline_basis {
1101 let value = 1.0 - left_offsets[j];
1102 out[[row_i, j - 1]] = if value.abs() <= 1e-15 { 0.0 } else { value };
1103 }
1104 continue;
1105 }
1106 let start = internal::evaluate_splines_sparse_into(
1107 x,
1108 bs_degree,
1109 knot_vector,
1110 &mut local,
1111 &mut scratch,
1112 );
1113 let total = local.iter().copied().sum::<f64>();
1114 let lead_end = start.min(num_bspline_basis);
1115 if lead_end > 1 {
1116 out.slice_mut(s![row_i, 0..(lead_end - 1)]).fill(total);
1117 }
1118 let mut running = 0.0f64;
1119 for offset in (0..support).rev() {
1120 let j = start + offset;
1121 if j >= num_bspline_basis {
1122 continue;
1123 }
1124 running += local[offset];
1125 if j > 0 {
1126 let value = running - left_offsets[j];
1127 out[[row_i, j - 1]] = if value.abs() <= 1e-15 { 0.0 } else { value };
1128 }
1129 }
1130 }
1131 Ok(out)
1132}
1133
1134#[derive(Default)]
1145pub struct BsplineDerivativeWorkspace {
1146 pub(crate) chain: Vec<Vec<f64>>,
1149 pub(crate) lower_basis: Vec<f64>,
1151 pub(crate) lower_scratch: internal::BsplineScratch,
1153}
1154
1155impl BsplineDerivativeWorkspace {
1156 #[inline]
1158 pub fn new() -> Self {
1159 Self::default()
1160 }
1161
1162 #[inline]
1165 pub(crate) fn chain_buffer(&mut self, depth: usize, len: usize) -> &mut [f64] {
1166 if self.chain.len() <= depth {
1167 self.chain.resize_with(depth + 1, Vec::new);
1168 }
1169 let buf = &mut self.chain[depth];
1170 if buf.len() != len {
1171 buf.resize(len, 0.0);
1172 }
1173 for v in buf.iter_mut() {
1174 *v = 0.0;
1175 }
1176 buf
1177 }
1178}
1179
1180pub(crate) fn evaluate_bspline_derivative_recurrence_into(
1198 derivative_order: usize,
1199 x: f64,
1200 knot_vector: ArrayView1<f64>,
1201 degree: usize,
1202 out: &mut [f64],
1203 workspace: &mut BsplineDerivativeWorkspace,
1204 depth: usize,
1205) -> Result<(), BasisError> {
1206 if degree < derivative_order {
1207 return Err(BasisError::InsufficientDegreeForDerivative {
1208 degree,
1209 derivative_order,
1210 minimum_degree: derivative_order,
1211 });
1212 }
1213 if depth == 0
1228 && (open_knot_derivative_exterior_is_zero(x, knot_vector, degree)
1229 || linear_extension_higher_derivative_is_zero(x, knot_vector, degree, derivative_order))
1230 {
1231 out.fill(0.0);
1232 return Ok(());
1233 }
1234 let x = if depth == 0 {
1235 clamp_eval_point_to_modeling_interval(x, knot_vector, degree)
1236 } else {
1237 x
1238 };
1239
1240 if derivative_order <= 1 {
1243 let num_basis_lower = knot_vector.len().saturating_sub(degree);
1244 if workspace.lower_basis.len() < num_basis_lower {
1245 workspace.lower_basis.resize(num_basis_lower, 0.0);
1246 }
1247 return evaluate_bspline_derivative_scalar_into(
1248 x,
1249 knot_vector,
1250 degree,
1251 out,
1252 &mut workspace.lower_basis,
1253 &mut workspace.lower_scratch,
1254 );
1255 }
1256
1257 validate_knots_for_degree(knot_vector, degree)?;
1258
1259 let num_basis = knot_vector.len() - degree - 1;
1260 if out.len() != num_basis {
1261 return Err(BasisError::InvalidKnotVector(format!(
1262 "Output buffer length {} does not match number of basis functions {}",
1263 out.len(),
1264 num_basis
1265 )));
1266 }
1267 let num_basis_lower = knot_vector.len() - degree;
1271
1272 workspace.chain_buffer(depth, num_basis_lower);
1276 let mut lower = std::mem::take(&mut workspace.chain[depth]);
1277
1278 let recurse = evaluate_bspline_derivative_recurrence_into(
1279 derivative_order - 1,
1280 x,
1281 knot_vector,
1282 degree - 1,
1283 &mut lower,
1284 workspace,
1285 depth + 1,
1286 );
1287 workspace.chain[depth] = lower;
1288 recurse?;
1289
1290 let lower = &workspace.chain[depth];
1291 let k = degree as f64;
1292 for i in 0..num_basis {
1293 let denom1 = knot_vector[i + degree] - knot_vector[i];
1294 let denom2 = knot_vector[i + degree + 1] - knot_vector[i + 1];
1295 let term1 = if !knot_span_is_degenerate(denom1) {
1296 k * lower[i] / denom1
1297 } else {
1298 0.0
1299 };
1300 let term2 = if !knot_span_is_degenerate(denom2) {
1301 k * lower[i + 1] / denom2
1302 } else {
1303 0.0
1304 };
1305 out[i] = term1 - term2;
1306 }
1307
1308 Ok(())
1309}
1310
1311pub fn evaluate_bsplinesecond_derivative_scalar(
1320 x: f64,
1321 knot_vector: ArrayView1<f64>,
1322 degree: usize,
1323 out: &mut [f64],
1324) -> Result<(), BasisError> {
1325 let mut workspace = BsplineDerivativeWorkspace::new();
1326 evaluate_bspline_derivative_recurrence_into(2, x, knot_vector, degree, out, &mut workspace, 0)
1327}
1328
1329pub fn evaluate_bsplinethird_derivative_scalar(
1338 x: f64,
1339 knot_vector: ArrayView1<f64>,
1340 degree: usize,
1341 out: &mut [f64],
1342) -> Result<(), BasisError> {
1343 let mut workspace = BsplineDerivativeWorkspace::new();
1344 evaluate_bspline_derivative_recurrence_into(3, x, knot_vector, degree, out, &mut workspace, 0)
1345}
1346
1347pub fn evaluate_bspline_fourth_derivative_scalar(
1356 x: f64,
1357 knot_vector: ArrayView1<f64>,
1358 degree: usize,
1359 out: &mut [f64],
1360) -> Result<(), BasisError> {
1361 let mut workspace = BsplineDerivativeWorkspace::new();
1362 evaluate_bspline_derivative_recurrence_into(4, x, knot_vector, degree, out, &mut workspace, 0)
1363}
1364
1365#[cfg(test)]
1383mod ispline_exterior_derivative_2695_tests {
1384 use super::*;
1385
1386 fn clamped_knots() -> Array1<f64> {
1389 Array1::from_vec(vec![
1390 -3.0, -3.0, -3.0, -3.0, -1.5, 0.0, 1.5, 3.0, 3.0, 3.0, 3.0,
1391 ])
1392 }
1393
1394 const DEGREE: usize = 2;
1396
1397 fn value_row(x: f64) -> Vec<f64> {
1398 let knots = clamped_knots();
1399 let data = Array1::from_vec(vec![x]);
1400 create_ispline_dense(data.view(), knots.view(), DEGREE)
1401 .expect("i-spline value")
1402 .row(0)
1403 .to_vec()
1404 }
1405
1406 fn derivative_row(x: f64, order: usize) -> Vec<f64> {
1407 let knots = clamped_knots();
1408 let data = Array1::from_vec(vec![x]);
1409 create_ispline_derivative_dense(data.view(), &knots, DEGREE, order)
1410 .expect("i-spline derivative")
1411 .row(0)
1412 .to_vec()
1413 }
1414
1415 #[test]
1418 fn the_ispline_value_is_constant_outside_the_modelling_interval() {
1419 for (a, b) in [(-4.0, -8.0), (4.0, 9.0)] {
1420 let left = value_row(a);
1421 let right = value_row(b);
1422 assert_eq!(
1423 left.len(),
1424 right.len(),
1425 "the basis width must not depend on the evaluation point"
1426 );
1427 for (j, (lo, hi)) in left.iter().zip(right.iter()).enumerate() {
1428 assert_eq!(
1429 lo.to_bits(),
1430 hi.to_bits(),
1431 "I_{j}({a}) = {lo} but I_{j}({b}) = {hi}; the I-spline value is \
1432 documented as saturating outside the knot domain"
1433 );
1434 }
1435 }
1436 }
1437
1438 #[test]
1442 fn the_ispline_derivative_matches_a_finite_difference_inside_the_interval() {
1443 let x = 0.4_f64;
1444 let h = 1.0e-5;
1445 let plus = value_row(x + h);
1446 let minus = value_row(x - h);
1447 let analytic = derivative_row(x, 1);
1448 let mut any_nonzero = false;
1449 for (j, value) in analytic.iter().enumerate() {
1450 let fd = (plus[j] - minus[j]) / (2.0 * h);
1451 assert!(
1452 (fd - value).abs() <= 1.0e-6 * (1.0 + value.abs()),
1453 "interior column {j}: analytic I'_{j}({x}) = {value:.9e} but the central \
1454 difference of the value is {fd:.9e}"
1455 );
1456 any_nonzero |= value.abs() > 1.0e-6;
1457 }
1458 assert!(
1459 any_nonzero,
1460 "the interior control must exercise a non-zero derivative"
1461 );
1462 }
1463
1464 #[test]
1466 fn the_ispline_derivative_is_zero_where_its_value_saturates() {
1467 for x in [-4.0_f64, -3.5, 3.5, 4.0, 12.0] {
1468 for order in 1..=4 {
1469 for (j, value) in derivative_row(x, order).iter().enumerate() {
1470 assert_eq!(
1471 *value, 0.0,
1472 "order-{order} I-spline derivative at x={x} (outside the knot domain \
1473 [-3, 3], where the value is constant) reports {value:.9e} in column \
1474 {j}; a constant function has zero derivative"
1475 );
1476 }
1477 }
1478 }
1479 }
1480
1481 #[test]
1486 fn the_monotone_warp_multiplier_is_one_where_the_warp_is_flat() {
1487 let beta_w = [0.30_f64, 0.40, 0.50, 0.60, 0.70, 0.80];
1488 for x in [-5.0_f64, 5.0] {
1489 let d1 = derivative_row(x, 1);
1490 assert_eq!(
1491 d1.len(),
1492 beta_w.len(),
1493 "fixture coefficient width must match the basis"
1494 );
1495 let m1: f64 = 1.0 + d1.iter().zip(beta_w.iter()).map(|(b, c)| b * c).sum::<f64>();
1496 assert_eq!(
1497 m1, 1.0,
1498 "at x={x} the warp value is constant, so its multiplier must be exactly 1"
1499 );
1500 }
1501 }
1502}