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}