1use super::*;
2
3pub(crate) static DUCHON_DESIGN_BUILD_COUNT: std::sync::atomic::AtomicUsize =
11 std::sync::atomic::AtomicUsize::new(0);
12
13pub(crate) fn duchon_coeff_exponents(p_order: usize, s_order: usize, m_or_n: usize) -> f64 {
14 -2.0 * (p_order + s_order - m_or_n) as f64
23}
24
25#[inline(always)]
26pub(crate) fn duchon_scaling_exponent(p_order: usize, s_order: usize, k_dim: usize) -> f64 {
27 k_dim as f64 - 2.0 * (p_order + s_order) as f64
28}
29
30#[derive(Clone, Copy)]
31pub(crate) struct DuchonMaternDerivativeTerm {
32 pub(crate) coeff: f64,
33 pub(crate) kappa_power: usize,
34 pub(crate) r_power: f64,
35 pub(crate) bessel_order: f64,
36}
37
38#[derive(Clone, Copy, Debug, Default)]
39pub(crate) struct DuchonRadialJets {
40 pub(crate) phi: f64,
41 pub(crate) phi_r: f64,
42 pub(crate) phi_rr: f64,
43 pub(crate) phi_rrr: f64,
44 pub(crate) q: f64,
45 pub(crate) q_r: f64,
46 pub(crate) q_rr: f64,
47 pub(crate) lap: f64,
48 pub(crate) lap_r: f64,
49 pub(crate) lap_rr: f64,
50 pub(crate) t: f64,
54 pub(crate) t_r: f64,
58 pub(crate) t_rr: f64,
65}
66
67#[derive(Clone, Copy, Debug, Default)]
68pub(crate) struct DuchonRegularizedOperatorCore {
69 pub(crate) q: f64,
70 pub(crate) t: f64,
71 pub(crate) t_r: f64,
72 pub(crate) t_rr: f64,
73}
74
75#[inline(always)]
76pub(crate) fn duchon_operator_jets_from_primary_core(
77 core: DuchonRegularizedOperatorCore,
78 r: f64,
79 d: f64,
80) -> DuchonRadialJets {
81 let r2 = r * r;
82 let mut out = DuchonRadialJets {
83 q: core.q,
84 t: core.t,
85 t_r: core.t_r,
86 t_rr: core.t_rr,
87 ..DuchonRadialJets::default()
88 };
89 out.q_r = r * out.t;
90 out.q_rr = out.t + r * out.t_r;
91 out.lap = d * out.q + r2 * out.t;
92 out.lap_r = (d + 2.0) * r * out.t + r2 * out.t_r;
93 out.lap_rr = (d + 2.0) * out.t + (d + 4.0) * r * out.t_r + r2 * out.t_rr;
94 out.phi_r = r * out.q;
95 out.phi_rr = out.q + r2 * out.t;
96 out.phi_rrr = 3.0 * r * out.t + r2 * out.t_r;
97
98 assert!(
99 ((out.phi_rr - (out.q + r * out.q_r)).abs()) <= 1e-10 * out.phi_rr.abs().max(1.0),
100 "radial scalar identity failed: phi_rr != q + r*q_r, phi_rr={}, q={}, r={}, q_r={}",
101 out.phi_rr,
102 out.q,
103 r,
104 out.q_r
105 );
106 assert!(
107 ((out.phi_rr - (out.q + r2 * out.t)).abs()) <= 1e-10 * out.phi_rr.abs().max(1.0),
108 "radial scalar identity failed: phi_rr != q + r2*t, phi_rr={}, q={}, r2={}, t={}",
109 out.phi_rr,
110 out.q,
111 r2,
112 out.t
113 );
114 assert!(
115 ((out.lap - (d * out.q + r2 * out.t)).abs()) <= 1e-10 * out.lap.abs().max(1.0),
116 "radial scalar identity failed: lap != d*q + r2*t, lap={}, d={}, q={}, r2={}, t={}",
117 out.lap,
118 d,
119 out.q,
120 r2,
121 out.t
122 );
123
124 out
125}
126
127#[inline(always)]
128pub(crate) fn scaled_log_kappa_derivatives(
129 value: f64,
130 radial_first: f64,
131 radialsecond: f64,
132 exponent: f64,
133 r: f64,
134) -> (f64, f64) {
135 let first = exponent * value + r * radial_first;
158 let second = exponent * exponent * value
159 + (2.0 * exponent + 1.0) * r * radial_first
160 + r * r * radialsecond;
161 (first, second)
162}
163
164pub(crate) fn duchon_partial_fraction_kernel_psi_triplet(
190 r: f64,
191 length_scale: f64,
192 p_order: usize,
193 s_order: usize,
194 k_dim: usize,
195 coeffs: &DuchonPartialFractionCoeffs,
196) -> Result<(f64, f64, f64), BasisError> {
197 assert!(
198 !duchon_hybrid_stable_integral_applies(p_order, s_order, k_dim),
199 "partial-fraction psi jet called for the stable-integral Duchon route"
200 );
201 let smoothness_order = 2 * (p_order + s_order);
202 let collision_taylor_radius = DUCHON_COLLISION_TAYLOR_REL * length_scale.max(1e-8);
203 if r <= collision_taylor_radius && smoothness_order > k_dim {
204 let r2 = r * r;
205 let mut value = 0.0_f64;
206 let mut first = 0.0_f64;
207 let mut second = 0.0_f64;
208 let mut r_power = 1.0_f64;
209 for j in 0..=3 {
210 if smoothness_order <= k_dim + 2 * j {
211 break;
212 }
213 let (derivative, derivative_first, derivative_second) =
214 duchon_phi_even_derivative_collision_psi_triplet(
215 length_scale,
216 p_order,
217 s_order,
218 k_dim,
219 coeffs,
220 j,
221 )?;
222 let factorial = gamma_lanczos((2 * j + 1) as f64);
223 let weight = r_power / factorial;
224 value += weight * derivative;
225 first += weight * derivative_first;
226 second += weight * derivative_second;
227 r_power *= r2;
228 }
229 return Ok((value, first, second));
230 }
231
232 let kappa = duchon_inverse_length_scale(length_scale, "Duchon partial-fraction ψ-triplet")?;
233 let kappa2 = kappa * kappa;
234 let mut value = KahanSum::default();
235 let mut first = KahanSum::default();
236 let mut second = KahanSum::default();
237 for (m, &coefficient) in coeffs.a.iter().enumerate().skip(1) {
238 if coefficient == 0.0 {
239 continue;
240 }
241 let block = polyharmonic_kernel(r, m as f64, k_dim);
242 let exponent = duchon_coeff_exponents(p_order, s_order, m);
243 value.add(coefficient * block);
244 first.add(exponent * coefficient * block);
245 second.add(exponent * exponent * coefficient * block);
246 }
247 for (n, &coefficient) in coeffs.b.iter().enumerate().skip(1) {
248 if coefficient == 0.0 {
249 continue;
250 }
251 let block = duchon_matern_block(r, kappa, n, k_dim)?;
252 let next = duchon_matern_block(r, kappa, n + 1, k_dim)?;
253 let next_next = duchon_matern_block(r, kappa, n + 2, k_dim)?;
254 let block_first = -2.0 * n as f64 * kappa2 * next;
255 let block_second = -4.0 * n as f64 * kappa2 * next
256 + 4.0 * n as f64 * (n + 1) as f64 * kappa2 * kappa2 * next_next;
257 let exponent = duchon_coeff_exponents(p_order, s_order, n);
258 value.add(coefficient * block);
259 first.add(coefficient * (exponent * block + block_first));
260 second.add(
261 coefficient
262 * (exponent * exponent * block
263 + 2.0 * exponent * block_first
264 + block_second),
265 );
266 }
267 let triplet = (value.sum(), first.sum(), second.sum());
268 if !(triplet.0.is_finite() && triplet.1.is_finite() && triplet.2.is_finite()) {
269 crate::bail_invalid_basis!(
270 "non-finite Duchon partial-fraction psi jet at r={r}, length_scale={length_scale}, p={p_order}, s={s_order}, dim={k_dim}"
271 );
272 }
273 Ok(triplet)
274}
275
276#[derive(Clone, Copy, Debug, PartialEq, Eq)]
296pub enum DuchonPsiDirection {
297 Global,
301 Axis(usize),
303}
304
305#[inline(always)]
330pub fn duchon_axis_log_kappa_derivatives(
331 value: f64,
332 radial_first: f64,
333 radialsecond: f64,
334 exponent: f64,
335 r: f64,
336 dim: usize,
337 axis_share: f64,
338 second_axis_share: f64,
339 same_axis: bool,
340) -> (f64, f64) {
341 let a_term = r * radial_first;
342 let b_term = r * r * radialsecond - a_term;
343 let c = exponent / dim.max(1) as f64;
344 let first = c * value + a_term * axis_share;
345 let second = b_term * axis_share * second_axis_share
346 + c * a_term * (axis_share + second_axis_share)
347 + if same_axis {
348 2.0 * a_term * axis_share
349 } else {
350 0.0
351 }
352 + c * c * value;
353 (first, second)
354}
355
356#[inline(always)]
362pub fn duchon_direction_derivatives(
363 direction: DuchonPsiDirection,
364 value: f64,
365 radial_first: f64,
366 radialsecond: f64,
367 exponent: f64,
368 r: f64,
369 dim: usize,
370 axis_shares: &[f64],
371) -> (f64, f64) {
372 match direction {
373 DuchonPsiDirection::Global => {
374 scaled_log_kappa_derivatives(value, radial_first, radialsecond, exponent, r)
375 }
376 DuchonPsiDirection::Axis(a) => {
377 let share = axis_shares.get(a).copied().unwrap_or(0.0);
378 duchon_axis_log_kappa_derivatives(
379 value,
380 radial_first,
381 radialsecond,
382 exponent,
383 r,
384 dim,
385 share,
386 share,
387 true,
388 )
389 }
390 }
391}
392
393#[inline(always)]
401pub fn duchon_axis_shares(components: &[f64], r: f64) -> Vec<f64> {
402 let d = components.len().max(1);
403 let r2 = r * r;
404 if !(r2 > 0.0) || !r2.is_finite() {
405 return vec![1.0 / d as f64; components.len()];
406 }
407 components.iter().map(|&s| s / r2).collect()
408}
409
410#[inline(always)]
411pub(crate) fn duchon_q_psi_triplet_from_jets(
412 jets: &DuchonRadialJets,
413 p_order: usize,
414 s_order: usize,
415 k_dim: usize,
416 r: f64,
417) -> (f64, f64) {
418 scaled_log_kappa_derivatives(
419 jets.q,
420 jets.q_r,
421 jets.q_rr,
422 duchon_operator_scaling_exponent(p_order, s_order, k_dim),
423 r,
424 )
425}
426
427#[inline(always)]
428pub(crate) fn duchon_operator_scaling_exponent(
429 p_order: usize,
430 s_order: usize,
431 k_dim: usize,
432) -> f64 {
433 duchon_scaling_exponent(p_order, s_order, k_dim) + 2.0
449}
450
451pub(crate) fn duchon_regularized_operator_core(
452 r_eval: f64,
453 kappa: f64,
454 k_dim: usize,
455 coeffs: &DuchonPartialFractionCoeffs,
456) -> Result<DuchonRegularizedOperatorCore, BasisError> {
457 let mut q_sum = KahanSum::default();
461 let mut t_sum = KahanSum::default();
462 let mut t_r_sum = KahanSum::default();
463 let mut t_rr_sum = KahanSum::default();
464
465 for (m, coeff) in coeffs.a.iter().enumerate().skip(1) {
466 if *coeff == 0.0 {
467 continue;
468 }
469 let (q, t, t_r, t_rr) = duchon_polyharmonic_operator_block_jets(r_eval, m, k_dim)?;
470 q_sum.add(coeff * q);
471 t_sum.add(coeff * t);
472 t_r_sum.add(coeff * t_r);
473 t_rr_sum.add(coeff * t_rr);
474 }
475 let max_ladder_steps = coeffs
480 .b
481 .iter()
482 .enumerate()
483 .skip(1)
484 .filter(|(_, coeff)| **coeff != 0.0)
485 .map(|(n, _)| duchon_matern_block_max_ladder_steps(n, k_dim))
486 .max();
487 if let Some(max_ladder_steps) = max_ladder_steps {
488 let ladder =
489 BesselKLadder::build(kappa * r_eval, !k_dim.is_multiple_of(2), max_ladder_steps);
490 for (n, coeff) in coeffs.b.iter().enumerate().skip(1) {
491 if *coeff == 0.0 {
492 continue;
493 }
494 let (q, t, t_r, t_rr) =
495 duchon_matern_operator_block_jets_with_ladder(r_eval, kappa, n, k_dim, &ladder)?;
496 q_sum.add(coeff * q);
497 t_sum.add(coeff * t);
498 t_r_sum.add(coeff * t_r);
499 t_rr_sum.add(coeff * t_rr);
500 }
501 }
502 Ok(DuchonRegularizedOperatorCore {
503 q: q_sum.sum(),
504 t: t_sum.sum(),
505 t_r: t_r_sum.sum(),
506 t_rr: t_rr_sum.sum(),
507 })
508}
509
510#[inline(always)]
511pub(crate) fn duchon_collision_taylor_operator_core(
512 r: f64,
513 phi_rr_collision: f64,
514 t_collision: f64,
515 t_rr_collision: f64,
516) -> DuchonRegularizedOperatorCore {
517 let r2 = r * r;
518 let r4 = r2 * r2;
519 DuchonRegularizedOperatorCore {
520 q: phi_rr_collision + 0.5 * t_collision * r2 + 0.125 * t_rr_collision * r4,
521 t: t_collision + 0.5 * t_rr_collision * r2,
522 t_r: t_rr_collision * r,
523 t_rr: t_rr_collision,
524 }
525}
526
527pub(crate) fn duchon_radial_jets(
528 r: f64,
529 length_scale: f64,
530 p_order: usize,
531 s_order: usize,
532 k_dim: usize,
533 coeffs: &DuchonPartialFractionCoeffs,
534) -> Result<DuchonRadialJets, BasisError> {
535 let kappa = duchon_inverse_length_scale(length_scale, "Duchon radial jets")?;
536 let r_floor = DUCHON_DERIVATIVE_R_FLOOR_REL * length_scale;
540 let collision_taylor_radius = DUCHON_COLLISION_TAYLOR_REL * length_scale;
541 let r_eval = r.max(r_floor);
542 let d = k_dim as f64;
543
544 let hybrid = duchon_hybrid_evaluator(Some(length_scale), p_order, s_order, k_dim)?;
548 let phi = match hybrid.as_ref() {
550 Some(hybrid) => hybrid.value(r)?,
551 None => duchon_matern_kernel_general_from_distance(
552 r,
553 Some(length_scale),
554 p_order,
555 s_order,
556 k_dim,
557 Some(coeffs),
558 )?,
559 };
560 if !phi.is_finite() {
561 crate::bail_invalid_basis!(
562 "non-finite Duchon radial kernel value at r={r}, length_scale={length_scale}, p={p_order}, s={s_order}, dim={k_dim}"
563 );
564 }
565
566 let operator_core = match hybrid.as_ref() {
578 Some(hybrid) => hybrid.operator_core(r_eval)?,
579 None => duchon_regularized_operator_core(r_eval, kappa, k_dim, coeffs)?,
580 };
581 let generic_jets = duchon_operator_jets_from_primary_core(operator_core, r_eval, d);
582 let mut out = DuchonRadialJets {
583 phi,
584 ..generic_jets
585 };
586
587 let smoothness_order = 2 * (p_order + s_order);
594 let collision_q_exists = smoothness_order > k_dim + 2;
595 let collision_t_exists = smoothness_order > k_dim + 4;
596 let collision_t_rr_exists = smoothness_order > k_dim + 6;
597
598 if r <= collision_taylor_radius.max(r_floor) && collision_t_exists {
599 let (analytic_phi_rr, _, _) =
603 duchonphi_rr_collision_psi_triplet(length_scale, p_order, s_order, k_dim, coeffs)?;
604 let analytic_t_collision =
605 duchon_phi_rrrr_collision(length_scale, p_order, s_order, k_dim, coeffs)? / 3.0;
606 let analytic_t_rr_collision = if collision_t_rr_exists {
607 duchon_phi_rrrrrr_collision(length_scale, p_order, s_order, k_dim, coeffs)? / 15.0
608 } else {
609 0.0
613 };
614 let collision_jets = duchon_operator_jets_from_primary_core(
615 duchon_collision_taylor_operator_core(
616 r,
617 analytic_phi_rr,
618 analytic_t_collision,
619 analytic_t_rr_collision,
620 ),
621 r,
622 d,
623 );
624 out = DuchonRadialJets {
625 phi: out.phi,
626 ..collision_jets
627 };
628 } else if r < r_floor && collision_q_exists {
629 let (analytic_phi_rr, _, _) =
635 duchonphi_rr_collision_psi_triplet(length_scale, p_order, s_order, k_dim, coeffs)?;
636 out.phi_r = analytic_phi_rr * r;
637 out.phi_rr = analytic_phi_rr;
638 out.q = analytic_phi_rr;
639 out.q_r = 0.0;
640 out.lap = d * analytic_phi_rr;
641 out.lap_r = 0.0;
642 }
643 if !out.phi_r.is_finite()
644 || !out.phi_rr.is_finite()
645 || !out.phi_rrr.is_finite()
646 || !out.q.is_finite()
647 || !out.q_r.is_finite()
648 || !out.q_rr.is_finite()
649 || !out.lap.is_finite()
650 || !out.lap_r.is_finite()
651 || !out.lap_rr.is_finite()
652 || !out.t.is_finite()
653 || !out.t_r.is_finite()
654 || !out.t_rr.is_finite()
655 {
656 crate::bail_invalid_basis!(
657 "non-finite Duchon radial jets at r={r}, length_scale={length_scale}, p={p_order}, s={s_order}, dim={k_dim}"
658 );
659 }
660 Ok(out)
661}
662
663pub(crate) struct DuchonRadialCoreValueJet {
699 pub(crate) value: f64,
700 pub(crate) first: f64,
701 pub(crate) second: f64,
702 pub(crate) exponent: f64,
703}
704
705pub(crate) fn duchon_radial_core_value_jet(
706 r: f64,
707 length_scale: f64,
708 p_order: usize,
709 s_order: usize,
710 k_dim: usize,
711 coeffs: &DuchonPartialFractionCoeffs,
712) -> Result<DuchonRadialCoreValueJet, BasisError> {
713 let jets = duchon_radial_jets(r, length_scale, p_order, s_order, k_dim, coeffs)?;
714 Ok(DuchonRadialCoreValueJet {
715 value: jets.phi,
716 first: jets.phi_r,
717 second: jets.phi_rr,
718 exponent: duchon_scaling_exponent(p_order, s_order, k_dim),
719 })
720}
721
722pub(crate) fn duchonphi_rr_collision_psi_triplet(
723 length_scale: f64,
724 p_order: usize,
725 s_order: usize,
726 k_dim: usize,
727 coeffs: &DuchonPartialFractionCoeffs,
728) -> Result<(f64, f64, f64), BasisError> {
729 duchon_phi_even_derivative_collision_psi_triplet(
741 length_scale,
742 p_order,
743 s_order,
744 k_dim,
745 coeffs,
746 1,
747 )
748}
749
750pub(crate) const EULER_MASCHERONI: f64 = 0.577_215_664_901_532_9;
752
753#[inline(always)]
757pub(crate) fn digamma_pos_int(n: usize) -> f64 {
758 assert!(n >= 1, "digamma_pos_int requires n >= 1: n={n}");
759 let mut h = 0.0_f64;
760 for j in 1..n {
761 h += 1.0 / j as f64;
762 }
763 -EULER_MASCHERONI + h
764}
765
766pub(crate) fn duchon_matern_block_taylor_r2j(
780 kappa: f64,
781 n_order: usize,
782 k_dim: usize,
783 j: usize,
784) -> (f64, f64) {
785 let n = n_order as f64;
786 let k_half = 0.5 * k_dim as f64;
787 let nu = n - k_half;
788 let c = kappa.powf(k_half - n)
790 / ((2.0 * std::f64::consts::PI).powf(k_half) * 2.0_f64.powf(n - 1.0) * gamma_lanczos(n));
791
792 if k_dim.is_multiple_of(2) {
793 let nu_int = n_order as i64 - (k_dim as i64) / 2;
795 duchon_matern_block_taylor_r2j_integer_nu(kappa, c, nu_int, j)
796 } else {
797 duchon_matern_block_taylor_r2j_half_integer_nu(kappa, c, nu, j)
799 }
800}
801
802#[inline(always)]
803pub(crate) fn psi_power_triplet(value: f64, exponent: f64) -> (f64, f64, f64) {
804 (value, exponent * value, exponent * exponent * value)
805}
806
807#[inline(always)]
808pub(crate) fn psi_power_log_triplet(
809 base: f64,
810 exponent: f64,
811 log_kappa_half: f64,
812) -> (f64, f64, f64) {
813 (
814 base * log_kappa_half,
815 base * (exponent * log_kappa_half + 1.0),
816 base * (exponent * exponent * log_kappa_half + 2.0 * exponent),
817 )
818}
819
820#[inline(always)]
821pub(crate) fn add_triplet(dst: &mut (f64, f64, f64), inc: (f64, f64, f64)) {
822 dst.0 += inc.0;
823 dst.1 += inc.1;
824 dst.2 += inc.2;
825}
826
827pub(crate) fn duchon_matern_block_taylor_r2j_triplet(
831 kappa: f64,
832 n_order: usize,
833 k_dim: usize,
834 j: usize,
835) -> ((f64, f64, f64), (f64, f64, f64)) {
836 let n = n_order as f64;
837 let k_half = 0.5 * k_dim as f64;
838 let nu = n - k_half;
839 let c_const = 1.0
840 / ((2.0 * std::f64::consts::PI).powf(k_half) * 2.0_f64.powf(n - 1.0) * gamma_lanczos(n));
841 let c_exp = k_half - n;
842
843 let mut pure = (0.0, 0.0, 0.0);
844 let mut log_part = (0.0, 0.0, 0.0);
845 let log_kappa_half = (0.5 * kappa).ln();
846
847 if k_dim.is_multiple_of(2) {
848 let nu_int = n_order as i64 - (k_dim as i64) / 2;
849 let mu = nu_int.unsigned_abs() as usize;
850 let sign_mu = if mu.is_multiple_of(2) { 1.0 } else { -1.0 };
851
852 if nu_int >= 0 {
853 let nu_usize = nu_int as usize;
854
855 if j < nu_usize {
856 let sign = if j.is_multiple_of(2) { 1.0 } else { -1.0 };
857 let power = 2 * j as i32 - nu_usize as i32;
858 let coeff = 0.5 * sign * gamma_lanczos((nu_usize - j) as f64)
859 / gamma_lanczos((j + 1) as f64)
860 * 2.0_f64.powi(-power);
861 let exponent = c_exp + power as f64;
862 let value = c_const * coeff * kappa.powf(exponent);
863 add_triplet(&mut pure, psi_power_triplet(value, exponent));
864 }
865
866 if j >= nu_usize {
867 let k = j - nu_usize;
868 let inv_fac = 1.0
869 / (gamma_lanczos((k + 1) as f64) * gamma_lanczos((nu_usize + k + 1) as f64));
870 let power = (2 * k + nu_usize) as i32;
871 let exponent = c_exp + power as f64;
872 let kp_base = c_const * kappa.powf(exponent) * 2.0_f64.powi(-power);
873
874 let log_base = -sign_mu * kp_base * inv_fac;
875 add_triplet(&mut log_part, psi_power_triplet(log_base, exponent));
876 add_triplet(
877 &mut pure,
878 psi_power_log_triplet(log_base, exponent, log_kappa_half),
879 );
880
881 let psi_sum = digamma_pos_int(k + 1) + digamma_pos_int(nu_usize + k + 1);
882 let digamma_base = sign_mu * 0.5 * kp_base * inv_fac * psi_sum;
883 add_triplet(&mut pure, psi_power_triplet(digamma_base, exponent));
884 }
885 } else {
886 let k = j;
887 let inv_fac =
888 1.0 / (gamma_lanczos((k + 1) as f64) * gamma_lanczos((mu + k + 1) as f64));
889 let power = (mu + 2 * k) as i32;
890 let exponent = c_exp + power as f64;
891 let kp_base = c_const * kappa.powf(exponent) * 2.0_f64.powi(-power);
892
893 let log_base = -sign_mu * kp_base * inv_fac;
894 add_triplet(&mut log_part, psi_power_triplet(log_base, exponent));
895 add_triplet(
896 &mut pure,
897 psi_power_log_triplet(log_base, exponent, log_kappa_half),
898 );
899
900 let psi_sum = digamma_pos_int(k + 1) + digamma_pos_int(mu + k + 1);
901 let digamma_base = sign_mu * 0.5 * kp_base * inv_fac * psi_sum;
902 add_triplet(&mut pure, psi_power_triplet(digamma_base, exponent));
903 }
904 } else {
905 let nu_abs = nu.abs();
906 let l = (nu_abs - 0.5).round().max(0.0) as usize;
912 let prefactor_const = (std::f64::consts::PI / 2.0).sqrt();
913 let prefactor_exp = -0.5;
914 let target = 2 * j;
915
916 for i in 0..=l {
917 let c_i = gamma_lanczos((l + i + 1) as f64)
918 / (gamma_lanczos((i + 1) as f64) * gamma_lanczos((l - i + 1) as f64));
919 let p_f64 = nu - 0.5 - i as f64;
920 let p_round = p_f64.round() as i64;
921 if (p_f64 - p_round as f64).abs() > 1e-12 {
922 continue;
923 }
924 let q_needed = target as i64 - p_round;
925 if q_needed < 0 {
926 continue;
927 }
928 let q = q_needed as usize;
929 let sign = if q.is_multiple_of(2) { 1.0 } else { -1.0 };
930 let exponent = c_exp + prefactor_exp - i as f64 + q as f64;
931 let value = c_const * prefactor_const * c_i * 2.0_f64.powi(-(i as i32)) * sign
932 / gamma_lanczos((q + 1) as f64)
933 * kappa.powf(exponent);
934 add_triplet(&mut pure, psi_power_triplet(value, exponent));
935 }
936 }
937
938 (pure, log_part)
939}
940
941pub(crate) fn duchon_matern_block_taylor_r2j_integer_nu(
953 kappa: f64,
954 c: f64,
955 nu_int: i64,
956 j: usize,
957) -> (f64, f64) {
958 let mu = nu_int.unsigned_abs() as usize; let kappa_half = 0.5 * kappa;
962
963 if nu_int >= 0 {
964 let nu = nu_int as usize;
965 let mut pure = 0.0;
970 let mut log_part = 0.0;
971
972 if j < nu {
974 let sign = if j.is_multiple_of(2) { 1.0 } else { -1.0 };
976 let coeff = sign * gamma_lanczos((nu - j) as f64) / gamma_lanczos((j + 1) as f64)
977 * kappa_half.powi(2 * j as i32 - nu as i32)
978 * 0.5;
979 pure += coeff;
980 }
981
982 if j >= nu {
984 let k = j - nu;
985 let inv_fac =
986 1.0 / (gamma_lanczos((k + 1) as f64) * gamma_lanczos((nu + k + 1) as f64));
987 let kp = kappa_half.powi(2 * k as i32 + nu as i32);
988 let sign_mu = if mu.is_multiple_of(2) { 1.0 } else { -1.0 }; log_part += -sign_mu * kp * inv_fac;
992
993 let psi_sum = digamma_pos_int(k + 1) + digamma_pos_int(nu + k + 1);
998 pure += -sign_mu * kp * inv_fac * kappa_half.ln();
999 pure += sign_mu * 0.5 * kp * inv_fac * psi_sum;
1000 }
1001
1002 (c * pure, c * log_part)
1003 } else {
1004 let k = j;
1008 let inv_fac = 1.0 / (gamma_lanczos((k + 1) as f64) * gamma_lanczos((mu + k + 1) as f64));
1009 let kp = kappa_half.powi(mu as i32 + 2 * k as i32);
1010 let sign_mu = if mu.is_multiple_of(2) { 1.0 } else { -1.0 };
1011
1012 let log_part = -sign_mu * kp * inv_fac;
1014
1015 let psi_sum = digamma_pos_int(k + 1) + digamma_pos_int(mu + k + 1);
1017 let pure =
1018 -sign_mu * kp * inv_fac * kappa_half.ln() + sign_mu * 0.5 * kp * inv_fac * psi_sum;
1019
1020 (c * pure, c * log_part)
1021 }
1022}
1023
1024pub(crate) fn duchon_matern_block_taylor_r2j_half_integer_nu(
1035 kappa: f64,
1036 c: f64,
1037 nu: f64,
1038 j: usize,
1039) -> (f64, f64) {
1040 let nu_abs = nu.abs();
1041 let l = (nu_abs - 0.5).round().max(0.0) as usize;
1045 let prefactor = (std::f64::consts::PI / (2.0 * kappa)).sqrt();
1053
1054 let target = 2 * j;
1061 let mut pure = 0.0;
1062
1063 for i in 0..=l {
1064 let c_i = gamma_lanczos((l + i + 1) as f64)
1065 / (gamma_lanczos((i + 1) as f64) * gamma_lanczos((l - i + 1) as f64));
1066 let inv_2kappa_i = (2.0 * kappa).powi(-(i as i32));
1067
1068 let p_f64 = nu - 0.5 - i as f64;
1070 let p_round = p_f64.round() as i64;
1071 if (p_f64 - p_round as f64).abs() > 1e-12 {
1072 continue;
1074 }
1075 let q_needed = target as i64 - p_round;
1076 if q_needed < 0 {
1077 continue;
1078 }
1079 let q = q_needed as usize;
1080 let exp_coeff = (-kappa).powi(q as i32) / gamma_lanczos((q + 1) as f64);
1081 pure += c_i * inv_2kappa_i * exp_coeff;
1082 }
1083
1084 (c * prefactor * pure, 0.0) }
1086
1087pub(crate) fn duchon_polyharmonic_block_taylor_r2j(m: usize, k_dim: usize, j: usize) -> (f64, f64) {
1095 let k_half = 0.5 * k_dim as f64;
1096 let alpha = 2 * m as i64 - k_dim as i64;
1097
1098 if alpha != 2 * j as i64 {
1099 return (0.0, 0.0);
1100 }
1101
1102 if k_dim.is_multiple_of(2) && m >= k_dim / 2 {
1104 let c = polyharmonic_log_sign(m, k_dim)
1106 / (2.0_f64.powi((2 * m - 1) as i32)
1107 * std::f64::consts::PI.powf(k_half)
1108 * gamma_lanczos(m as f64)
1109 * gamma_lanczos((m - k_dim / 2 + 1) as f64));
1110 (0.0, c)
1111 } else {
1112 let c = gamma_lanczos(k_half - m as f64)
1114 / (4.0_f64.powi(m as i32)
1115 * std::f64::consts::PI.powf(k_half)
1116 * gamma_lanczos(m as f64));
1117 (c, 0.0)
1118 }
1119}
1120
1121pub(crate) fn duchon_phi_even_derivative_collision(
1137 length_scale: f64,
1138 p_order: usize,
1139 s_order: usize,
1140 k_dim: usize,
1141 coeffs: &DuchonPartialFractionCoeffs,
1142 j: usize,
1143) -> Result<f64, BasisError> {
1144 let smoothness_order = 2 * (p_order + s_order);
1145 let required = k_dim + 2 * j;
1146
1147 if smoothness_order <= required {
1148 return Err(BasisError::duchon_smoothness_insufficient(
1151 format!("collision derivative phi^({})", 2 * j),
1152 2 * j,
1153 k_dim,
1154 p_order,
1155 s_order as f64,
1156 ));
1157 }
1158
1159 let kappa = duchon_inverse_length_scale(length_scale, "Duchon even-derivative collision")?;
1161 let mut total_pure = KahanSum::default();
1162 let mut total_log = KahanSum::default();
1163 let mut total_log_abs_scale = KahanSum::default();
1164
1165 for (m, &a_m) in coeffs.a.iter().enumerate().skip(1) {
1167 if a_m == 0.0 {
1168 continue;
1169 }
1170 let (pure, log) = duchon_polyharmonic_block_taylor_r2j(m, k_dim, j);
1171 total_pure.add(a_m * pure);
1172 total_log.add(a_m * log);
1173 total_log_abs_scale.add((a_m * log).abs());
1174 }
1175
1176 for (n, &b_n) in coeffs.b.iter().enumerate().skip(1) {
1178 if b_n == 0.0 {
1179 continue;
1180 }
1181 let (pure, log) = duchon_matern_block_taylor_r2j(kappa, n, k_dim, j);
1182 total_pure.add(b_n * pure);
1183 total_log.add(b_n * log);
1184 total_log_abs_scale.add((b_n * log).abs());
1185 }
1186 let total_pure = total_pure.sum();
1187 let total_log = total_log.sum();
1188 let total_log_abs_scale = total_log_abs_scale.sum();
1189
1190 let log_cancel_tol = 1e-10 * total_log_abs_scale.max(total_pure.abs()).max(1e-30);
1193 if total_log.abs() > log_cancel_tol {
1194 crate::bail_invalid_basis!(
1195 "Duchon Taylor a_{} log-coefficient did not cancel: log={total_log:.6e}, pure={total_pure:.6e}; \
1196 log_abs_scale={total_log_abs_scale:.6e}, tol={log_cancel_tol:.6e}; p={p_order}, s={s_order}, d={k_dim}",
1197 2 * j
1198 );
1199 }
1200
1201 let factorial_2j = gamma_lanczos((2 * j + 1) as f64);
1203 Ok(factorial_2j * total_pure)
1204}
1205
1206pub(crate) fn duchon_phi_even_derivative_collision_psi_triplet(
1207 length_scale: f64,
1208 p_order: usize,
1209 s_order: usize,
1210 k_dim: usize,
1211 coeffs: &DuchonPartialFractionCoeffs,
1212 j: usize,
1213) -> Result<(f64, f64, f64), BasisError> {
1214 let smoothness_order = 2 * (p_order + s_order);
1215 let required = k_dim + 2 * j;
1216
1217 if smoothness_order <= required {
1218 return Err(BasisError::duchon_smoothness_insufficient(
1222 format!("collision derivative phi^({}) psi triplet", 2 * j),
1223 2 * j,
1224 k_dim,
1225 p_order,
1226 s_order as f64,
1227 ));
1228 }
1229
1230 let kappa = duchon_inverse_length_scale(length_scale, "Duchon even-derivative collision ψ-triplet")?;
1231 let mut value = KahanSum::default();
1232 let mut psi = KahanSum::default();
1233 let mut psi_psi = KahanSum::default();
1234 let mut log_value = KahanSum::default();
1235 let mut log_psi = KahanSum::default();
1236 let mut log_psi_psi = KahanSum::default();
1237 let mut log_abs_scale = KahanSum::default();
1238
1239 for (m, &a_m) in coeffs.a.iter().enumerate().skip(1) {
1240 if a_m == 0.0 {
1241 continue;
1242 }
1243 let alpha_m = duchon_coeff_exponents(p_order, s_order, m);
1244 let (pure, log) = duchon_polyharmonic_block_taylor_r2j(m, k_dim, j);
1245 value.add(a_m * pure);
1246 psi.add(alpha_m * a_m * pure);
1247 psi_psi.add(alpha_m * alpha_m * a_m * pure);
1248 log_value.add(a_m * log);
1249 log_psi.add(alpha_m * a_m * log);
1250 log_psi_psi.add(alpha_m * alpha_m * a_m * log);
1251 log_abs_scale.add((a_m * log).abs());
1252 log_abs_scale.add((alpha_m * a_m * log).abs());
1253 log_abs_scale.add((alpha_m * alpha_m * a_m * log).abs());
1254 }
1255
1256 for (n, &b_n) in coeffs.b.iter().enumerate().skip(1) {
1257 if b_n == 0.0 {
1258 continue;
1259 }
1260 let beta_n = duchon_coeff_exponents(p_order, s_order, n);
1261 let (pure, log) = duchon_matern_block_taylor_r2j_triplet(kappa, n, k_dim, j);
1262 value.add(b_n * pure.0);
1263 psi.add(beta_n * b_n * pure.0 + b_n * pure.1);
1264 psi_psi.add(beta_n * beta_n * b_n * pure.0 + 2.0 * beta_n * b_n * pure.1 + b_n * pure.2);
1265 log_value.add(b_n * log.0);
1266 log_psi.add(beta_n * b_n * log.0 + b_n * log.1);
1267 log_psi_psi.add(beta_n * beta_n * b_n * log.0 + 2.0 * beta_n * b_n * log.1 + b_n * log.2);
1268 let log_v = b_n * log.0;
1269 let log_p = beta_n * b_n * log.0 + b_n * log.1;
1270 let log_pp = beta_n * beta_n * b_n * log.0 + 2.0 * beta_n * b_n * log.1 + b_n * log.2;
1271 log_abs_scale.add(log_v.abs());
1272 log_abs_scale.add(log_p.abs());
1273 log_abs_scale.add(log_pp.abs());
1274 }
1275
1276 let value = value.sum();
1277 let psi = psi.sum();
1278 let psi_psi = psi_psi.sum();
1279 let log_value = log_value.sum();
1280 let log_psi = log_psi.sum();
1281 let log_psi_psi = log_psi_psi.sum();
1282 let log_abs_scale = log_abs_scale.sum();
1283 let scale = value.abs().max(psi.abs()).max(psi_psi.abs()).max(1e-30);
1284 let log_cancel_tol = 1e-10 * log_abs_scale.max(scale);
1285 if log_value.abs().max(log_psi.abs()).max(log_psi_psi.abs()) > log_cancel_tol {
1286 crate::bail_invalid_basis!(
1287 "Duchon Taylor a_{} log-coefficient derivative did not cancel: \
1288 log=({log_value:.6e}, {log_psi:.6e}, {log_psi_psi:.6e}), \
1289 value=({value:.6e}, {psi:.6e}, {psi_psi:.6e}), log_abs_scale={log_abs_scale:.6e}, tol={log_cancel_tol:.6e}; \
1290 p={p_order}, s={s_order}, d={k_dim}",
1291 2 * j
1292 );
1293 }
1294
1295 let factorial_2j = gamma_lanczos((2 * j + 1) as f64);
1296 Ok((
1297 factorial_2j * value,
1298 factorial_2j * psi,
1299 factorial_2j * psi_psi,
1300 ))
1301}
1302
1303pub(crate) fn duchon_phi_rrrr_collision(
1315 length_scale: f64,
1316 p_order: usize,
1317 s_order: usize,
1318 k_dim: usize,
1319 coeffs: &DuchonPartialFractionCoeffs,
1320) -> Result<f64, BasisError> {
1321 duchon_phi_even_derivative_collision(length_scale, p_order, s_order, k_dim, coeffs, 2)
1322}
1323
1324pub(crate) fn duchon_phi_rrrrrr_collision(
1336 length_scale: f64,
1337 p_order: usize,
1338 s_order: usize,
1339 k_dim: usize,
1340 coeffs: &DuchonPartialFractionCoeffs,
1341) -> Result<f64, BasisError> {
1342 duchon_phi_even_derivative_collision(length_scale, p_order, s_order, k_dim, coeffs, 3)
1343}
1344
1345pub(crate) fn duchon_frozen_radial_chart(
1368 z_kernel: Array2<f64>,
1369 spec: &DuchonBasisSpec,
1370 site: &str,
1371) -> Result<Array2<f64>, BasisError> {
1372 let Some(v) = spec.radial_reparam.as_ref() else {
1373 if z_kernel.ncols() == 0 {
1374 return Ok(z_kernel);
1377 }
1378 crate::bail_invalid_basis!(
1379 "Duchon {site} ψ-derivative requires the frozen data-metric radial reparam V, but the spec carries none while the constrained kernel block has {} columns. The forward build adopts a fresh V(ψ) for this spec, so a derivative taken in the raw Z chart is the exact derivative of a different penalty (#2638). Replay BasisMetadata::Duchon::radial_reparam onto the spec first, as the κ-optimizer does.",
1380 z_kernel.ncols()
1381 );
1382 };
1383 if v.nrows() != z_kernel.ncols() {
1384 crate::bail_dim_basis!(
1385 "Duchon frozen radial reparam shape {:?} does not match constrained kernel dimension {}",
1386 v.dim(),
1387 z_kernel.ncols()
1388 );
1389 }
1390 Ok(fast_ab(&z_kernel, v))
1391}
1392
1393pub(crate) fn build_duchon_design_psi_derivativeswithworkspace(
1394 data: ArrayView2<'_, f64>,
1395 centers: ArrayView2<'_, f64>,
1396 spec: &DuchonBasisSpec,
1397 identifiability_transform: Option<&Array2<f64>>,
1398 workspace: &mut BasisWorkspace,
1399) -> Result<ScalarDesignPsiDerivatives, BasisError> {
1400 let length_scale = spec.length_scale.ok_or_else(|| {
1401 BasisError::InvalidInput(
1402 "exact Duchon log-kappa derivatives require hybrid Duchon with length_scale"
1403 .to_string(),
1404 )
1405 })?;
1406 let effective_nullspace_order = duchon_effective_nullspace_order(centers, spec.nullspace_order);
1412 let p_order = duchon_p_from_nullspace_order(effective_nullspace_order);
1413 let s_order = spec.power_as_usize();
1414 let kappa = 1.0 / length_scale;
1415 let coeffs = duchon_partial_fraction_coeffs(p_order, s_order, kappa);
1416 let z_kernel = duchon_frozen_radial_chart(
1419 kernel_constraint_nullspace(centers, effective_nullspace_order, &mut workspace.cache)?,
1420 spec,
1421 "design",
1422 )?;
1423 let poly_cols = polynomial_block_from_order(data, effective_nullspace_order).ncols();
1424 let p_padded = z_kernel.ncols() + poly_cols;
1425 if let Some(zf) = identifiability_transform
1426 && p_padded != zf.nrows()
1427 {
1428 crate::bail_dim_basis!(
1429 "Duchon identifiability transform mismatch in design derivatives: local cols={}, transform rows={}",
1430 p_padded,
1431 zf.nrows()
1432 );
1433 }
1434 let p_final = identifiability_transform
1435 .map(|zf| zf.ncols())
1436 .unwrap_or(p_padded);
1437 let chart = duchon_kernel_chart(
1440 centers,
1441 Some(length_scale),
1442 p_order,
1443 s_order,
1444 data.ncols(),
1445 spec.aniso_log_scales.as_deref(),
1446 Some(&coeffs),
1447 None,
1448 )
1449 .design_chart();
1450 build_scalar_design_psi_derivatives_shared(
1451 data,
1452 centers,
1453 spec.aniso_log_scales.as_deref(),
1454 p_final,
1455 Some(z_kernel),
1456 identifiability_transform.cloned(),
1457 poly_cols,
1458 RadialScalarKind::Duchon {
1459 length_scale,
1460 p_order,
1461 s_order,
1462 dim: data.ncols(),
1463 coeffs,
1464 },
1465 duchon_scaling_exponent(p_order, s_order, data.ncols()),
1466 chart,
1467 )
1468}
1469
1470pub(crate) fn duchon_operator_penalties_requested(spec: &DuchonOperatorPenaltySpec) -> bool {
1471 matches!(spec.mass, OperatorPenaltySpec::Active { .. })
1472 || matches!(spec.tension, OperatorPenaltySpec::Active { .. })
1473 || matches!(spec.stiffness, OperatorPenaltySpec::Active { .. })
1474}
1475
1476pub fn build_duchon_basis_log_kappa_aniso_derivativeswith_collocationwithworkspace(
1491 data: ArrayView2<'_, f64>,
1492 spec: &DuchonBasisSpec,
1493 centers: ArrayView2<'_, f64>,
1494 identifiability_transform: Option<&Array2<f64>>,
1495 operator_collocation_points: Option<ArrayView2<'_, f64>>,
1496 workspace: &mut BasisWorkspace,
1497) -> Result<AnisoBasisPsiDerivatives, BasisError> {
1498 let dim = data.ncols();
1499 if !crate::basis::duchon_spec_supports_axis_psi(spec, dim) {
1500 crate::bail_invalid_basis!(
1501 "Duchon per-axis ψ derivatives requested for a spec whose per-axis surface is not \
1502 derived (dim={dim}, length_scale={:?}, periodic={}, power={})",
1503 spec.length_scale,
1504 spec.periodic.is_some(),
1505 spec.power
1506 );
1507 }
1508 let length_scale = spec.length_scale.expect("capability check requires a hybrid scale");
1509 let eta = spec
1510 .aniso_log_scales
1511 .clone()
1512 .expect("capability check requires resolved anisotropy");
1513 let effective_nullspace_order = duchon_effective_nullspace_order(centers, spec.nullspace_order);
1514 let p_order = duchon_p_from_nullspace_order(effective_nullspace_order);
1515 let s_order = spec.power_as_usize();
1516 let coeffs = duchon_partial_fraction_coeffs(p_order, s_order, 1.0 / length_scale);
1517 let z_kernel = duchon_frozen_radial_chart(
1518 kernel_constraint_nullspace(centers, effective_nullspace_order, &mut workspace.cache)?,
1519 spec,
1520 "aniso design",
1521 )?;
1522 let poly_cols = polynomial_block_from_order(data, effective_nullspace_order).ncols();
1523 let p_padded = z_kernel.ncols() + poly_cols;
1524 if let Some(zf) = identifiability_transform
1525 && p_padded != zf.nrows()
1526 {
1527 crate::bail_dim_basis!(
1528 "Duchon identifiability transform mismatch in aniso design derivatives: local cols={}, transform rows={}",
1529 p_padded,
1530 zf.nrows()
1531 );
1532 }
1533 let p_final = identifiability_transform
1534 .map(|zf| zf.ncols())
1535 .unwrap_or(p_padded);
1536 let chart = duchon_kernel_chart(
1539 centers,
1540 Some(length_scale),
1541 p_order,
1542 s_order,
1543 dim,
1544 Some(eta.as_slice()),
1545 Some(&coeffs),
1546 None,
1547 )
1548 .design_chart();
1549 let mut result = build_aniso_design_psi_derivatives_shared(
1550 data,
1551 centers,
1552 &eta,
1553 p_final,
1554 Some(z_kernel),
1555 identifiability_transform.cloned(),
1556 poly_cols,
1557 RadialScalarKind::Duchon {
1558 length_scale,
1559 p_order,
1560 s_order,
1561 dim,
1562 coeffs,
1563 },
1564 chart,
1565 )?;
1566
1567 let directions: Vec<DuchonPsiDirection> = (0..dim).map(DuchonPsiDirection::Axis).collect();
1568 let native = crate::basis::build_duchon_native_penalty_psi_derivatives_in_directions(
1569 centers,
1570 spec,
1571 identifiability_transform,
1572 workspace,
1573 &directions,
1574 )?;
1575 let operator = if duchon_operator_penalties_requested(&spec.operator_penalties) {
1576 let Some(collocation_points) = operator_collocation_points else {
1577 crate::bail_invalid_basis!(
1578 "Duchon per-axis operator penalty derivatives require realized collocation points"
1579 );
1580 };
1581 crate::basis::build_duchon_operator_penalty_psi_derivatives_in_directions(
1582 collocation_points,
1583 centers,
1584 spec,
1585 identifiability_transform,
1586 workspace,
1587 &directions,
1588 )?
1589 } else {
1590 vec![(Vec::new(), Vec::new(), Vec::new()); dim]
1591 };
1592
1593 let mut penalties_first = Vec::with_capacity(dim);
1597 let mut penalties_second_diag = Vec::with_capacity(dim);
1598 let expected = native[0].0.len() + operator[0].0.len();
1599 for axis in 0..dim {
1600 if native[axis].0.len() != native[0].0.len()
1601 || operator[axis].0.len() != operator[0].0.len()
1602 {
1603 crate::bail_invalid_basis!(
1604 "Duchon per-axis penalty source counts disagree across axes: axis {axis} has \
1605 {}+{} blocks, axis 0 has {}+{}",
1606 native[axis].0.len(),
1607 operator[axis].0.len(),
1608 native[0].0.len(),
1609 operator[0].0.len()
1610 );
1611 }
1612 let mut first = Vec::with_capacity(expected);
1613 let mut second = Vec::with_capacity(expected);
1614 first.extend(native[axis].1.iter().cloned());
1615 first.extend(operator[axis].1.iter().cloned());
1616 second.extend(native[axis].2.iter().cloned());
1617 second.extend(operator[axis].2.iter().cloned());
1618 if first.len() != expected || second.len() != expected {
1619 crate::bail_invalid_basis!(
1620 "Duchon per-axis penalty derivative count mismatch on axis {axis}: assembled \
1621 {}/{} against {expected} active sources",
1622 first.len(),
1623 second.len()
1624 );
1625 }
1626 penalties_first.push(first);
1627 penalties_second_diag.push(second);
1628 }
1629 result.penalties_first = penalties_first;
1630 result.penalties_second_diag = penalties_second_diag;
1631 result.penalties_cross_pairs = Vec::new();
1636 result.penalties_cross_provider = None;
1637 Ok(result)
1638}
1639
1640pub fn build_duchon_basis_log_kappa_derivativeswith_collocationwithworkspace(
1641 data: ArrayView2<'_, f64>,
1642 spec: &DuchonBasisSpec,
1643 centers: ArrayView2<'_, f64>,
1644 identifiability_transform: Option<&Array2<f64>>,
1645 operator_collocation_points: Option<ArrayView2<'_, f64>>,
1646 workspace: &mut BasisWorkspace,
1647) -> Result<BasisPsiDerivativeBundle, BasisError> {
1648 let design_derivatives = build_duchon_design_psi_derivativeswithworkspace(
1649 data,
1650 centers,
1651 spec,
1652 identifiability_transform,
1653 workspace,
1654 )?;
1655 let (native_sources, native_first, native_second) =
1656 build_duchon_native_penalty_psi_derivatives(
1657 centers,
1658 spec,
1659 identifiability_transform,
1660 workspace,
1661 )?;
1662 let (operator_sources, operator_first, operator_second) = if duchon_operator_penalties_requested(
1663 &spec.operator_penalties,
1664 ) {
1665 let Some(collocation_points) = operator_collocation_points else {
1666 crate::bail_invalid_basis!(
1667 "Duchon log-kappa operator penalty derivatives require realized collocation points"
1668 );
1669 };
1670 build_duchon_operator_penalty_psi_derivatives(
1671 collocation_points,
1672 centers,
1673 spec,
1674 identifiability_transform,
1675 workspace,
1676 )?
1677 } else {
1678 (Vec::new(), Vec::new(), Vec::new())
1679 };
1680 let mut penalties_derivative = Vec::with_capacity(native_first.len() + operator_first.len());
1681 penalties_derivative.extend(native_first);
1682 penalties_derivative.extend(operator_first);
1683 let mut penaltiessecond_derivative =
1684 Vec::with_capacity(native_second.len() + operator_second.len());
1685 penaltiessecond_derivative.extend(native_second);
1686 penaltiessecond_derivative.extend(operator_second);
1687 let expected_derivative_count = native_sources.len() + operator_sources.len();
1688 if penalties_derivative.len() != expected_derivative_count {
1689 crate::bail_invalid_basis!(
1690 "Duchon penalty derivative count mismatch: assembled {}, expected {} from active penalty sources",
1691 penalties_derivative.len(),
1692 expected_derivative_count
1693 );
1694 }
1695 Ok(BasisPsiDerivativeBundle {
1696 first: BasisPsiDerivativeResult {
1697 design_derivative: design_derivatives.design_first,
1698 penalties_derivative,
1699 implicit_operator: None,
1700 },
1701 second: BasisPsiSecondDerivativeResult {
1702 designsecond_derivative: design_derivatives.design_second_diag,
1703 penaltiessecond_derivative,
1704 implicit_operator: None,
1705 },
1706 implicit_operator: design_derivatives.implicit_operator,
1707 })
1708}
1709
1710pub(crate) fn duchon_kernel_amplification(
1728 centers: ArrayView2<'_, f64>,
1729 length_scale: Option<f64>,
1730 p_order: usize,
1731 s_order: usize,
1732 d: usize,
1733 aniso_log_scales: Option<&[f64]>,
1734 coeffs: Option<&DuchonPartialFractionCoeffs>,
1735 pure_poly_coeff: Option<&PolyharmonicBlockCoeff>,
1736) -> f64 {
1737 duchon_kernel_chart(
1738 centers,
1739 length_scale,
1740 p_order,
1741 s_order,
1742 d,
1743 aniso_log_scales,
1744 coeffs,
1745 pure_poly_coeff,
1746 )
1747 .amplification
1748}
1749
1750#[derive(Clone, Copy, Debug)]
1763pub(crate) struct DuchonKernelChart {
1764 pub(crate) amplification: f64,
1766 pub(crate) reference_pair: Option<(usize, usize)>,
1769}
1770
1771impl DuchonKernelChart {
1772 pub(crate) const IDENTITY: Self = Self {
1773 amplification: 1.0,
1774 reference_pair: None,
1775 };
1776
1777 pub(crate) fn design_chart(&self) -> crate::basis::DesignKernelChart {
1778 crate::basis::DesignKernelChart {
1779 scale: self.amplification,
1780 reference_pair: self.reference_pair,
1781 }
1782 }
1783}
1784
1785pub(crate) fn duchon_kernel_chart(
1786 centers: ArrayView2<'_, f64>,
1787 length_scale: Option<f64>,
1788 p_order: usize,
1789 s_order: usize,
1790 d: usize,
1791 aniso_log_scales: Option<&[f64]>,
1792 coeffs: Option<&DuchonPartialFractionCoeffs>,
1793 pure_poly_coeff: Option<&PolyharmonicBlockCoeff>,
1794) -> DuchonKernelChart {
1795 let k = centers.nrows();
1796 if k == 0 {
1797 return DuchonKernelChart::IDENTITY;
1798 }
1799 let axis_scales = aniso_log_scales.map(aniso_axis_scales);
1800 let hybrid = duchon_hybrid_evaluator(length_scale, p_order, s_order, d)
1803 .ok()
1804 .flatten();
1805 let mut max_abs = 0.0_f64;
1806 let mut reference_pair = None;
1807 for i in 0..k {
1808 for j in i..k {
1809 let r = if let Some(scales) = axis_scales.as_deref() {
1810 aniso_distance_rows_with_scales(centers, i, centers, j, scales)
1811 } else {
1812 euclidean_distance_rows(centers, i, centers, j)
1813 };
1814 let val = if let Some(ppc) = pure_poly_coeff {
1815 ppc.eval(r)
1816 } else if let Some(hybrid) = hybrid.as_ref() {
1817 match hybrid.value(r) {
1818 Ok(v) => v,
1819 Err(_) => continue,
1820 }
1821 } else {
1822 match duchon_matern_kernel_general_from_distance(
1823 r,
1824 length_scale,
1825 p_order,
1826 s_order,
1827 d,
1828 coeffs,
1829 ) {
1830 Ok(v) => v,
1831 Err(_) => continue,
1832 }
1833 };
1834 if val.abs() > max_abs {
1835 max_abs = val.abs();
1836 reference_pair = Some((i, j));
1837 }
1838 }
1839 }
1840 if max_abs > 0.0 && max_abs < 1e-10 {
1844 DuchonKernelChart {
1845 amplification: 1.0 / max_abs,
1846 reference_pair,
1847 }
1848 } else {
1849 DuchonKernelChart::IDENTITY
1850 }
1851}
1852
1853pub fn duchon_pure_kernel_amplification(
1870 centers: ArrayView2<'_, f64>,
1871 order: DuchonNullspaceOrder,
1872 power: f64,
1873) -> f64 {
1874 let dim = centers.ncols();
1875 if dim == 0 || centers.nrows() == 0 {
1876 return 1.0;
1877 }
1878 let effective_order = duchon_effective_nullspace_order(centers, order);
1879 let p_order = duchon_p_from_nullspace_order(effective_order);
1880 let s_order: f64 = power;
1881 let pure_poly_coeff =
1882 PolyharmonicBlockCoeff::new(pure_duchon_block_order(p_order, s_order), dim);
1883 duchon_kernel_amplification(
1884 centers,
1885 None,
1886 p_order,
1887 duchon_power_to_usize(s_order),
1888 dim,
1889 None,
1890 None,
1891 Some(&pure_poly_coeff),
1892 )
1893}
1894
1895pub(crate) fn build_duchon_basis_designwithworkspace(
1896 data: ArrayView2<'_, f64>,
1897 centers: ArrayView2<'_, f64>,
1898 length_scale: Option<f64>,
1899 power: f64,
1900 nullspace_order: DuchonNullspaceOrder,
1901 aniso_log_scales: Option<&[f64]>,
1902 radial_reparam: Option<&Array2<f64>>,
1903 spectral_kernel_transform: Option<&Array2<f64>>,
1904 workspace: &mut BasisWorkspace,
1905) -> Result<DuchonBasisDesign, BasisError> {
1906 DUCHON_DESIGN_BUILD_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1907 let n = data.nrows();
1908 let d = data.ncols();
1909 let k = centers.nrows();
1910
1911 if d == 0 {
1912 crate::bail_invalid_basis!("Duchon basis requires at least one covariate dimension");
1913 }
1914 if k == 0 {
1915 crate::bail_invalid_basis!("Duchon basis requires at least one center");
1916 }
1917 if centers.ncols() != d {
1918 crate::bail_dim_basis!(
1919 "Duchon basis dimension mismatch: data has {d} columns, centers have {}",
1920 centers.ncols()
1921 );
1922 }
1923 if data.iter().any(|v| !v.is_finite()) || centers.iter().any(|v| !v.is_finite()) {
1924 crate::bail_invalid_basis!("Duchon basis requires finite data and center values");
1925 }
1926 let nullspace_order = duchon_effective_nullspace_order(centers, nullspace_order);
1929 let p_order = duchon_p_from_nullspace_order(nullspace_order);
1930 let s_order: f64 = power;
1931 let validation_power = if length_scale.is_some() {
1939 duchon_power_to_usize(s_order) as f64
1940 } else {
1941 s_order
1942 };
1943 validate_duchon_kernel_orders(length_scale, p_order, validation_power, d)?;
1944
1945 let center_mean: Vec<f64> = (0..d)
1961 .map(|c| centers.column(c).sum() / (k.max(1) as f64))
1962 .collect();
1963 let mut data_centered = data.to_owned();
1964 for c in 0..d {
1965 let mu = center_mean[c];
1966 data_centered.column_mut(c).mapv_inplace(|v| v - mu);
1967 }
1968
1969 let poly_block = polynomial_block_from_order(data_centered.view(), nullspace_order);
1970 if radial_reparam.is_some() && spectral_kernel_transform.is_some() {
1979 crate::bail_invalid_basis!(
1980 "Duchon design cannot combine landmark radial reparameterization with a direct \
1981 spectral kernel transform"
1982 );
1983 }
1984 let z_raw = if let Some(spectral) = spectral_kernel_transform {
1985 if spectral.nrows() != centers.nrows() {
1986 crate::bail_dim_basis!(
1987 "Duchon spectral kernel transform shape {:?} does not match {} centers",
1988 spectral.dim(),
1989 centers.nrows()
1990 );
1991 }
1992 spectral.clone()
1993 } else {
1994 kernel_constraint_nullspace(centers, nullspace_order, &mut workspace.cache)?
1995 };
1996 let z = if let Some(v) = radial_reparam {
2002 if v.nrows() != z_raw.ncols() {
2003 crate::bail_dim_basis!(
2004 "Duchon radial reparam shape {:?} does not match constrained kernel dimension {}",
2005 v.dim(),
2006 z_raw.ncols()
2007 );
2008 }
2009 fast_ab(&z_raw, v)
2010 } else {
2011 z_raw
2012 };
2013
2014 let coeffs = length_scale
2015 .map(|ls| {
2016 duchon_inverse_length_scale(ls, "Duchon basis design").map(|kappa| {
2017 duchon_partial_fraction_coeffs(p_order, duchon_power_to_usize(s_order), kappa)
2018 })
2019 })
2020 .transpose()?;
2021
2022 let warn_bounds = match (length_scale, aniso_log_scales) {
2029 (Some(_), Some(eta)) => {
2030 let y_centers = points_in_aniso_y_space(centers, eta);
2031 pairwise_distance_bounds(y_centers.view())
2032 }
2033 (Some(_), None) => pairwise_distance_bounds(centers),
2034 (None, _) => None,
2035 };
2036 if let (Some(length_scale), Some((r_min, r_max))) = (length_scale, warn_bounds) {
2037 let kappa = duchon_inverse_length_scale(length_scale, "Duchon basis operating range")?;
2038 let kappa_lo = 1e-2 / r_max;
2039 let kappa_hi = 1e2 / r_min;
2040 if kappa < kappa_lo || kappa > kappa_hi {
2041 log::debug!(
2042 "Duchon κ={} is outside recommended range [{}, {}] derived from centers (r_min={}, r_max={}); numerical conditioning may degrade",
2043 kappa,
2044 kappa_lo,
2045 kappa_hi,
2046 r_min,
2047 r_max
2048 );
2049 }
2050 }
2051
2052 let kernel_cols = z.ncols();
2053 let poly_cols = poly_block.ncols();
2054 let total_cols = kernel_cols + poly_cols;
2055
2056 let pure_poly_coeff = if length_scale.is_none() {
2059 Some(PolyharmonicBlockCoeff::new(
2060 (pure_duchon_block_order(p_order, s_order)) as f64,
2061 d,
2062 ))
2063 } else {
2064 None
2065 };
2066
2067 let axis_scales = aniso_log_scales.map(aniso_axis_scales);
2068 let kernel_amp = duchon_kernel_amplification(
2069 centers,
2070 length_scale,
2071 p_order,
2072 duchon_power_to_usize(s_order),
2073 d,
2074 aniso_log_scales,
2075 coeffs.as_ref(),
2076 pure_poly_coeff.as_ref(),
2077 );
2078 let hybrid_eval = if pure_poly_coeff.is_some() {
2090 None
2091 } else {
2092 duchon_hybrid_evaluator(length_scale, p_order, duchon_power_to_usize(s_order), d)?
2093 };
2094 let hybrid_kind = match (length_scale, coeffs.as_ref()) {
2095 (Some(ls), Some(c)) if pure_poly_coeff.is_none() => Some(RadialScalarKind::Duchon {
2096 length_scale: ls,
2097 p_order,
2098 s_order: duchon_power_to_usize(s_order),
2099 dim: d,
2100 coeffs: c.clone(),
2101 }),
2102 _ => None,
2103 };
2104 let value_profile = hybrid_kind.as_ref().filter(|_| hybrid_eval.is_none()).and_then(|kind| {
2109 if n.saturating_mul(k) < RADIAL_PROFILE_MIN_PAIRS {
2110 return None;
2111 }
2112 let (r_lo, r_hi) = (0..n)
2113 .into_par_iter()
2114 .map(|i| {
2115 let mut lo = f64::INFINITY;
2116 let mut hi = 0.0_f64;
2117 for j in 0..k {
2118 let r = if let Some(scales) = axis_scales.as_deref() {
2119 aniso_distance_rows_with_scales(data, i, centers, j, scales)
2120 } else {
2121 euclidean_distance_rows(data, i, centers, j)
2122 };
2123 if r > 0.0 {
2124 lo = lo.min(r);
2125 hi = hi.max(r);
2126 }
2127 }
2128 (lo, hi)
2129 })
2130 .reduce(
2131 || (f64::INFINITY, 0.0_f64),
2132 |a, b| (a.0.min(b.0), a.1.max(b.1)),
2133 );
2134 if r_lo.is_finite() && r_hi > r_lo {
2135 radial_profile::RadialProfile::build(kind, r_lo, r_hi)
2136 } else {
2137 None
2138 }
2139 });
2140 let mut basis = Array2::<f64>::zeros((n, total_cols));
2141 let chunk_size = 1024.min(n);
2144 let basis_result: Result<(), BasisError> = basis
2145 .axis_chunks_iter_mut(Axis(0), chunk_size)
2146 .into_par_iter()
2147 .enumerate()
2148 .try_for_each(|(ci, mut chunk)| {
2149 let rows = chunk.nrows();
2150 let chunk_start = ci * chunk_size;
2151 let mut kernel_block = Array2::<f64>::zeros((rows, k));
2154 for local_i in 0..rows {
2155 let i = chunk_start + local_i;
2156 let mut kernel_row = kernel_block.row_mut(local_i);
2157 for j in 0..k {
2158 let r = if let Some(scales) = axis_scales.as_deref() {
2159 aniso_distance_rows_with_scales(data, i, centers, j, scales)
2160 } else {
2161 euclidean_distance_rows(data, i, centers, j)
2162 };
2163 let raw = if let Some(ref ppc) = pure_poly_coeff {
2164 ppc.eval(r)
2166 } else if let Some(hybrid) = hybrid_eval.as_ref() {
2167 hybrid.value(r)?
2168 } else if let (Some(profile), Some(kind)) =
2169 (value_profile.as_ref(), hybrid_kind.as_ref())
2170 {
2171 profile.eval_or_exact(kind, r)?.0
2172 } else {
2173 duchon_matern_kernel_general_from_distance(
2174 r,
2175 length_scale,
2176 p_order,
2177 duchon_power_to_usize(s_order),
2178 d,
2179 coeffs.as_ref(),
2180 )?
2181 };
2182 kernel_row[j] = raw * kernel_amp;
2183 }
2184 }
2185 let mut product = Array2::<f64>::zeros((rows, kernel_cols));
2199 gam_linalg::faer_ndarray::with_faer_sequential(|| {
2200 gam_linalg::faer_ndarray::fast_ab_into(&kernel_block, &z, &mut product)
2201 });
2202 chunk.slice_mut(s![.., ..kernel_cols]).assign(&product);
2203 Ok(())
2204 });
2205 basis_result?;
2206 if poly_cols > 0 {
2207 basis.slice_mut(s![.., kernel_cols..]).assign(&poly_block);
2208 }
2209
2210 Ok(DuchonBasisDesign { basis })
2211}
2212
2213pub fn build_duchon_basis(
2215 data: ArrayView2<'_, f64>,
2216 spec: &DuchonBasisSpec,
2217) -> Result<BasisBuildResult, BasisError> {
2218 let mut workspace = BasisWorkspace::default();
2219 build_duchon_basiswithworkspace(data, spec, &mut workspace)
2220}
2221
2222pub fn create_duchon_basis_1d_derivative_dense(
2223 t: ArrayView1<'_, f64>,
2224 centers: ArrayView1<'_, f64>,
2225 power: f64,
2226 nullspace_order: DuchonNullspaceOrder,
2227 periodic: bool,
2228 period: Option<f64>,
2229 order: usize,
2230) -> Result<Array2<f64>, BasisError> {
2231 create_duchon_basis_1d_derivative_dense_with_radial_reparam(
2232 t,
2233 centers,
2234 power,
2235 nullspace_order,
2236 periodic,
2237 period,
2238 None,
2239 order,
2240 )
2241}
2242
2243pub fn create_duchon_basis_1d_derivative_dense_with_radial_reparam(
2248 t: ArrayView1<'_, f64>,
2249 centers: ArrayView1<'_, f64>,
2250 power: f64,
2251 nullspace_order: DuchonNullspaceOrder,
2252 periodic: bool,
2253 period: Option<f64>,
2254 radial_reparam: Option<ArrayView2<'_, f64>>,
2255 order: usize,
2256) -> Result<Array2<f64>, BasisError> {
2257 if order > 2 {
2258 crate::bail_invalid_basis!(
2259 "Duchon basis derivative supports orders 0, 1, and 2; got order={order}"
2260 );
2261 }
2262 if t.is_empty() || centers.is_empty() {
2263 crate::bail_invalid_basis!("Duchon basis derivative requires non-empty t and centers");
2264 }
2265 if t.iter().any(|v| !v.is_finite()) || centers.iter().any(|v| !v.is_finite()) {
2266 crate::bail_invalid_basis!("Duchon basis derivative requires finite t and center values");
2267 }
2268 if !periodic && period.is_some() {
2269 crate::bail_invalid_basis!(
2270 "Duchon basis derivative period is only valid when periodic=true"
2271 );
2272 }
2273 if periodic && radial_reparam.is_some() {
2274 crate::bail_invalid_basis!(
2275 "periodic 1-D Duchon derivatives do not admit an open-domain radial reparameterization"
2276 );
2277 }
2278
2279 let data = t.to_owned().insert_axis(Axis(1));
2280 let center_matrix = centers.to_owned().insert_axis(Axis(1));
2281 let mut workspace = BasisWorkspace::default();
2282 let user_m = duchon_p_from_nullspace_order(nullspace_order);
2287 let effective_order = if periodic {
2288 DuchonNullspaceOrder::Zero
2289 } else {
2290 duchon_effective_nullspace_order(center_matrix.view(), nullspace_order)
2291 };
2292 let p_order = duchon_p_from_nullspace_order(effective_order);
2293 let s_order = duchon_power_to_usize(power);
2294 validate_duchon_kernel_orders(None, p_order, s_order as f64, 1)?;
2295
2296 if periodic {
2297 let (collapsed_centers, left, resolved_period) =
2304 prepare_periodic_duchon_centers_1d_with_period(center_matrix, period)?;
2305 let z = kernel_constraint_nullspace(
2306 collapsed_centers.view(),
2307 effective_order,
2308 &mut workspace.cache,
2309 )?;
2310 let kernel_cols = z.ncols();
2311 let k_centers = collapsed_centers.nrows();
2312 let centers_col0: Vec<f64> = collapsed_centers.column(0).to_vec();
2313 let mut raw_kernel = Array2::<f64>::zeros((t.len(), k_centers));
2314 for i in 0..t.len() {
2315 let x = wrap_to_period(t[i], left, resolved_period);
2316 for j in 0..k_centers {
2317 let mut delta = (x - centers_col0[j]).rem_euclid(resolved_period);
2319 if delta > 0.5 * resolved_period {
2320 delta -= resolved_period;
2321 }
2322 let r = delta.abs();
2323 let sign = if delta > 0.0 {
2324 1.0
2325 } else if delta < 0.0 {
2326 -1.0
2327 } else {
2328 0.0
2329 };
2330 let (phi, phi_r, phi_rr) =
2331 periodic_duchon_kernel_bernoulli_triplet(r, user_m, resolved_period)?;
2332 raw_kernel[[i, j]] = match order {
2333 0 => phi,
2334 1 => phi_r * sign,
2335 2 => phi_rr,
2336 other => {
2337 crate::bail_invalid_basis!(
2338 "Duchon basis derivative supports orders 0, 1, and 2; got order={other}"
2339 );
2340 }
2341 };
2342 }
2343 }
2344 let mut basis = Array2::<f64>::zeros((t.len(), kernel_cols + 1));
2347 let design_kernel = fast_ab(&raw_kernel, &z);
2348 basis
2349 .slice_mut(s![.., 0..kernel_cols])
2350 .assign(&design_kernel);
2351 if order == 0 {
2352 basis.column_mut(kernel_cols).fill(1.0);
2353 }
2354 return Ok(basis);
2355 }
2356
2357 let mut z =
2358 kernel_constraint_nullspace(center_matrix.view(), effective_order, &mut workspace.cache)?;
2359 if let Some(radial_reparam) = radial_reparam {
2360 if radial_reparam.nrows() != z.ncols() {
2361 crate::bail_dim_basis!(
2362 "Duchon frozen radial reparam shape {:?} does not match constrained kernel dimension {}",
2363 radial_reparam.dim(),
2364 z.ncols()
2365 );
2366 }
2367 z = fast_ab(&z, &radial_reparam.to_owned());
2368 }
2369 let kernel_cols = z.ncols();
2370 let poly_cols = polynomial_block_from_order(data.view(), effective_order).ncols();
2371
2372 let pure_coeff =
2373 PolyharmonicBlockCoeff::new((pure_duchon_block_order(p_order, s_order as f64)) as f64, 1);
2374 let kernel_amp = duchon_kernel_amplification(
2375 center_matrix.view(),
2376 None,
2377 p_order,
2378 s_order,
2379 1,
2380 None,
2381 None,
2382 Some(&pure_coeff),
2383 );
2384
2385 let mut raw_kernel = Array2::<f64>::zeros((t.len(), centers.len()));
2386 for i in 0..t.len() {
2387 let x = t[i];
2388 for j in 0..centers.len() {
2389 let delta = x - centers[j];
2390 let r = delta.abs();
2391 let sign = if delta > 0.0 {
2392 1.0
2393 } else if delta < 0.0 {
2394 -1.0
2395 } else {
2396 0.0
2397 };
2398 let (phi, phi_r, phi_rr) =
2399 duchon_kernel_radial_triplet(r, None, p_order, s_order as f64, 1, None)?;
2400 raw_kernel[[i, j]] = match order {
2401 0 => phi,
2402 1 => phi_r * sign,
2403 2 => phi_rr,
2404 other => {
2405 crate::bail_invalid_basis!(
2406 "Duchon basis derivative supports orders 0, 1, and 2; got order={other}"
2407 );
2408 }
2409 } * kernel_amp;
2410 }
2411 }
2412
2413 let mut basis = Array2::<f64>::zeros((t.len(), kernel_cols + poly_cols));
2414 let design_kernel = fast_ab(&raw_kernel, &z);
2415 basis
2416 .slice_mut(s![.., 0..kernel_cols])
2417 .assign(&design_kernel);
2418 fill_duchon_1d_polynomial_derivative(&mut basis, kernel_cols, t, effective_order, order);
2419 Ok(basis)
2420}
2421
2422#[cfg(test)]
2423mod taylor_degree_tests {
2424 use super::*;
2425
2426 #[test]
2437 fn half_integer_matern_taylor_coeffs_1604() {
2438 let want_nu_3_2 = [0.25_f64, -0.125, -0.03125];
2439 let want_nu_5_2 = [0.1875_f64, -0.03125, 0.0078125];
2440 for (j, &want) in want_nu_3_2.iter().enumerate() {
2441 let (pure, log) = duchon_matern_block_taylor_r2j(1.0, 2, 1, j);
2442 assert!(log == 0.0, "no log term for half-integer ν (j={j}): {log}");
2443 assert!(
2444 (pure - want).abs() < 1e-13,
2445 "ν=3/2 r^{{{}}} coeff: got {pure:.15}, want {want}",
2446 2 * j
2447 );
2448 }
2449 for (j, &want) in want_nu_5_2.iter().enumerate() {
2450 let (pure, log) = duchon_matern_block_taylor_r2j(1.0, 3, 1, j);
2451 assert!(log == 0.0, "no log term for half-integer ν (j={j}): {log}");
2452 assert!(
2453 (pure - want).abs() < 1e-13,
2454 "ν=5/2 r^{{{}}} coeff: got {pure:.15}, want {want}",
2455 2 * j
2456 );
2457 }
2458 }
2459
2460 #[test]
2465 fn half_integer_matern_taylor_j0_matches_value_limit_1604() {
2466 let d = 1usize;
2467 for n in 1..=4usize {
2468 let nu = n as f64 - 0.5 * d as f64; for &kappa in &[0.3_f64, 1.0, 2.0, 7.5] {
2470 let (pure, _log) = duchon_matern_block_taylor_r2j(kappa, n, d, 0);
2471 let want = duchon_matern_block(0.0, kappa, n, d).expect("r→0 limit");
2473 let rel = (pure - want).abs() / want.abs().max(1e-300);
2474 assert!(
2475 rel < 1e-12,
2476 "ν={nu} κ={kappa}: Taylor j=0 {pure:.15e} vs value limit {want:.15e} (rel {rel:.2e})"
2477 );
2478 }
2479 }
2480 }
2481}
2482
2483#[cfg(test)]
2484mod end_to_end_1604_tests {
2485 use super::*;
2486 use gam_linalg::faer_ndarray::FaerEigh;
2487
2488 #[test]
2495 fn d1_hybrid_duchon_power_ge_2_builds_psd() {
2496 let n = 40usize;
2498 let mut data = Array2::<f64>::zeros((n, 1));
2499 for i in 0..n {
2500 data[[i, 0]] = -1.0 + 2.0 * (i as f64) / (n as f64 - 1.0);
2501 }
2502 for &power in &[2.0f64, 3.0] {
2503 let spec = DuchonBasisSpec {
2504 center_strategy: CenterStrategy::FarthestPoint { num_centers: 12 },
2505 periodic: None,
2506 length_scale: Some(0.5),
2507 power,
2508 nullspace_order: DuchonNullspaceOrder::Linear,
2509 identifiability: SpatialIdentifiability::None,
2510 aniso_log_scales: None,
2511 operator_penalties: DuchonOperatorPenaltySpec::default(),
2512 boundary: OneDimensionalBoundary::Open,
2513 radial_reparam: None,
2514 };
2515 let result = build_duchon_basis(data.view(), &spec).unwrap_or_else(|e| {
2516 panic!("d=1 hybrid Duchon power={power} build rejected (gam#1604): {e}")
2517 });
2518 assert!(
2519 !result.active_penalties.is_empty(),
2520 "d=1 hybrid Duchon power={power} produced no penalty"
2521 );
2522 for (k, penalty) in result.active_penalties.iter().enumerate() {
2523 let sym = symmetrize_penalty(&penalty.matrix);
2524 let (evals, _) =
2525 FaerEigh::eigh(&sym, faer::Side::Lower).expect("symmetric eigendecomposition");
2526 let lam_min = evals.iter().copied().fold(f64::INFINITY, f64::min);
2527 let lam_max = evals.iter().copied().fold(f64::NEG_INFINITY, f64::max);
2528 let tol = 1e-9 * lam_max.abs().max(1.0);
2529 assert!(
2530 lam_min >= -tol,
2531 "d=1 hybrid Duchon power={power} penalty[{k}] not PSD: λ_min={lam_min:.6e} (tol={tol:.3e})"
2532 );
2533 }
2534 }
2535 }
2536}