use crate::error::FdarError;
#[must_use]
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct FemSmoothResult {
pub node_values: Vec<f64>,
pub fitted_obs: Vec<f64>,
pub edf: f64,
pub gcv: f64,
pub rss: f64,
pub lambda: f64,
pub n_nodes: usize,
pub n_triangles: usize,
}
const BARY_EPS: f64 = 1e-10;
const BARY_DET_EPS: f64 = 1e-14;
fn mesh_validate(nodes: &[[f64; 2]], triangles: &[[usize; 3]]) -> Result<(), FdarError> {
if nodes.is_empty() {
return Err(FdarError::InvalidDimension {
parameter: "nodes",
expected: "at least one node".to_string(),
actual: "0 nodes".to_string(),
});
}
if triangles.is_empty() {
return Err(FdarError::InvalidDimension {
parameter: "triangles",
expected: "at least one triangle".to_string(),
actual: "0 triangles".to_string(),
});
}
let n = nodes.len();
let x_min = nodes.iter().map(|p| p[0]).fold(f64::INFINITY, f64::min);
let x_max = nodes.iter().map(|p| p[0]).fold(f64::NEG_INFINITY, f64::max);
let y_min = nodes.iter().map(|p| p[1]).fold(f64::INFINITY, f64::min);
let y_max = nodes.iter().map(|p| p[1]).fold(f64::NEG_INFINITY, f64::max);
let bbox_area = (x_max - x_min) * (y_max - y_min);
let area_tol = 1e-12 * bbox_area.max(1.0);
for (tri_idx, tri) in triangles.iter().enumerate() {
for &vi in tri.iter() {
if vi >= n {
return Err(FdarError::InvalidParameter {
parameter: "triangles",
message: format!(
"triangle {tri_idx} references vertex index {vi} which is out of range \
(mesh has {n} nodes)"
),
});
}
}
let [v0, v1, v2] = *tri;
let (x0, y0) = (nodes[v0][0], nodes[v0][1]);
let (x1, y1) = (nodes[v1][0], nodes[v1][1]);
let (x2, y2) = (nodes[v2][0], nodes[v2][1]);
let signed_area_2 = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0);
let area = 0.5 * signed_area_2.abs();
if area < area_tol {
return Err(FdarError::InvalidParameter {
parameter: "triangles",
message: format!(
"triangle {tri_idx} is degenerate (area ≈ {area:.2e} < tolerance \
{area_tol:.2e}); check for collinear or coincident nodes"
),
});
}
}
Ok(())
}
#[inline]
fn element_mass(area: f64) -> [[f64; 3]; 3] {
let a = area / 12.0;
[[2.0 * a, a, a], [a, 2.0 * a, a], [a, a, 2.0 * a]]
}
#[inline]
fn element_stiffness(
x0: f64,
y0: f64,
x1: f64,
y1: f64,
x2: f64,
y2: f64,
area: f64,
) -> [[f64; 3]; 3] {
let b0 = y1 - y2;
let c0 = x2 - x1;
let b1 = y2 - y0;
let c1 = x0 - x2;
let b2 = y0 - y1;
let c2 = x1 - x0;
let s = 1.0 / (4.0 * area);
[
[
s * (b0 * b0 + c0 * c0),
s * (b0 * b1 + c0 * c1),
s * (b0 * b2 + c0 * c2),
],
[
s * (b1 * b0 + c1 * c0),
s * (b1 * b1 + c1 * c1),
s * (b1 * b2 + c1 * c2),
],
[
s * (b2 * b0 + c2 * c0),
s * (b2 * b1 + c2 * c1),
s * (b2 * b2 + c2 * c2),
],
]
}
pub fn assemble_fem_matrices(
nodes: &[[f64; 2]],
triangles: &[[usize; 3]],
) -> Result<(Vec<f64>, Vec<f64>), FdarError> {
mesh_validate(nodes, triangles)?;
let n = nodes.len();
let mut m_global = vec![0.0_f64; n * n];
let mut k_global = vec![0.0_f64; n * n];
for tri in triangles {
let [v0, v1, v2] = *tri;
let (x0, y0) = (nodes[v0][0], nodes[v0][1]);
let (x1, y1) = (nodes[v1][0], nodes[v1][1]);
let (x2, y2) = (nodes[v2][0], nodes[v2][1]);
let area = 0.5 * ((x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0)).abs();
let m_e = element_mass(area);
let k_e = element_stiffness(x0, y0, x1, y1, x2, y2, area);
let local = [v0, v1, v2];
for (li, &gi) in local.iter().enumerate() {
for (lj, &gj) in local.iter().enumerate() {
m_global[gi * n + gj] += m_e[li][lj];
k_global[gi * n + gj] += k_e[li][lj];
}
}
}
Ok((m_global, k_global))
}
#[inline]
fn barycentric(
px: f64,
py: f64,
x0: f64,
y0: f64,
x1: f64,
y1: f64,
x2: f64,
y2: f64,
) -> Option<(f64, f64, f64)> {
let det = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0);
if det.abs() < BARY_DET_EPS {
return None;
}
let lam1 = ((px - x0) * (y2 - y0) - (py - y0) * (x2 - x0)) / det;
let lam2 = ((py - y0) * (x1 - x0) - (px - x0) * (y1 - y0)) / det;
let lam0 = 1.0 - lam1 - lam2;
Some((lam0, lam1, lam2))
}
fn locate_point(
nodes: &[[f64; 2]],
triangles: &[[usize; 3]],
px: f64,
py: f64,
) -> Option<(usize, (f64, f64, f64))> {
for (tri_idx, tri) in triangles.iter().enumerate() {
let [v0, v1, v2] = *tri;
let (x0, y0) = (nodes[v0][0], nodes[v0][1]);
let (x1, y1) = (nodes[v1][0], nodes[v1][1]);
let (x2, y2) = (nodes[v2][0], nodes[v2][1]);
if let Some((lam0, lam1, lam2)) = barycentric(px, py, x0, y0, x1, y1, x2, y2) {
if lam0 >= -BARY_EPS && lam1 >= -BARY_EPS && lam2 >= -BARY_EPS {
return Some((tri_idx, (lam0, lam1, lam2)));
}
}
}
None
}
pub fn fem_basis_eval(
nodes: &[[f64; 2]],
triangles: &[[usize; 3]],
query_xy: &[[f64; 2]],
) -> Result<Vec<(usize, [(usize, f64); 3])>, FdarError> {
mesh_validate(nodes, triangles)?;
let mut result = Vec::with_capacity(query_xy.len());
for (qi, &[px, py]) in query_xy.iter().enumerate() {
match locate_point(nodes, triangles, px, py) {
Some((tri_idx, (lam0, lam1, lam2))) => {
let [v0, v1, v2] = triangles[tri_idx];
result.push((tri_idx, [(v0, lam0), (v1, lam1), (v2, lam2)]));
}
None => {
return Err(FdarError::InvalidParameter {
parameter: "query_xy",
message: format!(
"query point {qi} ([{px}, {py}]) lies outside the triangulated mesh"
),
});
}
}
}
Ok(result)
}
#[must_use = "expensive FEM smoothing computation whose result should not be discarded"]
pub fn fem_smooth(
nodes: &[[f64; 2]],
triangles: &[[usize; 3]],
obs_xy: &[[f64; 2]],
y: &[f64],
lambda: f64,
) -> Result<FemSmoothResult, FdarError> {
if y.is_empty() {
return Err(FdarError::InvalidDimension {
parameter: "y",
expected: "at least one observation".to_string(),
actual: "0 observations".to_string(),
});
}
if obs_xy.len() != y.len() {
return Err(FdarError::InvalidDimension {
parameter: "obs_xy",
expected: format!("{} (= len(y))", y.len()),
actual: obs_xy.len().to_string(),
});
}
if lambda < 0.0 {
return Err(FdarError::InvalidParameter {
parameter: "lambda",
message: "smoothing parameter must be >= 0.0".to_string(),
});
}
let n_obs = obs_xy.len();
let big_n = nodes.len();
let (_m, k_global) = assemble_fem_matrices(nodes, triangles)?;
let basis_evals = fem_basis_eval(nodes, triangles, obs_xy)?;
let mut phi = vec![0.0_f64; n_obs * big_n];
for (i, (_tri_idx, weights)) in basis_evals.iter().enumerate() {
for &(node_idx, hat_val) in weights.iter() {
phi[i * big_n + node_idx] = hat_val;
}
}
let mut phi_t_phi = vec![0.0_f64; big_n * big_n];
let mut a_mat = vec![0.0_f64; big_n * big_n];
for i in 0..n_obs {
for a in 0..big_n {
let phi_ia = phi[i * big_n + a];
if phi_ia == 0.0 {
continue;
}
for b in a..big_n {
let val = phi_ia * phi[i * big_n + b];
phi_t_phi[a * big_n + b] += val;
a_mat[a * big_n + b] += val;
if a != b {
phi_t_phi[b * big_n + a] += val;
a_mat[b * big_n + a] += val;
}
}
}
}
for ab in 0..(big_n * big_n) {
a_mat[ab] += lambda * k_global[ab];
}
for a in 0..big_n {
a_mat[a * big_n + a] += 1e-10; }
let mut phi_t_y = vec![0.0_f64; big_n];
for i in 0..n_obs {
for a in 0..big_n {
phi_t_y[a] += phi[i * big_n + a] * y[i];
}
}
let c = crate::linalg::cholesky_solve(&a_mat, &phi_t_y, big_n)?;
let fitted_obs: Vec<f64> = (0..n_obs)
.map(|i| (0..big_n).map(|a| phi[i * big_n + a] * c[a]).sum())
.collect();
let rss: f64 = (0..n_obs).map(|i| (y[i] - fitted_obs[i]).powi(2)).sum();
let l = crate::linalg::cholesky_factor(&a_mat, big_n)?;
let mut a_inv = vec![0.0_f64; big_n * big_n];
let mut e_col = vec![0.0_f64; big_n];
for j in 0..big_n {
e_col.iter_mut().for_each(|v| *v = 0.0);
e_col[j] = 1.0;
let col = crate::linalg::cholesky_forward_back(&l, &e_col, big_n);
for i in 0..big_n {
a_inv[i * big_n + j] = col[i]; }
}
let mut edf = 0.0_f64;
for a in 0..big_n {
for b in 0..big_n {
edf += a_inv[a * big_n + b] * phi_t_phi[b * big_n + a];
}
}
let n_obs_f = n_obs as f64;
let gcv_denom = 1.0 - edf / n_obs_f;
let gcv = if gcv_denom.abs() > 1e-10 {
(rss / n_obs_f) / (gcv_denom * gcv_denom)
} else {
f64::INFINITY
};
Ok(FemSmoothResult {
node_values: c,
fitted_obs,
edf,
gcv,
rss,
lambda,
n_nodes: nodes.len(),
n_triangles: triangles.len(),
})
}
#[must_use = "GCV-selected FEM smoothing result should not be discarded"]
pub fn fem_smooth_gcv(
nodes: &[[f64; 2]],
triangles: &[[usize; 3]],
obs_xy: &[[f64; 2]],
y: &[f64],
log_lambda_range: (f64, f64),
n_grid: usize,
) -> Result<FemSmoothResult, FdarError> {
if n_grid < 2 {
return Err(FdarError::InvalidParameter {
parameter: "n_grid",
message: "GCV lambda grid requires at least 2 points".to_string(),
});
}
let (lo, hi) = log_lambda_range;
let mut best_gcv = f64::INFINITY;
let mut best_result: Option<FemSmoothResult> = None;
let mut last_err: Option<FdarError> = None;
for i in 0..n_grid {
let log_lam = lo + (hi - lo) * i as f64 / (n_grid - 1) as f64;
let lam = 10.0_f64.powf(log_lam);
match fem_smooth(nodes, triangles, obs_xy, y, lam) {
Ok(res) => {
if res.gcv.is_finite() && res.gcv < best_gcv {
best_gcv = res.gcv;
best_result = Some(res);
}
}
Err(e) => {
last_err = Some(e);
}
}
}
if let Some(result) = best_result {
return Ok(result);
}
if let Some(e) = last_err {
return Err(e);
}
Err(FdarError::ComputationFailed {
operation: "fem_smooth_gcv",
detail: "all lambda grid points produced non-finite GCV; try widening \
log_lambda_range or adding more observations"
.to_string(),
})
}
#[must_use = "FEM surface prediction result should not be discarded"]
pub fn fem_predict(
node_values: &[f64],
nodes: &[[f64; 2]],
triangles: &[[usize; 3]],
query_xy: &[[f64; 2]],
) -> Result<Vec<f64>, FdarError> {
if node_values.len() != nodes.len() {
return Err(FdarError::InvalidDimension {
parameter: "node_values",
expected: format!("{} (= nodes.len())", nodes.len()),
actual: node_values.len().to_string(),
});
}
let basis_evals = fem_basis_eval(nodes, triangles, query_xy)?;
let predictions: Vec<f64> = basis_evals
.iter()
.map(|(_tri_idx, weights)| {
weights
.iter()
.map(|&(node_idx, hat_val)| hat_val * node_values[node_idx])
.sum()
})
.collect();
Ok(predictions)
}
#[cfg(test)]
mod tests {
use super::*;
fn unit_square_mesh() -> ([[f64; 2]; 4], [[usize; 3]; 2]) {
let nodes = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
let triangles = [[0, 1, 2], [0, 2, 3]];
(nodes, triangles)
}
#[test]
fn test_assemble_unit_square_symmetry_and_nullspace() {
let (nodes, triangles) = unit_square_mesh();
let (m, k) = assemble_fem_matrices(&nodes, &triangles).unwrap();
assert_eq!(m.len(), 16, "M must be 4×4");
assert_eq!(k.len(), 16, "K must be 4×4");
let n = 4_usize;
for i in 0..n {
for j in 0..n {
let m_ij = m[i * n + j];
let m_ji = m[j * n + i];
assert!(
(m_ij - m_ji).abs() < 1e-12,
"M not symmetric at ({i},{j}): {m_ij} vs {m_ji}"
);
let k_ij = k[i * n + j];
let k_ji = k[j * n + i];
assert!(
(k_ij - k_ji).abs() < 1e-12,
"K not symmetric at ({i},{j}): {k_ij} vs {k_ji}"
);
}
}
for i in 0..n {
let row_sum: f64 = (0..n).map(|j| k[i * n + j]).sum();
assert!(
row_sum.abs() < 1e-9,
"K row {i} sum = {row_sum} (expected ≈ 0)"
);
}
crate::linalg::cholesky_factor(&m, n)
.expect("M must be positive-definite (Cholesky should succeed)");
}
#[test]
fn test_fem_basis_partition_of_unity() {
let (nodes, triangles) = unit_square_mesh();
let query = [[0.25_f64, 0.25]];
let result = fem_basis_eval(&nodes, &triangles, &query).unwrap();
assert_eq!(result.len(), 1);
let (_tri_idx, weights) = result[0];
let sum: f64 = weights.iter().map(|(_, w)| w).sum();
assert!(
(sum - 1.0).abs() < 1e-12,
"partition of unity violated: sum = {sum}"
);
}
#[test]
fn test_fem_basis_linear_exactness() {
let (nodes, triangles) = unit_square_mesh();
let g = |x: f64, y: f64| 2.0 + 3.0 * x - 1.5 * y;
let node_values: Vec<f64> = nodes.iter().map(|&[x, y]| g(x, y)).collect();
let px = 0.3_f64;
let py = 0.25_f64;
let query = [[px, py]];
let result = fem_basis_eval(&nodes, &triangles, &query).unwrap();
let (_tri_idx, weights) = result[0];
let interpolated: f64 = weights
.iter()
.map(|(node_idx, hat_val)| hat_val * node_values[*node_idx])
.sum();
let exact = g(px, py);
assert!(
(interpolated - exact).abs() < 1e-10,
"linear exactness violated: interpolated={interpolated}, exact={exact}"
);
}
#[test]
fn test_fem_degenerate_triangle_error() {
let nodes = [[0.0_f64, 0.0], [1.0, 0.0], [2.0, 0.0]];
let triangles = [[0_usize, 1, 2]];
let result = assemble_fem_matrices(&nodes, &triangles);
assert!(
matches!(result, Err(FdarError::InvalidParameter { .. })),
"degenerate triangle must return InvalidParameter, got: {result:?}"
);
}
#[test]
fn test_fem_bad_index_error() {
let (nodes, _) = unit_square_mesh();
let triangles = [[0_usize, 1, 4]]; let result = assemble_fem_matrices(&nodes, &triangles);
assert!(
matches!(result, Err(FdarError::InvalidParameter { .. })),
"out-of-range index must return InvalidParameter, got: {result:?}"
);
}
#[test]
fn test_fem_empty_mesh_error() {
let result_empty_nodes = assemble_fem_matrices(&[] as &[[f64; 2]], &[[0_usize, 1, 2]]);
assert!(
matches!(result_empty_nodes, Err(FdarError::InvalidDimension { .. })),
"empty nodes must return InvalidDimension, got: {result_empty_nodes:?}"
);
let (nodes, _) = unit_square_mesh();
let result_empty_tris = assemble_fem_matrices(&nodes, &[] as &[[usize; 3]]);
assert!(
matches!(result_empty_tris, Err(FdarError::InvalidDimension { .. })),
"empty triangles must return InvalidDimension, got: {result_empty_tris:?}"
);
}
#[test]
fn test_fem_obs_outside_mesh_error() {
let (nodes, triangles) = unit_square_mesh();
let query = [[5.0_f64, 5.0]];
let result = fem_basis_eval(&nodes, &triangles, &query);
assert!(
matches!(
result,
Err(FdarError::InvalidParameter {
parameter: "query_xy",
..
})
),
"outside-mesh point must return InvalidParameter(query_xy), got: {result:?}"
);
}
fn refined_square_mesh() -> (Vec<[f64; 2]>, Vec<[usize; 3]>) {
let mut nodes = Vec::with_capacity(16);
for i in 0..4_usize {
for j in 0..4_usize {
nodes.push([j as f64 / 3.0, i as f64 / 3.0]);
}
}
let mut triangles = Vec::with_capacity(18);
for i in 0..3_usize {
for j in 0..3_usize {
let ll = i * 4 + j; let lr = i * 4 + j + 1; let ul = (i + 1) * 4 + j; let ur = (i + 1) * 4 + j + 1; triangles.push([ll, lr, ur]);
triangles.push([ll, ur, ul]);
}
}
(nodes, triangles)
}
fn cell_centres() -> Vec<[f64; 2]> {
let mut pts = Vec::with_capacity(9);
for i in 0..3_usize {
for j in 0..3_usize {
let cx = (j as f64 + 0.5) / 3.0;
let cy = (i as f64 + 0.5) / 3.0;
pts.push([cx, cy]);
}
}
pts
}
#[test]
fn test_fem_smooth_solves_and_reduces_residual() {
let (nodes, triangles) = refined_square_mesh();
let obs_xy = cell_centres();
let n_obs = obs_xy.len();
let g =
|x: f64, y: f64| (std::f64::consts::PI * x).sin() * (std::f64::consts::PI * y).sin();
let y: Vec<f64> = obs_xy.iter().map(|&[x, y]| g(x, y)).collect();
let result = fem_smooth(&nodes, &triangles, &obs_xy, &y, 1e-2).unwrap();
assert_eq!(
result.node_values.len(),
nodes.len(),
"node_values length mismatch"
);
assert_eq!(
result.fitted_obs.len(),
obs_xy.len(),
"fitted_obs length mismatch"
);
assert!(result.rss.is_finite(), "rss must be finite");
let y_mean = y.iter().sum::<f64>() / n_obs as f64;
let y_var = y.iter().map(|&v| (v - y_mean).powi(2)).sum::<f64>() / n_obs as f64;
let relative_mse = result.rss / n_obs as f64;
assert!(
relative_mse < 0.1 * y_var.max(1e-6),
"relative MSE = {relative_mse:.3e} should be small relative to y variance {y_var:.3e}"
);
}
#[test]
fn test_fem_smooth_recovers_surface() {
let (nodes, triangles) = refined_square_mesh();
let obs_xy = cell_centres();
let g =
|x: f64, y: f64| (std::f64::consts::PI * x).sin() * (std::f64::consts::PI * y).sin();
let y: Vec<f64> = obs_xy.iter().map(|&[x, y]| g(x, y)).collect();
let result = fem_smooth(&nodes, &triangles, &obs_xy, &y, 1e-3).unwrap();
let mae = result
.fitted_obs
.iter()
.zip(y.iter())
.map(|(&f, &t)| (f - t).abs())
.sum::<f64>()
/ obs_xy.len() as f64;
assert!(
mae < 0.15,
"surface recovery MAE = {mae:.4} should be below 0.15"
);
}
#[test]
fn test_fem_smooth_interpolation_limit() {
let (nodes, triangles) = refined_square_mesh();
let obs_xy = cell_centres();
let g =
|x: f64, y: f64| (std::f64::consts::PI * x).sin() * (std::f64::consts::PI * y).sin();
let y: Vec<f64> = obs_xy.iter().map(|&[x, y]| g(x, y)).collect();
let result_small = fem_smooth(&nodes, &triangles, &obs_xy, &y, 1e-8).unwrap();
let result_large = fem_smooth(&nodes, &triangles, &obs_xy, &y, 10.0).unwrap();
assert!(
result_small.rss < result_large.rss,
"small λ should yield smaller RSS: small={:.4e} vs large={:.4e}",
result_small.rss,
result_large.rss
);
let max_resid_small = result_small
.fitted_obs
.iter()
.zip(y.iter())
.map(|(&f, &t)| (f - t).abs())
.fold(0.0_f64, f64::max);
assert!(
max_resid_small < 0.05,
"at λ=1e-8 max residual at obs = {max_resid_small:.4e} should approach 0"
);
}
#[test]
fn test_fem_gcv_finite() {
let (nodes, triangles) = refined_square_mesh();
let obs_xy = cell_centres();
let n_obs = obs_xy.len();
let g =
|x: f64, y: f64| (std::f64::consts::PI * x).sin() * (std::f64::consts::PI * y).sin();
let y: Vec<f64> = obs_xy.iter().map(|&[x, y]| g(x, y)).collect();
let result = fem_smooth(&nodes, &triangles, &obs_xy, &y, 0.1).unwrap();
assert!(
result.gcv.is_finite(),
"GCV must be finite, got: {}",
result.gcv
);
assert!(
result.edf > 0.0,
"edf must be positive, got: {}",
result.edf
);
assert!(
result.edf <= n_obs as f64 + 1e-6,
"edf must not exceed n_obs={n_obs}, got: {}",
result.edf
);
}
#[test]
fn test_fem_smooth_gcv_selects_finite() {
let (nodes, triangles) = refined_square_mesh();
let obs_xy = cell_centres();
let g =
|x: f64, y: f64| (std::f64::consts::PI * x).sin() * (std::f64::consts::PI * y).sin();
let y: Vec<f64> = obs_xy.iter().map(|&[x, y]| g(x, y)).collect();
let result = fem_smooth_gcv(&nodes, &triangles, &obs_xy, &y, (-6.0, 2.0), 9).unwrap();
assert!(
result.gcv.is_finite(),
"GCV from gcv search must be finite, got: {}",
result.gcv
);
assert!(
result.lambda >= 1e-6 && result.lambda <= 1e2 + 1e-9,
"chosen lambda = {} must lie within [1e-6, 1e2]",
result.lambda
);
}
#[test]
fn test_fem_predict_matches_nodes() {
let (nodes, triangles) = unit_square_mesh();
let f_lin = |x: f64, y: f64| 1.0 + 2.0 * x + 3.0 * y;
let node_values: Vec<f64> = nodes.iter().map(|&[x, y]| f_lin(x, y)).collect();
let query_xy: Vec<[f64; 2]> = vec![[0.25, 0.25], [0.5, 0.5], [0.75, 0.25], [0.25, 0.75]];
let preds = fem_predict(&node_values, &nodes, &triangles, &query_xy).unwrap();
for (&[qx, qy], &pred) in query_xy.iter().zip(preds.iter()) {
let exact = f_lin(qx, qy);
assert!(
(pred - exact).abs() < 1e-9,
"fem_predict at ({qx},{qy}): got {pred}, expected {exact}"
);
}
}
#[test]
fn test_fem_smooth_obs_outside_mesh_error() {
let (nodes, triangles) = refined_square_mesh();
let obs_xy: Vec<[f64; 2]> = vec![[0.25, 0.25], [5.0, 5.0]];
let y = vec![0.5, 0.8];
let result = fem_smooth(&nodes, &triangles, &obs_xy, &y, 0.1);
assert!(
matches!(
result,
Err(FdarError::InvalidParameter {
parameter: "query_xy",
..
})
),
"obs outside mesh must return InvalidParameter(query_xy), got: {result:?}"
);
}
}