Skip to main content

fdars_core/
fem_smoothing.rs

1//! Linear P1 finite-element surface smoothing over irregular 2D triangulated meshes.
2//!
3//! This module implements the **SR-PDE** (spatial regression with PDE penalisation) formulation
4//! for smoothing scattered observations over an irregular 2D domain specified by a user-supplied
5//! triangulated mesh (nodes + triangle connectivity).
6//!
7//! # Scope (v1)
8//!
9//! - **2D triangles only** — 3D tetrahedral FEM is out of scope.
10//! - **Linear P1 Lagrange "hat" basis** — one basis function per node.
11//! - **Neumann (natural, zero-flux) boundary conditions** — the standard choice for PDE surface
12//!   smoothing; Dirichlet/Robin BCs are deferred.
13//! - **Dense in-house assembly** — no new crate dependencies; sparse solvers are deferred.
14//! - **Isotropic Laplacian roughness penalty** — anisotropic/advection-diffusion PDEs are
15//!   deferred.
16//!
17//! # R Baseline
18//!
19//! Capability is matched against `fdaPDE 1.1-24`. Deliberate divergences:
20//! - Dense assembly vs `fdaPDE`'s sparse-matrix assembly — identical output for modest N.
21//! - No Dirichlet BC support in v1.
22//! - No space-varying PDE coefficients.
23//! - Point location via linear scan (O(T) per query) vs `fdaPDE`'s CGAL spatial index.
24//!
25//! # Public API (this wave)
26//!
27//! - [`assemble_fem_matrices`] — assemble global mass M and stiffness K (both N×N row-major).
28//! - [`fem_basis_eval`] — evaluate P1 hat functions (barycentric coords) at query points.
29//! - [`FemSmoothResult`] — result type shared with wave-2 smoothing functions.
30//!
31//! Wave-2 plans add `fem_smooth`, `fem_smooth_gcv`, and `fem_predict` to the same module.
32
33use crate::error::FdarError;
34
35// ──────────────────────────────────────────────────────────────────────────────
36// Public result type
37// ──────────────────────────────────────────────────────────────────────────────
38
39/// Result of FEM/PDE-regularized surface smoothing.
40///
41/// Returned by `fem_smooth` and `fem_smooth_gcv` (wave-2). Defined here in the foundation
42/// plan so wave-2 implementations can reference it without re-definition.
43///
44/// All matrices stored as row-major flat `Vec<f64>` internally; fitted values are plain
45/// `Vec<f64>` of length `n_nodes` and `n_obs` respectively.
46#[must_use]
47#[derive(Debug, Clone, PartialEq)]
48#[non_exhaustive]
49#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
50pub struct FemSmoothResult {
51    /// Fitted surface values at the mesh nodes (length `n_nodes`).
52    ///
53    /// The coefficient vector `c` solving `(Φ'Φ + λ·K) c = Φ'y`.
54    pub node_values: Vec<f64>,
55    /// Fitted values at the observation locations `obs_xy` (length `n_obs`).
56    ///
57    /// Computed as `fitted_obs[i] = Σ_k φ_k(obs_xy[i]) * c[k]`.
58    pub fitted_obs: Vec<f64>,
59    /// Effective degrees of freedom — trace of the hat matrix `Φ(Φ'Φ + λK)⁻¹Φ'`.
60    pub edf: f64,
61    /// Generalised cross-validation score.
62    ///
63    /// `GCV = n · RSS / (n − edf)²`. Set to `f64::INFINITY` if `edf ≥ n_obs`.
64    pub gcv: f64,
65    /// Residual sum of squares at the observation locations.
66    pub rss: f64,
67    /// Smoothing parameter λ used for this result.
68    pub lambda: f64,
69    /// Number of mesh nodes N.
70    pub n_nodes: usize,
71    /// Number of triangles T.
72    pub n_triangles: usize,
73}
74
75// ──────────────────────────────────────────────────────────────────────────────
76// Internal constants
77// ──────────────────────────────────────────────────────────────────────────────
78
79/// Numerical epsilon for the point-in-triangle test (barycentric tolerance).
80const BARY_EPS: f64 = 1e-10;
81
82/// Epsilon used in `barycentric` to guard against degenerate triangles at eval time.
83const BARY_DET_EPS: f64 = 1e-14;
84
85// ──────────────────────────────────────────────────────────────────────────────
86// Mesh validation
87// ──────────────────────────────────────────────────────────────────────────────
88
89/// Validate the mesh: non-empty, all indices in range, no degenerate (zero-area) triangles.
90///
91/// Called once at entry by every public function before any computation.
92fn mesh_validate(nodes: &[[f64; 2]], triangles: &[[usize; 3]]) -> Result<(), FdarError> {
93    if nodes.is_empty() {
94        return Err(FdarError::InvalidDimension {
95            parameter: "nodes",
96            expected: "at least one node".to_string(),
97            actual: "0 nodes".to_string(),
98        });
99    }
100    if triangles.is_empty() {
101        return Err(FdarError::InvalidDimension {
102            parameter: "triangles",
103            expected: "at least one triangle".to_string(),
104            actual: "0 triangles".to_string(),
105        });
106    }
107
108    let n = nodes.len();
109
110    // Compute bounding-box area for the degenerate-triangle tolerance.
111    let x_min = nodes.iter().map(|p| p[0]).fold(f64::INFINITY, f64::min);
112    let x_max = nodes.iter().map(|p| p[0]).fold(f64::NEG_INFINITY, f64::max);
113    let y_min = nodes.iter().map(|p| p[1]).fold(f64::INFINITY, f64::min);
114    let y_max = nodes.iter().map(|p| p[1]).fold(f64::NEG_INFINITY, f64::max);
115    let bbox_area = (x_max - x_min) * (y_max - y_min);
116    let area_tol = 1e-12 * bbox_area.max(1.0);
117
118    for (tri_idx, tri) in triangles.iter().enumerate() {
119        // Check vertex indices are in range.
120        for &vi in tri.iter() {
121            if vi >= n {
122                return Err(FdarError::InvalidParameter {
123                    parameter: "triangles",
124                    message: format!(
125                        "triangle {tri_idx} references vertex index {vi} which is out of range \
126                         (mesh has {n} nodes)"
127                    ),
128                });
129            }
130        }
131
132        // Check for degenerate triangle (area ≈ 0).
133        let [v0, v1, v2] = *tri;
134        let (x0, y0) = (nodes[v0][0], nodes[v0][1]);
135        let (x1, y1) = (nodes[v1][0], nodes[v1][1]);
136        let (x2, y2) = (nodes[v2][0], nodes[v2][1]);
137        let signed_area_2 = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0);
138        let area = 0.5 * signed_area_2.abs();
139        if area < area_tol {
140            return Err(FdarError::InvalidParameter {
141                parameter: "triangles",
142                message: format!(
143                    "triangle {tri_idx} is degenerate (area ≈ {area:.2e} < tolerance \
144                     {area_tol:.2e}); check for collinear or coincident nodes"
145                ),
146            });
147        }
148    }
149    Ok(())
150}
151
152// ──────────────────────────────────────────────────────────────────────────────
153// Element matrix closed forms (P1 linear FEM)
154// ──────────────────────────────────────────────────────────────────────────────
155
156/// Element mass matrix for a P1 triangle (3×3 local, local node ordering v0,v1,v2).
157///
158/// `M_e = (area / 12) * [[2,1,1],[1,2,1],[1,1,2]]`
159///
160/// Derivation: `∫_T λ_i λ_j dA = area/6` if `i=j`, `area/12` if `i≠j`.
161#[inline]
162fn element_mass(area: f64) -> [[f64; 3]; 3] {
163    let a = area / 12.0;
164    [[2.0 * a, a, a], [a, 2.0 * a, a], [a, a, 2.0 * a]]
165}
166
167/// Element stiffness matrix for a P1 triangle (Laplacian weak form, 3×3 local).
168///
169/// `K_e[i,j] = (b_i·b_j + c_i·c_j) / (4·area)`
170///
171/// where `b_i`, `c_i` are the gradient coefficients of the P1 hat functions:
172/// ```text
173/// b0 = y1 − y2,  c0 = x2 − x1
174/// b1 = y2 − y0,  c1 = x0 − x2
175/// b2 = y0 − y1,  c2 = x1 − x0
176/// ```
177///
178/// # Panics
179///
180/// Caller must ensure `area > 0` (guaranteed by `mesh_validate`).
181#[inline]
182fn element_stiffness(
183    x0: f64,
184    y0: f64,
185    x1: f64,
186    y1: f64,
187    x2: f64,
188    y2: f64,
189    area: f64,
190) -> [[f64; 3]; 3] {
191    let b0 = y1 - y2;
192    let c0 = x2 - x1;
193    let b1 = y2 - y0;
194    let c1 = x0 - x2;
195    let b2 = y0 - y1;
196    let c2 = x1 - x0;
197    let s = 1.0 / (4.0 * area);
198    [
199        [
200            s * (b0 * b0 + c0 * c0),
201            s * (b0 * b1 + c0 * c1),
202            s * (b0 * b2 + c0 * c2),
203        ],
204        [
205            s * (b1 * b0 + c1 * c0),
206            s * (b1 * b1 + c1 * c1),
207            s * (b1 * b2 + c1 * c2),
208        ],
209        [
210            s * (b2 * b0 + c2 * c0),
211            s * (b2 * b1 + c2 * c1),
212            s * (b2 * b2 + c2 * c2),
213        ],
214    ]
215}
216
217// ──────────────────────────────────────────────────────────────────────────────
218// Global assembly
219// ──────────────────────────────────────────────────────────────────────────────
220
221/// Assemble the global N×N mass matrix **M** and stiffness matrix **K** for a triangulated mesh.
222///
223/// Both matrices are returned as flat `Vec<f64>` in **row-major** order (element `(i, j)` at
224/// index `i * N + j`). This matches the layout expected by `crate::linalg::cholesky_solve` and
225/// related helpers.
226///
227/// # Arguments
228///
229/// * `nodes` — mesh nodes, each `[x, y]` (N nodes).
230/// * `triangles` — triangle connectivity, each `[v0, v1, v2]` as indices into `nodes` (T
231///   triangles). Triangle winding order (CW vs CCW) does not affect the result; areas are taken
232///   as absolute values.
233///
234/// # Returns
235///
236/// `(M, K)` — global mass and stiffness matrices, both `Vec<f64>` of length `N * N`.
237///
238/// **Properties:**
239/// - M is symmetric positive-definite (every node appears in at least one triangle with
240///   positive area after validation).
241/// - K is symmetric; each row sums to ≈ 0 (constant vector is in the null space — this is the
242///   Laplacian null-space property). K is PSD (not PD) with exactly one zero eigenvalue.
243///
244/// # Errors
245///
246/// Returns [`FdarError::InvalidDimension`] for empty `nodes` or `triangles`, and
247/// [`FdarError::InvalidParameter`] for out-of-range vertex indices or degenerate
248/// (zero-area) triangles.
249///
250/// # Example
251///
252/// ```rust
253/// use fdars_core::fem_smoothing::assemble_fem_matrices;
254/// // Unit square split into 2 triangles (4 nodes):
255/// let nodes = [[0.0f64, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
256/// let triangles = [[0usize, 1, 2], [0, 2, 3]];
257/// let (m, k) = assemble_fem_matrices(&nodes, &triangles).unwrap();
258/// assert_eq!(m.len(), 16); // 4×4
259/// assert_eq!(k.len(), 16);
260/// ```
261pub fn assemble_fem_matrices(
262    nodes: &[[f64; 2]],
263    triangles: &[[usize; 3]],
264) -> Result<(Vec<f64>, Vec<f64>), FdarError> {
265    mesh_validate(nodes, triangles)?;
266
267    let n = nodes.len();
268    let mut m_global = vec![0.0_f64; n * n];
269    let mut k_global = vec![0.0_f64; n * n];
270
271    for tri in triangles {
272        let [v0, v1, v2] = *tri;
273        let (x0, y0) = (nodes[v0][0], nodes[v0][1]);
274        let (x1, y1) = (nodes[v1][0], nodes[v1][1]);
275        let (x2, y2) = (nodes[v2][0], nodes[v2][1]);
276        // Absolute area (mesh_validate already ensured > 0).
277        let area = 0.5 * ((x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0)).abs();
278        let m_e = element_mass(area);
279        let k_e = element_stiffness(x0, y0, x1, y1, x2, y2, area);
280        let local = [v0, v1, v2];
281        for (li, &gi) in local.iter().enumerate() {
282            for (lj, &gj) in local.iter().enumerate() {
283                m_global[gi * n + gj] += m_e[li][lj];
284                k_global[gi * n + gj] += k_e[li][lj];
285            }
286        }
287    }
288
289    Ok((m_global, k_global))
290}
291
292// ──────────────────────────────────────────────────────────────────────────────
293// Barycentric coordinates and point location
294// ──────────────────────────────────────────────────────────────────────────────
295
296/// Compute barycentric coordinates of `(px, py)` with respect to a triangle
297/// `(x0,y0)–(x1,y1)–(x2,y2)`.
298///
299/// Returns `None` for degenerate triangles (`|det| < BARY_DET_EPS`).
300///
301/// # Formula
302///
303/// ```text
304/// det = (x1-x0)*(y2-y0) - (x2-x0)*(y1-y0)   // = 2 * signed area
305/// λ1  = ((px-x0)*(y2-y0) - (py-y0)*(x2-x0)) / det
306/// λ2  = ((py-y0)*(x1-x0) - (px-x0)*(y1-y0)) / det
307/// λ0  = 1 - λ1 - λ2
308/// ```
309#[inline]
310fn barycentric(
311    px: f64,
312    py: f64,
313    x0: f64,
314    y0: f64,
315    x1: f64,
316    y1: f64,
317    x2: f64,
318    y2: f64,
319) -> Option<(f64, f64, f64)> {
320    let det = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0);
321    if det.abs() < BARY_DET_EPS {
322        return None;
323    }
324    let lam1 = ((px - x0) * (y2 - y0) - (py - y0) * (x2 - x0)) / det;
325    let lam2 = ((py - y0) * (x1 - x0) - (px - x0) * (y1 - y0)) / det;
326    let lam0 = 1.0 - lam1 - lam2;
327    Some((lam0, lam1, lam2))
328}
329
330/// Locate a query point `(px, py)` in the triangulation via linear scan.
331///
332/// Returns the **first** triangle whose barycentric coordinates all satisfy `≥ −BARY_EPS`,
333/// together with the three barycentric weights `(λ0, λ1, λ2)`.
334///
335/// Returns `None` if no triangle contains the point (i.e., the point is outside the mesh).
336///
337/// Complexity: O(T) per query (v1; spatial index deferred per CONTEXT.md).
338fn locate_point(
339    nodes: &[[f64; 2]],
340    triangles: &[[usize; 3]],
341    px: f64,
342    py: f64,
343) -> Option<(usize, (f64, f64, f64))> {
344    for (tri_idx, tri) in triangles.iter().enumerate() {
345        let [v0, v1, v2] = *tri;
346        let (x0, y0) = (nodes[v0][0], nodes[v0][1]);
347        let (x1, y1) = (nodes[v1][0], nodes[v1][1]);
348        let (x2, y2) = (nodes[v2][0], nodes[v2][1]);
349        if let Some((lam0, lam1, lam2)) = barycentric(px, py, x0, y0, x1, y1, x2, y2) {
350            if lam0 >= -BARY_EPS && lam1 >= -BARY_EPS && lam2 >= -BARY_EPS {
351                return Some((tri_idx, (lam0, lam1, lam2)));
352            }
353        }
354    }
355    None
356}
357
358// ──────────────────────────────────────────────────────────────────────────────
359// Public basis evaluation
360// ──────────────────────────────────────────────────────────────────────────────
361
362/// Evaluate the P1 hat functions at a set of query points.
363///
364/// For each query point, locates the containing triangle via barycentric coordinates and returns
365/// the three non-zero hat-function (node, value) pairs. Points outside the mesh return an error.
366///
367/// # Arguments
368///
369/// * `nodes` — mesh nodes (N × 2 coordinates).
370/// * `triangles` — triangle connectivity (T × 3 vertex indices).
371/// * `query_xy` — query points, each `[x, y]`.
372///
373/// # Returns
374///
375/// A `Vec` of length `query_xy.len()`, where each entry is:
376/// `(containing_triangle_index, [(node_index, hat_value); 3])`.
377///
378/// The three hat values sum to 1.0 for any interior point (partition of unity). The hat values
379/// are the barycentric coordinates `(λ0, λ1, λ2)` of the query point within the containing
380/// triangle, corresponding to nodes `(v0, v1, v2)` of that triangle.
381///
382/// # Errors
383///
384/// Returns [`FdarError::InvalidDimension`] for an empty mesh, and
385/// [`FdarError::InvalidParameter`] for:
386/// - out-of-range vertex indices or degenerate triangles (via `mesh_validate`).
387/// - any query point that lies outside the triangulated domain (parameter `"query_xy"`).
388///
389/// # Example
390///
391/// ```rust
392/// use fdars_core::fem_smoothing::fem_basis_eval;
393/// let nodes = [[0.0f64, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
394/// let triangles = [[0usize, 1, 2], [0, 2, 3]];
395/// let result = fem_basis_eval(&nodes, &triangles, &[[0.25, 0.25]]).unwrap();
396/// let (_tri_idx, weights) = result[0];
397/// let sum: f64 = weights.iter().map(|(_, w)| w).sum();
398/// assert!((sum - 1.0).abs() < 1e-12, "hat values must sum to 1");
399/// ```
400pub fn fem_basis_eval(
401    nodes: &[[f64; 2]],
402    triangles: &[[usize; 3]],
403    query_xy: &[[f64; 2]],
404) -> Result<Vec<(usize, [(usize, f64); 3])>, FdarError> {
405    mesh_validate(nodes, triangles)?;
406
407    let mut result = Vec::with_capacity(query_xy.len());
408
409    for (qi, &[px, py]) in query_xy.iter().enumerate() {
410        match locate_point(nodes, triangles, px, py) {
411            Some((tri_idx, (lam0, lam1, lam2))) => {
412                let [v0, v1, v2] = triangles[tri_idx];
413                result.push((tri_idx, [(v0, lam0), (v1, lam1), (v2, lam2)]));
414            }
415            None => {
416                return Err(FdarError::InvalidParameter {
417                    parameter: "query_xy",
418                    message: format!(
419                        "query point {qi} ([{px}, {py}]) lies outside the triangulated mesh"
420                    ),
421                });
422            }
423        }
424    }
425
426    Ok(result)
427}
428
429// ──────────────────────────────────────────────────────────────────────────────
430// SR-PDE surface smoothing
431// ──────────────────────────────────────────────────────────────────────────────
432
433/// PDE-regularised (Laplacian-penalty) surface smoothing at a **fixed** smoothing parameter λ.
434///
435/// Solves the SR-PDE penalised normal equations `(Φ'Φ + λ·K) c = Φ'y` using the in-house dense
436/// Cholesky solver from [`crate::linalg`]. Returns the fitted node-coefficient vector `c`
437/// together with diagnostic fields (edf, GCV, RSS, λ).
438///
439/// # SR-PDE System
440///
441/// - **Φ** (n_obs × N) is the observation matrix built from P1 hat-function evaluations at
442///   `obs_xy` (3 non-zeros per row by barycentric coordinates).
443/// - **K** (N × N) is the global stiffness matrix assembled by [`assemble_fem_matrices`].
444/// - **ε = 1 × 10⁻¹⁰ ridge** is added to the diagonal of `Φ'Φ + λ·K` before factorisation to
445///   lift K's constant null space (the constant function has zero roughness penalty but may have
446///   zero data fit with very few observations).
447///
448/// # GCV and EDF
449///
450/// Effective degrees of freedom: `edf = tr(A⁻¹ · Φ'Φ)`, where `A = Φ'Φ + λK + εI`.
451/// This trace is computed as the elementwise dot product of `A⁻¹` and `Φ'Φ` (both N×N,
452/// symmetric), avoiding the n_obs×n_obs hat matrix. `A⁻¹` is built column-by-column via
453/// Cholesky forward-back substitution — **O(N³)** cost. For v1, **N ≲ 2 000** is recommended.
454///
455/// `GCV = (RSS / n) / (1 − edf / n)²`, set to `f64::INFINITY` if `edf ≥ n`.
456///
457/// # Arguments
458///
459/// * `nodes` — mesh nodes, each `[x, y]` (N nodes, N ≥ 1).
460/// * `triangles` — triangle connectivity (T triangles). Validated on entry.
461/// * `obs_xy` — observation locations (n_obs points), each `[x, y]`. All must lie inside the
462///   mesh.
463/// * `y` — observed scalar response values (length n_obs).
464/// * `lambda` — smoothing parameter (≥ 0; larger → smoother surface).
465///
466/// # Errors
467///
468/// - [`FdarError::InvalidDimension`]: empty `nodes`, `triangles`, or `y`; or
469///   `obs_xy.len() != y.len()`.
470/// - [`FdarError::InvalidParameter`]: `lambda < 0.0`; degenerate/out-of-range mesh; or any
471///   `obs_xy` point outside the mesh domain (surfaced by [`fem_basis_eval`]).
472/// - [`FdarError::ComputationFailed`]: the Cholesky factorisation of `A` fails (matrix is
473///   singular even after ridge); check that observations are not all collinear on a single node.
474#[must_use = "expensive FEM smoothing computation whose result should not be discarded"]
475pub fn fem_smooth(
476    nodes: &[[f64; 2]],
477    triangles: &[[usize; 3]],
478    obs_xy: &[[f64; 2]],
479    y: &[f64],
480    lambda: f64,
481) -> Result<FemSmoothResult, FdarError> {
482    // ── Input validation ─────────────────────────────────────────────────────
483    if y.is_empty() {
484        return Err(FdarError::InvalidDimension {
485            parameter: "y",
486            expected: "at least one observation".to_string(),
487            actual: "0 observations".to_string(),
488        });
489    }
490    if obs_xy.len() != y.len() {
491        return Err(FdarError::InvalidDimension {
492            parameter: "obs_xy",
493            expected: format!("{} (= len(y))", y.len()),
494            actual: obs_xy.len().to_string(),
495        });
496    }
497    if lambda < 0.0 {
498        return Err(FdarError::InvalidParameter {
499            parameter: "lambda",
500            message: "smoothing parameter must be >= 0.0".to_string(),
501        });
502    }
503
504    let n_obs = obs_xy.len();
505    let big_n = nodes.len(); // number of mesh nodes
506
507    // ── Build K (stiffness) via assemble_fem_matrices ────────────────────────
508    // mesh_validate is called inside assemble_fem_matrices.
509    let (_m, k_global) = assemble_fem_matrices(nodes, triangles)?;
510
511    // ── Build Φ (n_obs × big_n) row-major flat Vec<f64> ──────────────────────
512    // fem_basis_eval validates mesh (again) and returns an error if any obs is outside.
513    let basis_evals = fem_basis_eval(nodes, triangles, obs_xy)?;
514    let mut phi = vec![0.0_f64; n_obs * big_n];
515    for (i, (_tri_idx, weights)) in basis_evals.iter().enumerate() {
516        for &(node_idx, hat_val) in weights.iter() {
517            phi[i * big_n + node_idx] = hat_val;
518        }
519    }
520
521    // ── Assemble Φ'Φ (N × N, row-major) ──────────────────────────────────────
522    // O(n_obs · N²); exploiting symmetry of Φ'Φ.
523    // OPT-F: build Φ'Φ and A = Φ'Φ (…+λK+εI below) in a SINGLE assembly pass, removing the
524    // load-bearing `phi_t_phi.clone()` (one N×N copy, ~2.6 MB at N=576). `phi_t_phi` is kept PURE
525    // (Φ'Φ only) because the GCV trace below reads it; regularization is added to `a_mat` alone.
526    let mut phi_t_phi = vec![0.0_f64; big_n * big_n];
527    let mut a_mat = vec![0.0_f64; big_n * big_n];
528    for i in 0..n_obs {
529        for a in 0..big_n {
530            let phi_ia = phi[i * big_n + a];
531            if phi_ia == 0.0 {
532                continue;
533            }
534            for b in a..big_n {
535                let val = phi_ia * phi[i * big_n + b];
536                phi_t_phi[a * big_n + b] += val;
537                a_mat[a * big_n + b] += val;
538                if a != b {
539                    phi_t_phi[b * big_n + a] += val;
540                    a_mat[b * big_n + a] += val;
541                }
542            }
543        }
544    }
545
546    // ── Build A = Φ'Φ + λ·K + ε·I (added to a_mat only; phi_t_phi stays pure) ──────────────
547    for ab in 0..(big_n * big_n) {
548        a_mat[ab] += lambda * k_global[ab];
549    }
550    for a in 0..big_n {
551        a_mat[a * big_n + a] += 1e-10; // ridge to lift K's constant null space
552    }
553
554    // ── Build Φ'y (length N) ─────────────────────────────────────────────────
555    let mut phi_t_y = vec![0.0_f64; big_n];
556    for i in 0..n_obs {
557        for a in 0..big_n {
558            phi_t_y[a] += phi[i * big_n + a] * y[i];
559        }
560    }
561
562    // ── Solve (Φ'Φ + λK + εI) c = Φ'y ───────────────────────────────────────
563    let c = crate::linalg::cholesky_solve(&a_mat, &phi_t_y, big_n)?;
564
565    // ── Fitted values at observations ─────────────────────────────────────────
566    let fitted_obs: Vec<f64> = (0..n_obs)
567        .map(|i| (0..big_n).map(|a| phi[i * big_n + a] * c[a]).sum())
568        .collect();
569
570    let rss: f64 = (0..n_obs).map(|i| (y[i] - fitted_obs[i]).powi(2)).sum();
571
572    // ── Compute A⁻¹ column-by-column for GCV trace ───────────────────────────
573    // PERF (Phase 47 OPT-F, DEFERRED): this dense O(N³) Cholesky factorization plus the N
574    // column-by-column forward/back solves for the full A⁻¹ (needed only for the GCV edf trace) is
575    // the structural wall-time bottleneck (~452 ms @ 576 nodes, PROF-01). No safe behavior-preserving
576    // constant-factor win exists without either sparse assembly/solvers (a new crate dependency —
577    // out of scope for this no-new-dependency milestone) or skipping the GCV computation (which would
578    // change the returned `edf`/`gcv` fields — a breaking API change, also out of scope). Deferred; a
579    // future breaking/1.0-readiness or sparse-linalg milestone can revisit. See PERF-RESULTS.md.
580    let l = crate::linalg::cholesky_factor(&a_mat, big_n)?;
581    let mut a_inv = vec![0.0_f64; big_n * big_n];
582    let mut e_col = vec![0.0_f64; big_n];
583    for j in 0..big_n {
584        e_col.iter_mut().for_each(|v| *v = 0.0);
585        e_col[j] = 1.0;
586        let col = crate::linalg::cholesky_forward_back(&l, &e_col, big_n);
587        for i in 0..big_n {
588            a_inv[i * big_n + j] = col[i]; // a_inv[i, j] in row-major
589        }
590    }
591
592    // edf = tr(A⁻¹ · Φ'Φ) = Σ_{a,b} A⁻¹[a,b] * Φ'Φ[b,a]
593    let mut edf = 0.0_f64;
594    for a in 0..big_n {
595        for b in 0..big_n {
596            edf += a_inv[a * big_n + b] * phi_t_phi[b * big_n + a];
597        }
598    }
599
600    let n_obs_f = n_obs as f64;
601    let gcv_denom = 1.0 - edf / n_obs_f;
602    let gcv = if gcv_denom.abs() > 1e-10 {
603        (rss / n_obs_f) / (gcv_denom * gcv_denom)
604    } else {
605        f64::INFINITY
606    };
607
608    Ok(FemSmoothResult {
609        node_values: c,
610        fitted_obs,
611        edf,
612        gcv,
613        rss,
614        lambda,
615        n_nodes: nodes.len(),
616        n_triangles: triangles.len(),
617    })
618}
619
620/// PDE-regularised surface smoothing with GCV-optimal λ selected from a log₁₀ grid.
621///
622/// Evaluates [`fem_smooth`] at `n_grid` equally-spaced log₁₀(λ) values spanning
623/// `log_lambda_range` and returns the result with the smallest finite GCV score.
624///
625/// Mirrors the grid-search approach of `smooth_basis_gcv` in `smooth_basis.rs`.
626///
627/// # Arguments
628///
629/// * `log_lambda_range` — `(lo, hi)` in log₁₀ scale (e.g., `(-6.0, 2.0)`).
630/// * `n_grid` — number of grid points (≥ 2). Larger grids give finer resolution at O(n_grid · N³)
631///   total cost.
632///
633/// # Errors
634///
635/// - [`FdarError::InvalidParameter`]: `n_grid < 2`.
636/// - [`FdarError::ComputationFailed`]: all grid points produced non-finite GCV scores (e.g., mesh
637///   is too coarse relative to the observations — try widening `log_lambda_range` or adding
638///   more observations).
639/// - Any error from [`fem_smooth`] propagated from the last failed grid call.
640#[must_use = "GCV-selected FEM smoothing result should not be discarded"]
641pub fn fem_smooth_gcv(
642    nodes: &[[f64; 2]],
643    triangles: &[[usize; 3]],
644    obs_xy: &[[f64; 2]],
645    y: &[f64],
646    log_lambda_range: (f64, f64),
647    n_grid: usize,
648) -> Result<FemSmoothResult, FdarError> {
649    if n_grid < 2 {
650        return Err(FdarError::InvalidParameter {
651            parameter: "n_grid",
652            message: "GCV lambda grid requires at least 2 points".to_string(),
653        });
654    }
655
656    let (lo, hi) = log_lambda_range;
657    let mut best_gcv = f64::INFINITY;
658    let mut best_result: Option<FemSmoothResult> = None;
659    let mut last_err: Option<FdarError> = None;
660
661    for i in 0..n_grid {
662        let log_lam = lo + (hi - lo) * i as f64 / (n_grid - 1) as f64;
663        let lam = 10.0_f64.powf(log_lam);
664        match fem_smooth(nodes, triangles, obs_xy, y, lam) {
665            Ok(res) => {
666                if res.gcv.is_finite() && res.gcv < best_gcv {
667                    best_gcv = res.gcv;
668                    best_result = Some(res);
669                }
670            }
671            Err(e) => {
672                last_err = Some(e);
673            }
674        }
675    }
676
677    if let Some(result) = best_result {
678        return Ok(result);
679    }
680
681    // All grid points produced non-finite GCV or errors.
682    if let Some(e) = last_err {
683        return Err(e);
684    }
685
686    Err(FdarError::ComputationFailed {
687        operation: "fem_smooth_gcv",
688        detail: "all lambda grid points produced non-finite GCV; try widening \
689                 log_lambda_range or adding more observations"
690            .to_string(),
691    })
692}
693
694/// Evaluate the fitted surface at new (x, y) locations by P1 interpolation.
695///
696/// For each query point, locates its containing triangle and computes
697/// `Σ_k φ_k(x, y) · node_values[k]` using the three non-zero barycentric weights.
698///
699/// **Linear-field exactness:** P1 interpolation reproduces any linear function exactly,
700/// so `fem_predict` returns the exact value for linear node-value fields.
701///
702/// # Arguments
703///
704/// * `node_values` — fitted surface values at the mesh nodes (length N = `nodes.len()`).
705/// * `nodes` — mesh nodes (same as passed to [`fem_smooth`]).
706/// * `triangles` — triangle connectivity (same as passed to [`fem_smooth`]).
707/// * `query_xy` — locations at which to evaluate the surface, each `[x, y]`.
708///
709/// # Errors
710///
711/// - [`FdarError::InvalidDimension`]: `node_values.len() != nodes.len()`.
712/// - [`FdarError::InvalidParameter`]: any query point outside the mesh domain (surfaced by
713///   [`fem_basis_eval`]).
714#[must_use = "FEM surface prediction result should not be discarded"]
715pub fn fem_predict(
716    node_values: &[f64],
717    nodes: &[[f64; 2]],
718    triangles: &[[usize; 3]],
719    query_xy: &[[f64; 2]],
720) -> Result<Vec<f64>, FdarError> {
721    if node_values.len() != nodes.len() {
722        return Err(FdarError::InvalidDimension {
723            parameter: "node_values",
724            expected: format!("{} (= nodes.len())", nodes.len()),
725            actual: node_values.len().to_string(),
726        });
727    }
728
729    let basis_evals = fem_basis_eval(nodes, triangles, query_xy)?;
730    let predictions: Vec<f64> = basis_evals
731        .iter()
732        .map(|(_tri_idx, weights)| {
733            weights
734                .iter()
735                .map(|&(node_idx, hat_val)| hat_val * node_values[node_idx])
736                .sum()
737        })
738        .collect();
739
740    Ok(predictions)
741}
742
743// ──────────────────────────────────────────────────────────────────────────────
744// Tests
745// ──────────────────────────────────────────────────────────────────────────────
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750
751    // ── Unit-square fixture ────────────────────────────────────────────────────
752    //
753    //   3 ──── 2
754    //   |    / |
755    //   |   /  |
756    //   |  /   |
757    //   | /    |
758    //   0 ──── 1
759    //
760    // nodes:     [[0,0],[1,0],[1,1],[0,1]]
761    // triangles: [[0,1,2],[0,2,3]]  (unit square split diagonally)
762    // Each triangle has area = 0.5.
763
764    fn unit_square_mesh() -> ([[f64; 2]; 4], [[usize; 3]; 2]) {
765        let nodes = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
766        let triangles = [[0, 1, 2], [0, 2, 3]];
767        (nodes, triangles)
768    }
769
770    // ── Task 1 tests ───────────────────────────────────────────────────────────
771
772    #[test]
773    fn test_assemble_unit_square_symmetry_and_nullspace() {
774        let (nodes, triangles) = unit_square_mesh();
775        let (m, k) = assemble_fem_matrices(&nodes, &triangles).unwrap();
776
777        // Both matrices must be 4×4 (16 entries).
778        assert_eq!(m.len(), 16, "M must be 4×4");
779        assert_eq!(k.len(), 16, "K must be 4×4");
780
781        let n = 4_usize;
782
783        // Symmetry check for M and K.
784        for i in 0..n {
785            for j in 0..n {
786                let m_ij = m[i * n + j];
787                let m_ji = m[j * n + i];
788                assert!(
789                    (m_ij - m_ji).abs() < 1e-12,
790                    "M not symmetric at ({i},{j}): {m_ij} vs {m_ji}"
791                );
792                let k_ij = k[i * n + j];
793                let k_ji = k[j * n + i];
794                assert!(
795                    (k_ij - k_ji).abs() < 1e-12,
796                    "K not symmetric at ({i},{j}): {k_ij} vs {k_ji}"
797                );
798            }
799        }
800
801        // K row-sums ≈ 0 (constant null space property of the Laplacian stiffness).
802        for i in 0..n {
803            let row_sum: f64 = (0..n).map(|j| k[i * n + j]).sum();
804            assert!(
805                row_sum.abs() < 1e-9,
806                "K row {i} sum = {row_sum} (expected ≈ 0)"
807            );
808        }
809
810        // M is SPD — verify by Cholesky factorisation (must return Ok).
811        crate::linalg::cholesky_factor(&m, n)
812            .expect("M must be positive-definite (Cholesky should succeed)");
813    }
814
815    // ── Task 2 tests ───────────────────────────────────────────────────────────
816
817    #[test]
818    fn test_fem_basis_partition_of_unity() {
819        let (nodes, triangles) = unit_square_mesh();
820        // Interior point in triangle 0 ([0,1,2]).
821        let query = [[0.25_f64, 0.25]];
822        let result = fem_basis_eval(&nodes, &triangles, &query).unwrap();
823        assert_eq!(result.len(), 1);
824        let (_tri_idx, weights) = result[0];
825        let sum: f64 = weights.iter().map(|(_, w)| w).sum();
826        assert!(
827            (sum - 1.0).abs() < 1e-12,
828            "partition of unity violated: sum = {sum}"
829        );
830    }
831
832    #[test]
833    fn test_fem_basis_linear_exactness() {
834        let (nodes, triangles) = unit_square_mesh();
835
836        // Linear field g(x,y) = 2.0 + 3.0*x - 1.5*y
837        let g = |x: f64, y: f64| 2.0 + 3.0 * x - 1.5 * y;
838        let node_values: Vec<f64> = nodes.iter().map(|&[x, y]| g(x, y)).collect();
839
840        let px = 0.3_f64;
841        let py = 0.25_f64;
842        let query = [[px, py]];
843        let result = fem_basis_eval(&nodes, &triangles, &query).unwrap();
844        let (_tri_idx, weights) = result[0];
845
846        // Reconstruct via P1 interpolation: sum hat_value * g_at_node.
847        let interpolated: f64 = weights
848            .iter()
849            .map(|(node_idx, hat_val)| hat_val * node_values[*node_idx])
850            .sum();
851        let exact = g(px, py);
852
853        assert!(
854            (interpolated - exact).abs() < 1e-10,
855            "linear exactness violated: interpolated={interpolated}, exact={exact}"
856        );
857    }
858
859    // ── Task 3 tests — error paths ─────────────────────────────────────────────
860
861    #[test]
862    fn test_fem_degenerate_triangle_error() {
863        // Collinear nodes: all on the x-axis → area = 0.
864        let nodes = [[0.0_f64, 0.0], [1.0, 0.0], [2.0, 0.0]];
865        let triangles = [[0_usize, 1, 2]];
866        let result = assemble_fem_matrices(&nodes, &triangles);
867        assert!(
868            matches!(result, Err(FdarError::InvalidParameter { .. })),
869            "degenerate triangle must return InvalidParameter, got: {result:?}"
870        );
871    }
872
873    #[test]
874    fn test_fem_bad_index_error() {
875        // 4 nodes but triangle references index 4 (out of range).
876        let (nodes, _) = unit_square_mesh();
877        let triangles = [[0_usize, 1, 4]]; // index 4 >= len(nodes)=4
878        let result = assemble_fem_matrices(&nodes, &triangles);
879        assert!(
880            matches!(result, Err(FdarError::InvalidParameter { .. })),
881            "out-of-range index must return InvalidParameter, got: {result:?}"
882        );
883    }
884
885    #[test]
886    fn test_fem_empty_mesh_error() {
887        // Empty nodes.
888        let result_empty_nodes = assemble_fem_matrices(&[] as &[[f64; 2]], &[[0_usize, 1, 2]]);
889        assert!(
890            matches!(result_empty_nodes, Err(FdarError::InvalidDimension { .. })),
891            "empty nodes must return InvalidDimension, got: {result_empty_nodes:?}"
892        );
893
894        // Empty triangles.
895        let (nodes, _) = unit_square_mesh();
896        let result_empty_tris = assemble_fem_matrices(&nodes, &[] as &[[usize; 3]]);
897        assert!(
898            matches!(result_empty_tris, Err(FdarError::InvalidDimension { .. })),
899            "empty triangles must return InvalidDimension, got: {result_empty_tris:?}"
900        );
901    }
902
903    #[test]
904    fn test_fem_obs_outside_mesh_error() {
905        let (nodes, triangles) = unit_square_mesh();
906        // Point clearly outside the [0,1]×[0,1] unit square.
907        let query = [[5.0_f64, 5.0]];
908        let result = fem_basis_eval(&nodes, &triangles, &query);
909        assert!(
910            matches!(
911                result,
912                Err(FdarError::InvalidParameter {
913                    parameter: "query_xy",
914                    ..
915                })
916            ),
917            "outside-mesh point must return InvalidParameter(query_xy), got: {result:?}"
918        );
919    }
920
921    // ── Refined mesh fixture (4×4 nodes = 16 nodes, 18 triangles) ─────────────
922    //
923    // Grid cells: 3×3 = 9 cells, each split into 2 triangles → 18 triangles total.
924    // Node (i, j) has index i * 4 + j  (i = row 0..4, j = col 0..4).
925    // Node coords: x = j/3, y = i/3   (maps [0,3]×[0,3] grid to [0,1]×[0,1]).
926    //
927    // Each cell (i, j) with i in 0..3, j in 0..3 has lower-left node at index i*4+j.
928    //   Lower-left triangle: [i*4+j, i*4+j+1, (i+1)*4+j+1]
929    //   Upper-right triangle: [i*4+j, (i+1)*4+j+1, (i+1)*4+j]
930    fn refined_square_mesh() -> (Vec<[f64; 2]>, Vec<[usize; 3]>) {
931        let mut nodes = Vec::with_capacity(16);
932        for i in 0..4_usize {
933            for j in 0..4_usize {
934                nodes.push([j as f64 / 3.0, i as f64 / 3.0]);
935            }
936        }
937        let mut triangles = Vec::with_capacity(18);
938        for i in 0..3_usize {
939            for j in 0..3_usize {
940                let ll = i * 4 + j; // lower-left
941                let lr = i * 4 + j + 1; // lower-right
942                let ul = (i + 1) * 4 + j; // upper-left
943                let ur = (i + 1) * 4 + j + 1; // upper-right
944                triangles.push([ll, lr, ur]);
945                triangles.push([ll, ur, ul]);
946            }
947        }
948        (nodes, triangles)
949    }
950
951    /// Observation points placed at cell centres (deterministic, no RNG).
952    fn cell_centres() -> Vec<[f64; 2]> {
953        let mut pts = Vec::with_capacity(9);
954        for i in 0..3_usize {
955            for j in 0..3_usize {
956                let cx = (j as f64 + 0.5) / 3.0;
957                let cy = (i as f64 + 0.5) / 3.0;
958                pts.push([cx, cy]);
959            }
960        }
961        pts
962    }
963
964    // ── Task 1 (tracer) tests ──────────────────────────────────────────────────
965
966    #[test]
967    fn test_fem_smooth_solves_and_reduces_residual() {
968        let (nodes, triangles) = refined_square_mesh();
969        let obs_xy = cell_centres();
970        let n_obs = obs_xy.len();
971
972        // Smooth ground truth: g(x,y) = sin(π·x)·sin(π·y) evaluated at cell centres.
973        let g =
974            |x: f64, y: f64| (std::f64::consts::PI * x).sin() * (std::f64::consts::PI * y).sin();
975        let y: Vec<f64> = obs_xy.iter().map(|&[x, y]| g(x, y)).collect();
976
977        let result = fem_smooth(&nodes, &triangles, &obs_xy, &y, 1e-2).unwrap();
978
979        assert_eq!(
980            result.node_values.len(),
981            nodes.len(),
982            "node_values length mismatch"
983        );
984        assert_eq!(
985            result.fitted_obs.len(),
986            obs_xy.len(),
987            "fitted_obs length mismatch"
988        );
989        assert!(result.rss.is_finite(), "rss must be finite");
990
991        // RSS / n_obs must be small relative to variance of y.
992        let y_mean = y.iter().sum::<f64>() / n_obs as f64;
993        let y_var = y.iter().map(|&v| (v - y_mean).powi(2)).sum::<f64>() / n_obs as f64;
994        let relative_mse = result.rss / n_obs as f64;
995        assert!(
996            relative_mse < 0.1 * y_var.max(1e-6),
997            "relative MSE = {relative_mse:.3e} should be small relative to y variance {y_var:.3e}"
998        );
999    }
1000
1001    // ── Task 2 tests — GCV/edf + surface recovery + interpolation limit ────────
1002
1003    #[test]
1004    fn test_fem_smooth_recovers_surface() {
1005        let (nodes, triangles) = refined_square_mesh();
1006        let obs_xy = cell_centres();
1007
1008        let g =
1009            |x: f64, y: f64| (std::f64::consts::PI * x).sin() * (std::f64::consts::PI * y).sin();
1010        let y: Vec<f64> = obs_xy.iter().map(|&[x, y]| g(x, y)).collect();
1011
1012        let result = fem_smooth(&nodes, &triangles, &obs_xy, &y, 1e-3).unwrap();
1013
1014        // Mean absolute error between fitted and true at obs points.
1015        let mae = result
1016            .fitted_obs
1017            .iter()
1018            .zip(y.iter())
1019            .map(|(&f, &t)| (f - t).abs())
1020            .sum::<f64>()
1021            / obs_xy.len() as f64;
1022
1023        assert!(
1024            mae < 0.15,
1025            "surface recovery MAE = {mae:.4} should be below 0.15"
1026        );
1027    }
1028
1029    #[test]
1030    fn test_fem_smooth_interpolation_limit() {
1031        let (nodes, triangles) = refined_square_mesh();
1032        let obs_xy = cell_centres();
1033
1034        let g =
1035            |x: f64, y: f64| (std::f64::consts::PI * x).sin() * (std::f64::consts::PI * y).sin();
1036        let y: Vec<f64> = obs_xy.iter().map(|&[x, y]| g(x, y)).collect();
1037
1038        // Very small λ → near-interpolation (small residuals at observations).
1039        let result_small = fem_smooth(&nodes, &triangles, &obs_xy, &y, 1e-8).unwrap();
1040        // Large λ → strong smoothing (larger residuals).
1041        let result_large = fem_smooth(&nodes, &triangles, &obs_xy, &y, 10.0).unwrap();
1042
1043        assert!(
1044            result_small.rss < result_large.rss,
1045            "small λ should yield smaller RSS: small={:.4e} vs large={:.4e}",
1046            result_small.rss,
1047            result_large.rss
1048        );
1049        // At very small λ, residuals at observations should be near zero.
1050        let max_resid_small = result_small
1051            .fitted_obs
1052            .iter()
1053            .zip(y.iter())
1054            .map(|(&f, &t)| (f - t).abs())
1055            .fold(0.0_f64, f64::max);
1056        assert!(
1057            max_resid_small < 0.05,
1058            "at λ=1e-8 max residual at obs = {max_resid_small:.4e} should approach 0"
1059        );
1060    }
1061
1062    #[test]
1063    fn test_fem_gcv_finite() {
1064        let (nodes, triangles) = refined_square_mesh();
1065        let obs_xy = cell_centres();
1066        let n_obs = obs_xy.len();
1067
1068        let g =
1069            |x: f64, y: f64| (std::f64::consts::PI * x).sin() * (std::f64::consts::PI * y).sin();
1070        let y: Vec<f64> = obs_xy.iter().map(|&[x, y]| g(x, y)).collect();
1071
1072        let result = fem_smooth(&nodes, &triangles, &obs_xy, &y, 0.1).unwrap();
1073
1074        assert!(
1075            result.gcv.is_finite(),
1076            "GCV must be finite, got: {}",
1077            result.gcv
1078        );
1079        assert!(
1080            result.edf > 0.0,
1081            "edf must be positive, got: {}",
1082            result.edf
1083        );
1084        assert!(
1085            result.edf <= n_obs as f64 + 1e-6,
1086            "edf must not exceed n_obs={n_obs}, got: {}",
1087            result.edf
1088        );
1089    }
1090
1091    // ── Task 3 tests — fem_smooth_gcv + fem_predict + outside-mesh error ───────
1092
1093    #[test]
1094    fn test_fem_smooth_gcv_selects_finite() {
1095        let (nodes, triangles) = refined_square_mesh();
1096        let obs_xy = cell_centres();
1097
1098        let g =
1099            |x: f64, y: f64| (std::f64::consts::PI * x).sin() * (std::f64::consts::PI * y).sin();
1100        let y: Vec<f64> = obs_xy.iter().map(|&[x, y]| g(x, y)).collect();
1101
1102        let result = fem_smooth_gcv(&nodes, &triangles, &obs_xy, &y, (-6.0, 2.0), 9).unwrap();
1103
1104        assert!(
1105            result.gcv.is_finite(),
1106            "GCV from gcv search must be finite, got: {}",
1107            result.gcv
1108        );
1109        assert!(
1110            result.lambda >= 1e-6 && result.lambda <= 1e2 + 1e-9,
1111            "chosen lambda = {} must lie within [1e-6, 1e2]",
1112            result.lambda
1113        );
1114    }
1115
1116    #[test]
1117    fn test_fem_predict_matches_nodes() {
1118        // Use the unit-square mesh (4 nodes, 2 triangles).
1119        let (nodes, triangles) = unit_square_mesh();
1120
1121        // Linear field f(x, y) = 1.0 + 2.0*x + 3.0*y evaluated at nodes.
1122        let f_lin = |x: f64, y: f64| 1.0 + 2.0 * x + 3.0 * y;
1123        let node_values: Vec<f64> = nodes.iter().map(|&[x, y]| f_lin(x, y)).collect();
1124
1125        // Interior query points (inside the mesh).
1126        let query_xy: Vec<[f64; 2]> = vec![[0.25, 0.25], [0.5, 0.5], [0.75, 0.25], [0.25, 0.75]];
1127
1128        let preds = fem_predict(&node_values, &nodes, &triangles, &query_xy).unwrap();
1129
1130        for (&[qx, qy], &pred) in query_xy.iter().zip(preds.iter()) {
1131            let exact = f_lin(qx, qy);
1132            assert!(
1133                (pred - exact).abs() < 1e-9,
1134                "fem_predict at ({qx},{qy}): got {pred}, expected {exact}"
1135            );
1136        }
1137    }
1138
1139    #[test]
1140    fn test_fem_smooth_obs_outside_mesh_error() {
1141        let (nodes, triangles) = refined_square_mesh();
1142
1143        // One obs point clearly outside the [0,1]×[0,1] mesh.
1144        let obs_xy: Vec<[f64; 2]> = vec![[0.25, 0.25], [5.0, 5.0]];
1145        let y = vec![0.5, 0.8];
1146
1147        let result = fem_smooth(&nodes, &triangles, &obs_xy, &y, 0.1);
1148        assert!(
1149            matches!(
1150                result,
1151                Err(FdarError::InvalidParameter {
1152                    parameter: "query_xy",
1153                    ..
1154                })
1155            ),
1156            "obs outside mesh must return InvalidParameter(query_xy), got: {result:?}"
1157        );
1158    }
1159}