Skip to main content

scirs2_integrate/iga/
iga_solver.rs

1//! Isogeometric Analysis (IGA) solver for elliptic BVPs on NURBS domains.
2//!
3//! The IGA philosophy: use the same NURBS basis functions for both the
4//! geometry description and the solution approximation. This gives exact
5//! geometry representation and high-order convergence for smooth problems.
6//!
7//! This module provides a 1-D IGA solver for the model problem:
8//!
9//! ```text
10//! −(a(x) u')' = f(x)  on Ω = (0, 1)
11//! u(0) = u_0,  u(1) = u_1   (Dirichlet BCs)
12//! ```
13//!
14//! and a 2-D IGA solver on a rectangular parametric domain.
15//!
16//! ## Algorithm (1-D)
17//!
18//! 1. Construct B-spline basis {N_{i,p}} on \[0,1\].
19//! 2. Assemble the stiffness matrix K\[i,j\] = ∫ a(x) N'_i N'_j dx
20//!    and the load vector f\[i\] = ∫ f(x) N_i dx.
21//! 3. Apply Dirichlet BCs by row/column elimination.
22//! 4. Solve K u = f.
23//!
24//! ## References
25//!
26//! - Hughes, Cottrell & Bazilevs (2005), "Isogeometric Analysis: CAD, FEM, NURBS…"
27//! - Cottrell, Hughes & Bazilevs (2009), "Isogeometric Analysis: Toward Integration…"
28
29use super::bspline::BSplineBasis;
30use crate::error::{IntegrateError, IntegrateResult};
31
32// We need gaussian_elimination — pull it from panel_method via the BEM module
33// but since iga is a separate module tree, we inline a small copy here.
34
35// ---------------------------------------------------------------------------
36// Inline Gaussian elimination (avoid cross-module dependency)
37// ---------------------------------------------------------------------------
38
39fn gauss_solve(a: &mut [Vec<f64>], b: &mut [f64], n: usize) -> IntegrateResult<Vec<f64>> {
40    for col in 0..n {
41        let mut max_row = col;
42        let mut max_val = a[col][col].abs();
43        for row in (col + 1)..n {
44            let v = a[row][col].abs();
45            if v > max_val {
46                max_val = v;
47                max_row = row;
48            }
49        }
50        if max_val < 1e-300 {
51            return Err(IntegrateError::LinearSolveError(
52                "Near-singular stiffness matrix in IGA solve".to_string(),
53            ));
54        }
55        a.swap(col, max_row);
56        b.swap(col, max_row);
57        let pivot = a[col][col];
58        for row in (col + 1)..n {
59            let factor = a[row][col] / pivot;
60            for k in col..n {
61                let sub = factor * a[col][k];
62                a[row][k] -= sub;
63            }
64            let sub_b = factor * b[col];
65            b[row] -= sub_b;
66        }
67    }
68    let mut x = vec![0.0_f64; n];
69    for i in (0..n).rev() {
70        let mut s = b[i];
71        for j in (i + 1)..n {
72            s -= a[i][j] * x[j];
73        }
74        x[i] = s / a[i][i];
75    }
76    Ok(x)
77}
78
79// ---------------------------------------------------------------------------
80// Gauss-Legendre quadrature (element-level)
81// ---------------------------------------------------------------------------
82
83fn gauss_legendre(n: usize) -> (Vec<f64>, Vec<f64>) {
84    match n {
85        1 => (vec![0.0], vec![2.0]),
86        2 => (
87            vec![-0.577_350_269_189_625_8, 0.577_350_269_189_625_8],
88            vec![1.0, 1.0],
89        ),
90        3 => (
91            vec![-0.774_596_669_241_483_4, 0.0, 0.774_596_669_241_483_4],
92            vec![
93                0.555_555_555_555_556,
94                0.888_888_888_888_889,
95                0.555_555_555_555_556,
96            ],
97        ),
98        4 => (
99            vec![
100                -0.861_136_311_594_052_6,
101                -0.339_981_043_584_856_3,
102                0.339_981_043_584_856_3,
103                0.861_136_311_594_052_6,
104            ],
105            vec![
106                0.347_854_845_137_453_8,
107                0.652_145_154_862_546_1,
108                0.652_145_154_862_546_1,
109                0.347_854_845_137_453_8,
110            ],
111        ),
112        _ => gauss_legendre(4),
113    }
114}
115
116// ---------------------------------------------------------------------------
117// IGA 1-D Solver
118// ---------------------------------------------------------------------------
119
120/// Configuration for the 1-D IGA solver.
121#[derive(Debug, Clone)]
122pub struct IGASolver1DConfig {
123    /// Number of Gauss quadrature points per element (knot span).
124    pub n_gauss: usize,
125}
126
127impl Default for IGASolver1DConfig {
128    fn default() -> Self {
129        Self { n_gauss: 4 }
130    }
131}
132
133/// Solution of the 1-D IGA problem.
134#[derive(Debug, Clone)]
135pub struct IGASolution1D {
136    /// B-spline coefficients (control variables) u_i.
137    pub coefficients: Vec<f64>,
138    /// Underlying basis.
139    pub basis: BSplineBasis,
140}
141
142impl IGASolution1D {
143    /// Evaluate the solution at a parameter t ∈ \[0,1\].
144    pub fn eval(&self, t: f64) -> f64 {
145        let (span, n_vals) = self.basis.eval_basis_functions(t);
146        let p = self.basis.degree;
147        let start = span.saturating_sub(p);
148        let mut val = 0.0_f64;
149        for (k, &n_k) in n_vals.iter().enumerate() {
150            let idx = start + k;
151            if idx < self.coefficients.len() {
152                val += n_k * self.coefficients[idx];
153            }
154        }
155        val
156    }
157
158    /// Evaluate the derivative at t.
159    pub fn eval_deriv(&self, t: f64) -> f64 {
160        let (span, dn_vals) = self.basis.eval_basis_derivatives(t);
161        let p = self.basis.degree;
162        let start = span.saturating_sub(p);
163        let mut val = 0.0_f64;
164        for (k, &dn_k) in dn_vals.iter().enumerate() {
165            let idx = start + k;
166            if idx < self.coefficients.len() {
167                val += dn_k * self.coefficients[idx];
168            }
169        }
170        val
171    }
172}
173
174/// 1-D IGA solver for −(a u')' = f on \[0,1\] with Dirichlet BCs.
175pub struct IGASolver1D {
176    basis: BSplineBasis,
177    cfg: IGASolver1DConfig,
178}
179
180impl IGASolver1D {
181    /// Create a 1-D IGA solver.
182    ///
183    /// # Arguments
184    ///
185    /// * `degree` — B-spline degree p.
186    /// * `n_elements` — Number of uniform knot spans.
187    /// * `cfg` — Solver configuration.
188    pub fn new(degree: usize, n_elements: usize, cfg: IGASolver1DConfig) -> IntegrateResult<Self> {
189        let basis = BSplineBasis::uniform_open(degree, n_elements + degree)?;
190        Ok(Self { basis, cfg })
191    }
192
193    /// Create from an explicit B-spline basis.
194    pub fn from_basis(basis: BSplineBasis, cfg: IGASolver1DConfig) -> Self {
195        Self { basis, cfg }
196    }
197
198    /// Assemble the global stiffness matrix K[i,j] = ∫ a(x) N'_i(x) N'_j(x) dx.
199    fn assemble_stiffness<A>(&self, a_coeff: &A) -> Vec<Vec<f64>>
200    where
201        A: Fn(f64) -> f64,
202    {
203        let n = self.basis.n_basis;
204        let mut k_mat = vec![vec![0.0_f64; n]; n];
205        let knots = &self.basis.knots;
206        let p = self.basis.degree;
207        let (xi_ref, w_ref) = gauss_legendre(self.cfg.n_gauss);
208
209        // Iterate over each knot span [t_i, t_{i+1}]
210        for span_idx in p..=(n - 1) {
211            let ta = knots[span_idx];
212            let tb = knots[span_idx + 1];
213            if (tb - ta).abs() < 1e-15 {
214                continue; // Zero-length span (repeated knot)
215            }
216            let half = (tb - ta) * 0.5;
217            let mid = (ta + tb) * 0.5;
218
219            for (&xi, &w) in xi_ref.iter().zip(w_ref.iter()) {
220                let t = mid + half * xi;
221                let jac = half;
222
223                let a_val = a_coeff(t);
224                let (_, dn_vals) = self.basis.eval_basis_derivatives(t);
225                // span from find_span corresponds to span_idx
226                let start = span_idx.saturating_sub(p);
227
228                for (ki, &dn_i) in dn_vals.iter().enumerate() {
229                    let i = start + ki;
230                    if i >= n {
231                        continue;
232                    }
233                    for (kj, &dn_j) in dn_vals.iter().enumerate() {
234                        let j = start + kj;
235                        if j >= n {
236                            continue;
237                        }
238                        k_mat[i][j] += a_val * dn_i * dn_j * w * jac;
239                    }
240                }
241            }
242        }
243        k_mat
244    }
245
246    /// Assemble the global load vector f[i] = ∫ f(x) N_i(x) dx.
247    fn assemble_load<F>(&self, f_rhs: &F) -> Vec<f64>
248    where
249        F: Fn(f64) -> f64,
250    {
251        let n = self.basis.n_basis;
252        let mut f_vec = vec![0.0_f64; n];
253        let knots = &self.basis.knots;
254        let p = self.basis.degree;
255        let (xi_ref, w_ref) = gauss_legendre(self.cfg.n_gauss);
256
257        for span_idx in p..=(n - 1) {
258            let ta = knots[span_idx];
259            let tb = knots[span_idx + 1];
260            if (tb - ta).abs() < 1e-15 {
261                continue;
262            }
263            let half = (tb - ta) * 0.5;
264            let mid = (ta + tb) * 0.5;
265
266            for (&xi, &w) in xi_ref.iter().zip(w_ref.iter()) {
267                let t = mid + half * xi;
268                let jac = half;
269                let f_val = f_rhs(t);
270                let (_, n_vals) = self.basis.eval_basis_functions(t);
271                let start = span_idx.saturating_sub(p);
272
273                for (ki, &n_k) in n_vals.iter().enumerate() {
274                    let i = start + ki;
275                    if i < n {
276                        f_vec[i] += f_val * n_k * w * jac;
277                    }
278                }
279            }
280        }
281        f_vec
282    }
283
284    /// Solve −(a u')' = f with Dirichlet BCs u(0) = u0, u(1) = u1.
285    ///
286    /// # Arguments
287    ///
288    /// * `a_coeff` — Coefficient function a(x) ≥ ε > 0.
289    /// * `f_rhs` — Right-hand side f(x).
290    /// * `u0`, `u1` — Boundary values at x=0 and x=1.
291    pub fn solve<A, F>(
292        &self,
293        a_coeff: &A,
294        f_rhs: &F,
295        u0: f64,
296        u1: f64,
297    ) -> IntegrateResult<IGASolution1D>
298    where
299        A: Fn(f64) -> f64,
300        F: Fn(f64) -> f64,
301    {
302        let n = self.basis.n_basis;
303        if n < 2 {
304            return Err(IntegrateError::InvalidInput(
305                "Need at least 2 basis functions".to_string(),
306            ));
307        }
308
309        let mut k_mat = self.assemble_stiffness(a_coeff);
310        let mut f_vec = self.assemble_load(f_rhs);
311
312        // Apply Dirichlet BCs: strong enforcement via row/column elimination.
313        // First DOF (index 0) corresponds to x=0, last (index n-1) to x=1.
314        // We set u_0 = u0 and u_{n-1} = u1.
315
316        // Modify load vector: subtract known boundary contributions
317        for i in 1..(n - 1) {
318            f_vec[i] -= k_mat[i][0] * u0 + k_mat[i][n - 1] * u1;
319        }
320
321        // Extract interior system (rows and cols 1..n-2)
322        let n_free = n - 2;
323        if n_free == 0 {
324            // Only boundary DOFs: trivial
325            let mut coeffs = vec![0.0_f64; n];
326            coeffs[0] = u0;
327            coeffs[n - 1] = u1;
328            return Ok(IGASolution1D {
329                coefficients: coeffs,
330                basis: self.basis.clone(),
331            });
332        }
333
334        let mut k_free = vec![vec![0.0_f64; n_free]; n_free];
335        let mut f_free = vec![0.0_f64; n_free];
336        for i in 0..n_free {
337            f_free[i] = f_vec[i + 1];
338            for j in 0..n_free {
339                k_free[i][j] = k_mat[i + 1][j + 1];
340            }
341        }
342
343        let u_free = gauss_solve(&mut k_free, &mut f_free, n_free)?;
344
345        let mut coeffs = vec![0.0_f64; n];
346        coeffs[0] = u0;
347        coeffs[1..(n_free + 1)].copy_from_slice(&u_free[..n_free]);
348        coeffs[n - 1] = u1;
349
350        Ok(IGASolution1D {
351            coefficients: coeffs,
352            basis: self.basis.clone(),
353        })
354    }
355}
356
357// ---------------------------------------------------------------------------
358// IGA 2-D Solver
359// ---------------------------------------------------------------------------
360
361/// 2-D IGA solution on a tensor-product B-spline domain.
362#[derive(Debug, Clone)]
363pub struct IGASolution2D {
364    /// Control variables u_{ij} on the tensor-product mesh.
365    pub coefficients: Vec<Vec<f64>>,
366    /// B-spline basis in u direction.
367    pub basis_u: BSplineBasis,
368    /// B-spline basis in v direction.
369    pub basis_v: BSplineBasis,
370}
371
372impl IGASolution2D {
373    /// Evaluate the solution at (u, v).
374    pub fn eval(&self, u: f64, v: f64) -> f64 {
375        let (span_u, n_u) = self.basis_u.eval_basis_functions(u);
376        let (span_v, n_v) = self.basis_v.eval_basis_functions(v);
377        let pu = self.basis_u.degree;
378        let pv = self.basis_v.degree;
379        let start_u = span_u.saturating_sub(pu);
380        let start_v = span_v.saturating_sub(pv);
381
382        let mut val = 0.0_f64;
383        for (ki, &n_ui) in n_u.iter().enumerate() {
384            let i = start_u + ki;
385            if i >= self.coefficients.len() {
386                continue;
387            }
388            for (kj, &n_vj) in n_v.iter().enumerate() {
389                let j = start_v + kj;
390                if j < self.coefficients[i].len() {
391                    val += n_ui * n_vj * self.coefficients[i][j];
392                }
393            }
394        }
395        val
396    }
397}
398
399/// 2-D IGA solver for −∇²u = f on \[0,1\]² with homogeneous Dirichlet BCs.
400pub struct IGASolver2D {
401    basis_u: BSplineBasis,
402    basis_v: BSplineBasis,
403    cfg: IGASolver1DConfig,
404}
405
406impl IGASolver2D {
407    /// Create a 2-D IGA solver on a uniform tensor-product mesh.
408    pub fn new(
409        degree: usize,
410        n_elements_u: usize,
411        n_elements_v: usize,
412        cfg: IGASolver1DConfig,
413    ) -> IntegrateResult<Self> {
414        let basis_u = BSplineBasis::uniform_open(degree, n_elements_u + degree)?;
415        let basis_v = BSplineBasis::uniform_open(degree, n_elements_v + degree)?;
416        Ok(Self {
417            basis_u,
418            basis_v,
419            cfg,
420        })
421    }
422
423    /// Solve −∇²u = f with zero Dirichlet BCs on the entire boundary.
424    ///
425    /// Uses a tensor-product assembly: the global system has (n_u × n_v) DOFs.
426    /// Interior DOFs are the free variables; boundary DOFs are set to zero.
427    pub fn solve<F>(&self, f_rhs: &F) -> IntegrateResult<IGASolution2D>
428    where
429        F: Fn(f64, f64) -> f64,
430    {
431        let nu = self.basis_u.n_basis;
432        let nv = self.basis_v.n_basis;
433        let n_total = nu * nv;
434        let knots_u = &self.basis_u.knots;
435        let knots_v = &self.basis_v.knots;
436        let pu = self.basis_u.degree;
437        let pv = self.basis_v.degree;
438        let (xi_ref, w_ref) = gauss_legendre(self.cfg.n_gauss);
439
440        // Global index: idx(i, j) = i * nv + j
441        let idx = |i: usize, j: usize| i * nv + j;
442
443        // Mark boundary DOFs (zero Dirichlet)
444        let is_boundary = |i: usize, j: usize| i == 0 || i == nu - 1 || j == 0 || j == nv - 1;
445
446        let mut k_global = vec![vec![0.0_f64; n_total]; n_total];
447        let mut f_global = vec![0.0_f64; n_total];
448
449        // Assemble element by element (tensor product of 1-D spans)
450        for span_i in pu..=(nu - 1) {
451            let ua = knots_u[span_i];
452            let ub = knots_u[span_i + 1];
453            if (ub - ua).abs() < 1e-15 {
454                continue;
455            }
456            let half_u = (ub - ua) * 0.5;
457            let mid_u = (ua + ub) * 0.5;
458
459            for span_j in pv..=(nv - 1) {
460                let va = knots_v[span_j];
461                let vb = knots_v[span_j + 1];
462                if (vb - va).abs() < 1e-15 {
463                    continue;
464                }
465                let half_v = (vb - va) * 0.5;
466                let mid_v = (va + vb) * 0.5;
467
468                // 2-D Gauss quadrature
469                for (&xi_u, &w_u) in xi_ref.iter().zip(w_ref.iter()) {
470                    let u_pt = mid_u + half_u * xi_u;
471                    let (_, n_u) = self.basis_u.eval_basis_functions(u_pt);
472                    let (_, dn_u) = self.basis_u.eval_basis_derivatives(u_pt);
473                    let start_u = span_i.saturating_sub(pu);
474
475                    for (&xi_v, &w_v) in xi_ref.iter().zip(w_ref.iter()) {
476                        let v_pt = mid_v + half_v * xi_v;
477                        let (_, n_v) = self.basis_v.eval_basis_functions(v_pt);
478                        let (_, dn_v) = self.basis_v.eval_basis_derivatives(v_pt);
479                        let start_v = span_j.saturating_sub(pv);
480
481                        let jac = half_u * half_v * w_u * w_v;
482                        let f_val = f_rhs(u_pt, v_pt);
483
484                        // Assemble stiffness and load
485                        for (ki, (&n_ui, &dn_ui)) in n_u.iter().zip(dn_u.iter()).enumerate() {
486                            let i = start_u + ki;
487                            if i >= nu {
488                                continue;
489                            }
490                            for (kj, (&n_vj, &dn_vj)) in n_v.iter().zip(dn_v.iter()).enumerate() {
491                                let j = start_v + kj;
492                                if j >= nv {
493                                    continue;
494                                }
495                                let row = idx(i, j);
496
497                                // Load: ∫ f N_ij dΩ
498                                f_global[row] += f_val * n_ui * n_vj * jac;
499
500                                // Stiffness: ∫ ∇N_ij · ∇N_kl dΩ
501                                for (ki2, (&n_ui2, &dn_ui2)) in
502                                    n_u.iter().zip(dn_u.iter()).enumerate()
503                                {
504                                    let i2 = start_u + ki2;
505                                    if i2 >= nu {
506                                        continue;
507                                    }
508                                    for (kj2, (&n_vj2, &dn_vj2)) in
509                                        n_v.iter().zip(dn_v.iter()).enumerate()
510                                    {
511                                        let j2 = start_v + kj2;
512                                        if j2 >= nv {
513                                            continue;
514                                        }
515                                        let col = idx(i2, j2);
516
517                                        // ∇N_{ij} · ∇N_{i2j2} = N'_i N_j * N'_i2 N_j2 / Jac_u²
518                                        //                       + N_i N'_j * N_i2 N'_j2 / Jac_v²
519                                        // Note: dn_u is physical derivative only if Jacobian = half_u
520                                        // Here the parametric domain IS the physical domain, so Jacobian = 1.
521                                        let k_val = (dn_ui * n_vj) * (dn_ui2 * n_vj2)
522                                            + (n_ui * dn_vj) * (n_ui2 * dn_vj2);
523                                        k_global[row][col] += k_val * jac;
524                                    }
525                                }
526                            }
527                        }
528                    }
529                }
530            }
531        }
532
533        // Apply Dirichlet BCs: zero on boundary
534        for i in 0..nu {
535            for j in 0..nv {
536                if is_boundary(i, j) {
537                    let row = idx(i, j);
538                    for k in 0..n_total {
539                        k_global[row][k] = 0.0;
540                        k_global[k][row] = 0.0;
541                    }
542                    k_global[row][row] = 1.0;
543                    f_global[row] = 0.0;
544                }
545            }
546        }
547
548        // Solve the system
549        let u_flat = gauss_solve(&mut k_global, &mut f_global, n_total)?;
550
551        // Reshape to 2-D
552        let mut coeffs = vec![vec![0.0_f64; nv]; nu];
553        for i in 0..nu {
554            for j in 0..nv {
555                coeffs[i][j] = u_flat[idx(i, j)];
556            }
557        }
558
559        Ok(IGASolution2D {
560            coefficients: coeffs,
561            basis_u: self.basis_u.clone(),
562            basis_v: self.basis_v.clone(),
563        })
564    }
565}
566
567// ---------------------------------------------------------------------------
568// Main IGASolver facade
569// ---------------------------------------------------------------------------
570
571/// Isogeometric Analysis solver (facade for 1-D and 2-D problems).
572pub struct IGASolver;
573
574impl IGASolver {
575    /// Create a 1-D IGA solver.
576    pub fn solver_1d(degree: usize, n_elements: usize) -> IntegrateResult<IGASolver1D> {
577        IGASolver1D::new(degree, n_elements, IGASolver1DConfig::default())
578    }
579
580    /// Create a 2-D IGA solver.
581    pub fn solver_2d(
582        degree: usize,
583        n_elements_u: usize,
584        n_elements_v: usize,
585    ) -> IntegrateResult<IGASolver2D> {
586        IGASolver2D::new(
587            degree,
588            n_elements_u,
589            n_elements_v,
590            IGASolver1DConfig::default(),
591        )
592    }
593}
594
595// ---------------------------------------------------------------------------
596// Tests
597// ---------------------------------------------------------------------------
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602
603    #[test]
604    fn test_iga_1d_poisson_uniform() {
605        // Solve −u'' = π² sin(πx) on [0,1], u(0)=u(1)=0.
606        // Exact solution: u(x) = sin(πx).
607        let solver = IGASolver1D::new(3, 8, IGASolver1DConfig { n_gauss: 4 })
608            .expect("IGA 1D solver creation");
609
610        let a = |_x: f64| 1.0_f64;
611        let f =
612            |x: f64| std::f64::consts::PI * std::f64::consts::PI * (std::f64::consts::PI * x).sin();
613
614        let sol = solver.solve(&a, &f, 0.0, 0.0).expect("IGA 1D solve");
615
616        // Check at interior points
617        let test_pts = [0.25, 0.5, 0.75];
618        for &x in &test_pts {
619            let u_iga = sol.eval(x);
620            let u_exact = (std::f64::consts::PI * x).sin();
621            let err = (u_iga - u_exact).abs();
622            assert!(
623                err < 0.05,
624                "IGA 1D u({x}) = {u_iga:.6}, exact = {u_exact:.6}, err = {err:.2e}"
625            );
626        }
627    }
628
629    #[test]
630    fn test_iga_1d_variable_coeff() {
631        // Solve −(2 u')' = −2 on [0,1], u(0)=0, u(1)=1.
632        // ⟹ 2u'' = 2, u'' = 1.
633        // General solution: u(x) = x²/2 + cx + d.
634        // BCs: u(0)=0 ⟹ d=0; u(1)=1 ⟹ 1/2 + c = 1 ⟹ c = 1/2.
635        // Exact: u(x) = x²/2 + x/2 = x(x+1)/2.
636        let solver =
637            IGASolver1D::new(2, 4, IGASolver1DConfig { n_gauss: 3 }).expect("IGA 1D creation");
638        let a = |_x: f64| 2.0_f64;
639        let f = |_x: f64| -2.0_f64;
640        let sol = solver
641            .solve(&a, &f, 0.0, 1.0)
642            .expect("IGA 1D variable coeff solve");
643
644        for k in 0..5 {
645            let x = k as f64 * 0.2 + 0.1;
646            let u_iga = sol.eval(x);
647            let u_exact = x * (x + 1.0) / 2.0;
648            let err = (u_iga - u_exact).abs();
649            assert!(
650                err < 0.05,
651                "IGA 1D variable coeff u({x:.1}) = {u_iga:.6}, exact = {u_exact:.6}"
652            );
653        }
654    }
655
656    #[test]
657    fn test_iga_1d_solution_derivative() {
658        // Check that the derivative of the solution matches the exact derivative.
659        let solver = IGASolver1D::new(3, 6, IGASolver1DConfig::default()).expect("IGA 1D creation");
660        let a = |_x: f64| 1.0_f64;
661        let pi = std::f64::consts::PI;
662        let f = |x: f64| pi * pi * (pi * x).sin();
663        let sol = solver.solve(&a, &f, 0.0, 0.0).expect("solve");
664
665        let x = 0.3;
666        let du_iga = sol.eval_deriv(x);
667        let du_exact = pi * (pi * x).cos();
668        let err = (du_iga - du_exact).abs();
669        assert!(err < 0.3, "Derivative error = {err:.4} (too large)");
670    }
671}