use crate::error::EmlError;
use super::numerics::{
apply_axis_derivative, central_differences, first_derivative_1d, second_derivative_1d,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PdeShape {
D1 {
nx: usize,
},
D2 {
nx: usize,
ny: usize,
},
D3 {
nx: usize,
ny: usize,
nz: usize,
},
}
impl PdeShape {
pub fn n_spatial(&self) -> usize {
match self {
PdeShape::D1 { nx } => *nx,
PdeShape::D2 { nx, ny } => nx * ny,
PdeShape::D3 { nx, ny, nz } => nx * ny * nz,
}
}
pub fn ndim(&self) -> usize {
match self {
PdeShape::D1 { .. } => 1,
PdeShape::D2 { .. } => 2,
PdeShape::D3 { .. } => 3,
}
}
pub fn spatial_dims(&self) -> Vec<usize> {
match self {
PdeShape::D1 { nx } => vec![*nx],
PdeShape::D2 { nx, ny } => vec![*nx, *ny],
PdeShape::D3 { nx, ny, nz } => vec![*nx, *ny, *nz],
}
}
}
#[derive(Debug, Clone)]
pub struct PdeField {
pub data: Vec<f64>,
pub shape: PdeShape,
pub dx: Vec<f64>,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PdeLibraryTerm {
pub label: String,
pub latex: String,
pub factors: Vec<Vec<usize>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum PdeMode {
#[default]
Collocation,
WeakForm,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PdeConfig {
pub fd_accuracy: usize,
pub trim_boundary: usize,
pub ridge_lambda: f64,
pub threshold: f64,
pub max_iter: usize,
#[cfg_attr(feature = "serde", serde(default))]
pub spatial_dims: Option<usize>,
#[cfg_attr(feature = "serde", serde(default))]
pub max_deriv_order: Option<usize>,
#[cfg_attr(feature = "serde", serde(default))]
pub library: Option<Vec<PdeLibraryTerm>>,
#[cfg_attr(feature = "serde", serde(default))]
pub mode: Option<PdeMode>,
}
impl Default for PdeConfig {
fn default() -> Self {
Self {
fd_accuracy: 2,
trim_boundary: 1,
ridge_lambda: 1e-5,
threshold: 0.01,
max_iter: 10,
spatial_dims: None,
max_deriv_order: None,
library: None,
mode: None,
}
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PdeResult {
pub equation: String,
pub latex: String,
pub coefficients: Vec<(String, f64)>,
pub mse: f64,
}
fn strridge(
theta: &[f64],
b: &[f64],
n_rows: usize,
n_terms: usize,
lambda: f64,
threshold: f64,
max_iter: usize,
) -> Vec<f64> {
super::strlsq::strlsq(theta, n_rows, n_terms, b, threshold, lambda, max_iter)
}
fn term_names() -> Vec<&'static str> {
vec!["1", "u", "u^2", "u_x", "u*u_x", "u_xx"]
}
fn term_to_latex(name: &str) -> &str {
match name {
"1" => "1",
"u" => "u",
"u^2" => "u^{2}",
"u_x" => "u_x",
"u*u_x" => "u \\cdot u_x",
"u_xx" => "u_{xx}",
_ => name,
}
}
fn default_library_1d() -> Vec<PdeLibraryTerm> {
vec![
PdeLibraryTerm {
label: "1".into(),
latex: "1".into(),
factors: vec![], },
PdeLibraryTerm {
label: "u".into(),
latex: "u".into(),
factors: vec![vec![0]],
},
PdeLibraryTerm {
label: "u^2".into(),
latex: "u^{2}".into(),
factors: vec![vec![0], vec![0]],
},
PdeLibraryTerm {
label: "u_x".into(),
latex: "u_x".into(),
factors: vec![vec![1]],
},
PdeLibraryTerm {
label: "u*u_x".into(),
latex: "u \\cdot u_x".into(),
factors: vec![vec![0], vec![1]],
},
PdeLibraryTerm {
label: "u_xx".into(),
latex: "u_{xx}".into(),
factors: vec![vec![2]],
},
]
}
fn default_library_2d() -> Vec<PdeLibraryTerm> {
vec![
PdeLibraryTerm {
label: "1".into(),
latex: "1".into(),
factors: vec![],
},
PdeLibraryTerm {
label: "u".into(),
latex: "u".into(),
factors: vec![vec![0, 0]],
},
PdeLibraryTerm {
label: "u_x".into(),
latex: "u_x".into(),
factors: vec![vec![1, 0]],
},
PdeLibraryTerm {
label: "u_y".into(),
latex: "u_y".into(),
factors: vec![vec![0, 1]],
},
PdeLibraryTerm {
label: "u_xx".into(),
latex: "u_{xx}".into(),
factors: vec![vec![2, 0]],
},
PdeLibraryTerm {
label: "u_yy".into(),
latex: "u_{yy}".into(),
factors: vec![vec![0, 2]],
},
PdeLibraryTerm {
label: "u_xy".into(),
latex: "u_{xy}".into(),
factors: vec![vec![1, 1]],
},
PdeLibraryTerm {
label: "u*u_x".into(),
latex: "u u_x".into(),
factors: vec![vec![0, 0], vec![1, 0]],
},
PdeLibraryTerm {
label: "u*u_y".into(),
latex: "u u_y".into(),
factors: vec![vec![0, 0], vec![0, 1]],
},
PdeLibraryTerm {
label: "u^2".into(),
latex: "u^{2}".into(),
factors: vec![vec![0, 0], vec![0, 0]],
},
]
}
pub fn discover_pde(
_engine: &super::SymRegEngine,
field: &[Vec<f64>],
dx: f64,
dt: f64,
config: &PdeConfig,
) -> Result<PdeResult, EmlError> {
if field.is_empty() {
return Err(EmlError::EmptyData);
}
let n_time = field.len();
let n_space = field[0].len();
let min_time = 3_usize;
let min_space = 3 + 2 * config.trim_boundary;
if n_time < min_time {
return Err(EmlError::GridTooSmall {
needed: min_time,
got: n_time,
});
}
if n_space < min_space {
return Err(EmlError::GridTooSmall {
needed: min_space,
got: n_space,
});
}
let u_x: Vec<Vec<f64>> = field
.iter()
.map(|row| first_derivative_1d(row, dx, config.fd_accuracy))
.collect();
let u_xx: Vec<Vec<f64>> = field
.iter()
.map(|row| second_derivative_1d(row, dx, config.fd_accuracy))
.collect();
let mut u_t = vec![vec![0.0_f64; n_space]; n_time];
for i in 0..n_space {
let col: Vec<f64> = (0..n_time).map(|j| field[j][i]).collect();
let col_dt = central_differences(&col, dt);
for j in 0..n_time {
u_t[j][i] = col_dt[j];
}
}
let trim_t = 1_usize;
let trim_x = config.trim_boundary;
let t_range = trim_t..n_time.saturating_sub(trim_t);
let x_range = trim_x..n_space.saturating_sub(trim_x);
let n_t_trim = t_range.len();
let n_x_trim = x_range.len();
let n_data = n_t_trim * n_x_trim;
if n_data == 0 {
return Err(EmlError::GridTooSmall { needed: 1, got: 0 });
}
let terms = term_names();
let n_terms = terms.len();
let mut target = vec![0.0_f64; n_data];
let mut theta = vec![0.0_f64; n_data * n_terms];
let mut row_idx = 0_usize;
for j in t_range.clone() {
for i in x_range.clone() {
let u_val = field[j][i];
let ux_val = u_x[j][i];
let uxx_val = u_xx[j][i];
target[row_idx] = u_t[j][i];
theta[row_idx * n_terms] = 1.0;
theta[row_idx * n_terms + 1] = u_val;
theta[row_idx * n_terms + 2] = u_val * u_val;
theta[row_idx * n_terms + 3] = ux_val;
theta[row_idx * n_terms + 4] = u_val * ux_val;
theta[row_idx * n_terms + 5] = uxx_val;
row_idx += 1;
}
}
let mut col_scales = vec![1.0_f64; n_terms];
for j in 0..n_terms {
let ss: f64 = (0..n_data)
.map(|i| theta[i * n_terms + j].powi(2))
.sum::<f64>()
/ n_data as f64;
let scale = ss.sqrt().max(f64::EPSILON);
col_scales[j] = scale;
for i in 0..n_data {
theta[i * n_terms + j] /= scale;
}
}
let coeffs_norm = strridge(
&theta,
&target,
n_data,
n_terms,
config.ridge_lambda,
config.threshold,
config.max_iter,
);
let coeffs: Vec<f64> = coeffs_norm
.iter()
.zip(&col_scales)
.map(|(&c, &s)| c / s)
.collect();
let mut theta_orig = theta.clone();
for j in 0..n_terms {
for i in 0..n_data {
theta_orig[i * n_terms + j] *= col_scales[j];
}
}
let mse = {
let mut ss = 0.0_f64;
for i in 0..n_data {
let pred: f64 = (0..n_terms)
.map(|j| coeffs[j] * theta_orig[i * n_terms + j])
.sum();
ss += (pred - target[i]).powi(2);
}
ss / n_data as f64
};
let active: Vec<(String, f64)> = terms
.iter()
.zip(&coeffs)
.filter(|&(_, &c)| c.abs() >= config.threshold)
.map(|(&name, &c)| (name.to_string(), c))
.collect();
let rhs_pretty = if active.is_empty() {
"0".to_string()
} else {
active
.iter()
.enumerate()
.map(|(k, (name, c))| {
if k == 0 {
format!("{c:.6} {name}")
} else if *c >= 0.0 {
format!(" + {c:.6} {name}")
} else {
format!(" - {:.6} {name}", c.abs())
}
})
.collect::<String>()
};
let rhs_latex = if active.is_empty() {
"0".to_string()
} else {
active
.iter()
.enumerate()
.map(|(k, (name, c))| {
let lt = term_to_latex(name);
if k == 0 {
format!("{c:.4} {lt}")
} else if *c >= 0.0 {
format!(" + {c:.4} {lt}")
} else {
format!(" - {:.4} {lt}", c.abs())
}
})
.collect::<String>()
};
Ok(PdeResult {
equation: format!("u_t = {rhs_pretty}"),
latex: format!("u_t = {rhs_latex}"),
coefficients: active,
mse,
})
}
pub fn discover_pde_nd(
field: &PdeField,
t: &[f64],
config: &PdeConfig,
) -> Result<PdeResult, EmlError> {
if field.data.is_empty() || t.is_empty() {
return Err(EmlError::EmptyData);
}
let nt = t.len();
let dt = if nt > 1 { t[1] - t[0] } else { 1.0 };
let library = config
.library
.clone()
.unwrap_or_else(|| match &field.shape {
PdeShape::D1 { .. } => default_library_1d(),
PdeShape::D2 { .. } => default_library_2d(),
PdeShape::D3 { .. } => default_library_2d(), });
let mode = config.mode.unwrap_or(PdeMode::Collocation);
match &field.shape {
PdeShape::D1 { nx } => discover_pde_nd_impl(
field,
NdImplCtx {
nt,
n_spatial: *nx,
dt,
spatial_shape: &[*nx],
library: &library,
mode,
},
config,
),
PdeShape::D2 { nx, ny } => discover_pde_nd_impl(
field,
NdImplCtx {
nt,
n_spatial: nx * ny,
dt,
spatial_shape: &[*nx, *ny],
library: &library,
mode,
},
config,
),
PdeShape::D3 { nx, ny, nz } => discover_pde_nd_impl(
field,
NdImplCtx {
nt,
n_spatial: nx * ny * nz,
dt,
spatial_shape: &[*nx, *ny, *nz],
library: &library,
mode,
},
config,
),
}
}
struct NdImplCtx<'a> {
nt: usize,
n_spatial: usize,
dt: f64,
spatial_shape: &'a [usize],
library: &'a [PdeLibraryTerm],
mode: PdeMode,
}
fn discover_pde_nd_impl(
field: &PdeField,
ctx: NdImplCtx<'_>,
config: &PdeConfig,
) -> Result<PdeResult, EmlError> {
let NdImplCtx {
nt,
n_spatial,
dt,
spatial_shape,
library,
mode,
} = ctx;
let n_axes = spatial_shape.len();
if nt < 3 {
return Err(EmlError::GridTooSmall { needed: 3, got: nt });
}
for &sz in spatial_shape {
let min_sz = 3 + 2 * config.trim_boundary;
if sz < min_sz {
return Err(EmlError::GridTooSmall {
needed: min_sz,
got: sz,
});
}
}
let full_shape: Vec<usize> = std::iter::once(nt)
.chain(spatial_shape.iter().copied())
.collect();
let mut u_t = vec![0.0f64; nt * n_spatial];
for s in 0..n_spatial {
let ts: Vec<f64> = (0..nt).map(|ti| field.data[ti * n_spatial + s]).collect();
let ts_dt = central_differences(&ts, dt);
for ti in 0..nt {
u_t[ti * n_spatial + s] = ts_dt[ti];
}
}
let mut deriv_cache: std::collections::HashMap<Vec<usize>, Vec<f64>> =
std::collections::HashMap::new();
deriv_cache.insert(vec![0; n_axes], field.data.clone());
for term in library {
for factor in &term.factors {
let key = if factor.is_empty() {
vec![0; n_axes]
} else {
factor.clone()
};
if deriv_cache.contains_key(&key) {
continue;
}
let mut current = field.data.clone();
for (axis_idx, &order) in key.iter().enumerate() {
if order == 0 {
continue;
}
let dx = if axis_idx < field.dx.len() {
field.dx[axis_idx]
} else {
field.dx[0]
};
current = apply_axis_derivative(¤t, &full_shape, axis_idx, dx, order);
}
deriv_cache.insert(key, current);
}
}
let hann_weights = if mode == PdeMode::WeakForm {
build_hann_weights(spatial_shape)
} else {
vec![1.0f64; n_spatial]
};
let trim_t = 1_usize;
let trim_x = config.trim_boundary;
let t_start = trim_t;
let t_end = nt.saturating_sub(trim_t);
let n_t_trim = t_end.saturating_sub(t_start);
if n_t_trim == 0 {
return Err(EmlError::GridTooSmall { needed: 1, got: 0 });
}
let n_spatial_trim: usize = spatial_shape
.iter()
.map(|&sz| sz.saturating_sub(2 * trim_x))
.product();
let n_data = n_t_trim * n_spatial_trim;
if n_data == 0 {
return Err(EmlError::GridTooSmall { needed: 1, got: 0 });
}
let n_terms = library.len();
let mut target = vec![0.0f64; n_data];
let mut theta = vec![0.0f64; n_data * n_terms];
let zero_key = vec![0usize; n_axes];
let u_data = deriv_cache.get(&zero_key).expect("zero key always present");
let mut row_idx = 0_usize;
for ti in t_start..t_end {
let spatial_flat_indices = enumerate_trimmed_spatial(spatial_shape, trim_x);
for flat_s in &spatial_flat_indices {
let global_idx = ti * n_spatial + flat_s;
let weight = hann_weights[*flat_s];
target[row_idx] = u_t[global_idx] * weight;
for (term_idx, term) in library.iter().enumerate() {
let val = if term.factors.is_empty() {
weight
} else {
let mut prod = 1.0f64;
for factor in &term.factors {
let key = if factor.is_empty() {
vec![0; n_axes]
} else {
factor.clone()
};
let deriv_field = deriv_cache
.get(&key)
.map(|v| v[ti * n_spatial + flat_s])
.unwrap_or_else(|| u_data[ti * n_spatial + flat_s]);
prod *= deriv_field;
}
prod * weight
};
theta[row_idx * n_terms + term_idx] = val;
}
row_idx += 1;
}
}
let mut col_scales = vec![1.0f64; n_terms];
for j in 0..n_terms {
let ss: f64 = (0..n_data)
.map(|i| theta[i * n_terms + j].powi(2))
.sum::<f64>()
/ n_data as f64;
let scale = ss.sqrt().max(f64::EPSILON);
col_scales[j] = scale;
for i in 0..n_data {
theta[i * n_terms + j] /= scale;
}
}
let coeffs_norm = strridge(
&theta,
&target,
n_data,
n_terms,
config.ridge_lambda,
config.threshold,
config.max_iter,
);
let coeffs: Vec<f64> = coeffs_norm
.iter()
.zip(&col_scales)
.map(|(&c, &s)| c / s)
.collect();
let mut theta_orig = theta.clone();
for j in 0..n_terms {
for i in 0..n_data {
theta_orig[i * n_terms + j] *= col_scales[j];
}
}
let mse = {
let mut ss = 0.0f64;
for i in 0..n_data {
let pred: f64 = (0..n_terms)
.map(|j| coeffs[j] * theta_orig[i * n_terms + j])
.sum();
ss += (pred - target[i]).powi(2);
}
ss / n_data as f64
};
let active: Vec<(String, f64)> = library
.iter()
.zip(&coeffs)
.filter(|&(_, &c)| c.abs() >= config.threshold)
.map(|(term, &c)| (term.label.clone(), c))
.collect();
let rhs_pretty = format_rhs_pretty(&active);
let rhs_latex = format_rhs_latex(library, &coeffs, config.threshold);
Ok(PdeResult {
equation: format!("u_t = {rhs_pretty}"),
latex: format!("u_t = {rhs_latex}"),
coefficients: active,
mse,
})
}
fn enumerate_trimmed_spatial(spatial_shape: &[usize], trim: usize) -> Vec<usize> {
match spatial_shape.len() {
1 => {
let nx = spatial_shape[0];
(trim..nx.saturating_sub(trim)).collect()
}
2 => {
let nx = spatial_shape[0];
let ny = spatial_shape[1];
let mut result = Vec::new();
for xi in trim..nx.saturating_sub(trim) {
for yi in trim..ny.saturating_sub(trim) {
result.push(xi * ny + yi);
}
}
result
}
3 => {
let nx = spatial_shape[0];
let ny = spatial_shape[1];
let nz = spatial_shape[2];
let mut result = Vec::new();
for xi in trim..nx.saturating_sub(trim) {
for yi in trim..ny.saturating_sub(trim) {
for zi in trim..nz.saturating_sub(trim) {
result.push(xi * ny * nz + yi * nz + zi);
}
}
}
result
}
_ => vec![],
}
}
fn build_hann_weights(spatial_shape: &[usize]) -> Vec<f64> {
let n_spatial: usize = spatial_shape.iter().product();
let hann_1d: Vec<Vec<f64>> = spatial_shape
.iter()
.map(|&n| build_hann_weights_1d(n))
.collect();
let mut weights = vec![1.0f64; n_spatial];
match spatial_shape.len() {
1 => {
weights.copy_from_slice(&hann_1d[0]);
}
2 => {
let nx = spatial_shape[0];
let ny = spatial_shape[1];
for xi in 0..nx {
for yi in 0..ny {
weights[xi * ny + yi] = hann_1d[0][xi] * hann_1d[1][yi];
}
}
}
3 => {
let nx = spatial_shape[0];
let ny = spatial_shape[1];
let nz = spatial_shape[2];
for xi in 0..nx {
for yi in 0..ny {
for zi in 0..nz {
weights[xi * ny * nz + yi * nz + zi] =
hann_1d[0][xi] * hann_1d[1][yi] * hann_1d[2][zi];
}
}
}
}
_ => {}
}
weights
}
fn build_hann_weights_1d(n: usize) -> Vec<f64> {
if n == 0 {
return vec![];
}
let denom = (n - 1).max(1) as f64;
(0..n)
.map(|i| 0.5 * (1.0 - (2.0 * std::f64::consts::PI * i as f64 / denom).cos()))
.collect()
}
fn format_rhs_pretty(active: &[(String, f64)]) -> String {
if active.is_empty() {
return "0".to_string();
}
active
.iter()
.enumerate()
.map(|(k, (name, c))| {
if k == 0 {
format!("{c:.6} {name}")
} else if *c >= 0.0 {
format!(" + {c:.6} {name}")
} else {
format!(" - {:.6} {name}", c.abs())
}
})
.collect()
}
fn format_rhs_latex(library: &[PdeLibraryTerm], coeffs: &[f64], threshold: f64) -> String {
let active: Vec<(&PdeLibraryTerm, f64)> = library
.iter()
.zip(coeffs)
.filter(|&(_, &c)| c.abs() >= threshold)
.map(|(t, &c)| (t, c))
.collect();
if active.is_empty() {
return "0".to_string();
}
active
.iter()
.enumerate()
.map(|(k, (term, c))| {
if k == 0 {
format!("{c:.4} {}", term.latex)
} else if *c >= 0.0 {
format!(" + {c:.4} {}", term.latex)
} else {
format!(" - {:.4} {}", c.abs(), term.latex)
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::super::numerics::nth_derivative_1d;
use super::*;
use crate::symreg::{SymRegConfig, SymRegEngine};
#[test]
fn heat_equation_recovery() {
let n_x = 20_usize;
let n_t = 20_usize;
let x_max = std::f64::consts::PI;
let t_max = 1.0_f64;
let dx = x_max / (n_x - 1) as f64;
let dt = t_max / (n_t - 1) as f64;
let alpha = 0.1_f64;
let k = 1.0_f64;
let field: Vec<Vec<f64>> = (0..n_t)
.map(|jt| {
let t = jt as f64 * dt;
(0..n_x)
.map(|ix| {
let x = ix as f64 * dx;
(-alpha * k * k * t).exp() * (k * x).sin()
})
.collect()
})
.collect();
let config = PdeConfig {
trim_boundary: 2,
ridge_lambda: 1e-6,
threshold: 0.02,
..PdeConfig::default()
};
let engine = SymRegEngine::new(SymRegConfig::default());
let result = discover_pde(&engine, &field, dx, dt, &config)
.expect("heat equation discovery should succeed");
let uxx_coeff = result
.coefficients
.iter()
.find(|(name, _)| name == "u_xx")
.map(|(_, c)| *c);
assert!(
uxx_coeff.is_some(),
"u_xx term must be present; got: {:?}",
result.coefficients
);
let coeff = uxx_coeff.expect("already checked");
assert!(
(coeff - alpha).abs() < 0.05,
"u_xx coefficient should be ≈ 0.1, got {coeff}"
);
for (name, c) in &result.coefficients {
if name != "u_xx" {
assert!(c.abs() < 0.1, "term {name} should be near zero, got {c}");
}
}
}
#[test]
fn grid_too_small_time() {
let field: Vec<Vec<f64>> = vec![vec![1.0, 2.0, 3.0], vec![2.0, 3.0, 4.0]]; let config = PdeConfig::default();
let engine = SymRegEngine::new(SymRegConfig::default());
let result = discover_pde(&engine, &field, 0.1, 0.1, &config);
assert!(
matches!(result, Err(EmlError::GridTooSmall { .. })),
"expected GridTooSmall, got {result:?}"
);
}
#[test]
fn grid_too_small_space() {
let field: Vec<Vec<f64>> = (0..5).map(|_| vec![1.0, 2.0]).collect(); let config = PdeConfig {
trim_boundary: 2,
..PdeConfig::default()
};
let engine = SymRegEngine::new(SymRegConfig::default());
let result = discover_pde(&engine, &field, 0.1, 0.1, &config);
assert!(
matches!(result, Err(EmlError::GridTooSmall { .. })),
"expected GridTooSmall, got {result:?}"
);
}
#[test]
fn nth_derivative_1d_cubic() {
let n = 20_usize;
let dx = 0.1_f64;
let x: Vec<f64> = (0..n).map(|i| i as f64 * dx).collect();
let cubic: Vec<f64> = x.iter().map(|&xi| xi * xi * xi).collect();
let d3 = nth_derivative_1d(&cubic, dx, 3);
let end = d3.len().saturating_sub(3);
for (i, val) in d3.iter().enumerate().take(end).skip(3) {
assert!(
(val - 6.0).abs() < 0.5,
"d³x³/dx³[{}] = {} (expected ≈ 6)",
i,
val
);
}
}
#[test]
fn nth_derivative_1d_order_zero() {
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let result = nth_derivative_1d(&data, 0.1, 0);
assert_eq!(result, data);
}
#[test]
fn heat_equation_2d_recovery() {
let alpha = 0.5_f64;
let nt = 30_usize;
let nx = 22_usize;
let ny = 22_usize;
let dt = 0.002_f64;
let dx = 0.15_f64;
let dy = 0.15_f64;
let t_vals: Vec<f64> = (0..nt).map(|i| i as f64 * dt).collect();
let x_vals: Vec<f64> = (0..nx).map(|i| i as f64 * dx).collect();
let y_vals: Vec<f64> = (0..ny).map(|i| i as f64 * dy).collect();
let mut data = vec![0.0f64; nt * nx * ny];
for ti in 0..nt {
for xi in 0..nx {
for yi in 0..ny {
data[ti * nx * ny + xi * ny + yi] =
(-2.0 * alpha * t_vals[ti]).exp() * x_vals[xi].sin() * y_vals[yi].sin();
}
}
}
let field = PdeField {
data,
shape: PdeShape::D2 { nx, ny },
dx: vec![dx, dy],
};
let library = vec![
PdeLibraryTerm {
label: "u_xx".into(),
latex: "u_{xx}".into(),
factors: vec![vec![2, 0]],
},
PdeLibraryTerm {
label: "u_yy".into(),
latex: "u_{yy}".into(),
factors: vec![vec![0, 2]],
},
];
let config = PdeConfig {
library: Some(library),
trim_boundary: 2,
ridge_lambda: 1e-6,
threshold: 0.05,
..PdeConfig::default()
};
let result = discover_pde_nd(&field, &t_vals, &config)
.expect("2-D heat equation discovery should succeed");
let uxx = result
.coefficients
.iter()
.find(|(n, _)| n == "u_xx")
.map(|(_, c)| *c);
let uyy = result
.coefficients
.iter()
.find(|(n, _)| n == "u_yy")
.map(|(_, c)| *c);
assert!(
uxx.is_some(),
"u_xx term must be present; got: {:?}",
result.coefficients
);
assert!(
uyy.is_some(),
"u_yy term must be present; got: {:?}",
result.coefficients
);
let uxx_c = uxx.expect("checked");
let uyy_c = uyy.expect("checked");
let coeff_sum = uxx_c + uyy_c;
let expected_sum = 2.0 * alpha;
assert!(
(coeff_sum - expected_sum).abs() < 0.15,
"sum of u_xx + u_yy coefficients should be ≈ 2·alpha = {expected_sum}, \
got {coeff_sum} (u_xx={uxx_c}, u_yy={uyy_c})"
);
assert!(
uxx_c > 0.0,
"u_xx coefficient should be positive, got {uxx_c}"
);
assert!(
uyy_c > 0.0,
"u_yy coefficient should be positive, got {uyy_c}"
);
}
#[test]
fn default_1d_library_matches_legacy() {
let lib = default_library_1d();
let names: Vec<&str> = lib.iter().map(|t| t.label.as_str()).collect();
let legacy = term_names();
assert_eq!(
names, legacy,
"default 1-D library labels must match legacy term_names()"
);
}
#[test]
fn weak_form_mode_runs() {
let n_x = 15_usize;
let n_t = 15_usize;
let x_max = std::f64::consts::PI;
let t_max = 0.5_f64;
let dx = x_max / (n_x - 1) as f64;
let dt = t_max / (n_t - 1) as f64;
let alpha = 0.1_f64;
let t_vals: Vec<f64> = (0..n_t).map(|i| i as f64 * dt).collect();
let mut data = vec![0.0f64; n_t * n_x];
for ti in 0..n_t {
for xi in 0..n_x {
let x = xi as f64 * dx;
data[ti * n_x + xi] = (-alpha * t_vals[ti]).exp() * x.sin();
}
}
let field = PdeField {
data,
shape: PdeShape::D1 { nx: n_x },
dx: vec![dx],
};
let config = PdeConfig {
mode: Some(PdeMode::WeakForm),
trim_boundary: 2,
ridge_lambda: 1e-5,
threshold: 0.005,
..PdeConfig::default()
};
let result = discover_pde_nd(&field, &t_vals, &config);
assert!(result.is_ok(), "weak form should not fail: {:?}", result);
}
#[test]
fn mixed_derivative_u_xy() {
let nx = 12_usize;
let ny = 12_usize;
let nt = 10_usize;
let dx = 0.3_f64;
let dy = 0.3_f64;
let dt = 0.1_f64;
let x_vals: Vec<f64> = (0..nx).map(|i| i as f64 * dx).collect();
let y_vals: Vec<f64> = (0..ny).map(|i| i as f64 * dy).collect();
let t_vals: Vec<f64> = (0..nt).map(|i| i as f64 * dt).collect();
let mut data = vec![0.0f64; nt * nx * ny];
for ti in 0..nt {
for xi in 0..nx {
for yi in 0..ny {
data[ti * nx * ny + xi * ny + yi] =
x_vals[xi].sin() * y_vals[yi].cos() * (-t_vals[ti]).exp();
}
}
}
let field = PdeField {
data,
shape: PdeShape::D2 { nx, ny },
dx: vec![dx, dy],
};
let library = vec![
PdeLibraryTerm {
label: "u".into(),
latex: "u".into(),
factors: vec![vec![0, 0]],
},
PdeLibraryTerm {
label: "u_xy".into(),
latex: "u_{xy}".into(),
factors: vec![vec![1, 1]],
},
];
let config = PdeConfig {
library: Some(library),
trim_boundary: 2,
ridge_lambda: 1e-5,
threshold: 0.005,
..PdeConfig::default()
};
let result = discover_pde_nd(&field, &t_vals, &config);
assert!(
result.is_ok(),
"mixed derivative test should not fail: {:?}",
result
);
let r = result.expect("already checked");
let u_coeff = r
.coefficients
.iter()
.find(|(n, _)| n == "u")
.map(|(_, c)| *c);
assert!(
u_coeff.is_some(),
"u term expected; got: {:?}",
r.coefficients
);
let uc = u_coeff.expect("already checked");
assert!(
(uc + 1.0).abs() < 0.3,
"u coefficient should be ≈ -1, got {uc}"
);
}
}