use scirs2_core::ndarray::{ArrayView1, ArrayView2};
use scirs2_symbolic::regression::{DiscoveredFormula, OdeConfig, SrConfig};
use std::fmt;
#[derive(Debug, Clone)]
pub struct OdeDiscoveryConfig {
pub max_complexity: usize,
pub population_size: usize,
pub n_generations: usize,
pub top_n: usize,
pub seed: u64,
}
impl Default for OdeDiscoveryConfig {
fn default() -> Self {
Self {
max_complexity: 12,
population_size: 200,
n_generations: 50,
top_n: 3,
seed: 42,
}
}
}
impl OdeDiscoveryConfig {
fn to_ode_config(&self, dt: f64) -> OdeConfig {
let sr = SrConfig::default()
.with_max_nodes(self.max_complexity)
.with_beam_width(self.population_size)
.with_max_iter(self.n_generations)
.with_top_n(self.top_n)
.with_seed(self.seed);
OdeConfig::new(dt).with_sr_config(sr)
}
}
#[derive(Debug)]
pub enum OdeDiscoveryError {
EmptyTrajectory,
DimensionMismatch {
t_len: usize,
y_rows: usize,
},
NonUniformSampling {
max_dev: f64,
},
SymbolicError(String),
}
impl fmt::Display for OdeDiscoveryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
OdeDiscoveryError::EmptyTrajectory => {
write!(f, "trajectory is empty — no samples to process")
}
OdeDiscoveryError::DimensionMismatch { t_len, y_rows } => write!(
f,
"dimension mismatch: t has {t_len} points but y has {y_rows} rows"
),
OdeDiscoveryError::NonUniformSampling { max_dev } => write!(
f,
"non-uniform sampling detected (max interval deviation = {max_dev:.3e}); \
only uniform time grids are supported"
),
OdeDiscoveryError::SymbolicError(msg) => {
write!(f, "symbolic regression error: {msg}")
}
}
}
}
impl std::error::Error for OdeDiscoveryError {}
pub fn discover_ode_from_trajectory(
t: ArrayView1<'_, f64>,
y: ArrayView2<'_, f64>,
config: &OdeDiscoveryConfig,
) -> Result<Vec<Vec<DiscoveredFormula>>, OdeDiscoveryError> {
let n_samples = t.len();
let y_rows = y.shape()[0];
if n_samples == 0 || y_rows == 0 {
return Err(OdeDiscoveryError::EmptyTrajectory);
}
if n_samples != y_rows {
return Err(OdeDiscoveryError::DimensionMismatch {
t_len: n_samples,
y_rows,
});
}
let dt = if n_samples >= 2 {
let total = t[n_samples - 1] - t[0];
total / (n_samples - 1) as f64
} else {
1.0
};
if n_samples >= 3 && dt.abs() > 0.0 {
let tol = 1e-9 * dt.abs();
let mut max_dev = 0.0_f64;
for i in 1..n_samples {
let interval = t[i] - t[i - 1];
let dev = (interval - dt).abs();
if dev > max_dev {
max_dev = dev;
}
}
if max_dev > tol {
return Err(OdeDiscoveryError::NonUniformSampling { max_dev });
}
}
let ode_cfg = config.to_ode_config(dt);
let results = scirs2_symbolic::regression::discover_ode(y, &ode_cfg);
Ok(results)
}
#[cfg(test)]
mod tests {
use super::*;
use scirs2_core::ndarray::{Array1, Array2};
fn uniform_t(n: usize, dt: f64) -> Array1<f64> {
Array1::from_vec((0..n).map(|i| i as f64 * dt).collect())
}
#[test]
fn test_empty_t_returns_err() {
let t = Array1::<f64>::zeros(0);
let y = Array2::<f64>::zeros((0, 1));
let cfg = OdeDiscoveryConfig::default();
let result = discover_ode_from_trajectory(t.view(), y.view(), &cfg);
assert!(
matches!(result, Err(OdeDiscoveryError::EmptyTrajectory)),
"expected EmptyTrajectory, got {:?}",
result.err().map(|e| e.to_string())
);
}
#[test]
fn test_dim_mismatch_returns_err() {
let t = uniform_t(3, 0.1);
let y = Array2::<f64>::zeros((4, 1));
let cfg = OdeDiscoveryConfig::default();
let result = discover_ode_from_trajectory(t.view(), y.view(), &cfg);
assert!(
matches!(
result,
Err(OdeDiscoveryError::DimensionMismatch {
t_len: 3,
y_rows: 4
})
),
"expected DimensionMismatch{{t_len:3,y_rows:4}}, got {:?}",
result.err().map(|e| e.to_string())
);
}
#[test]
fn test_discover_runs_without_panic() {
let dt = 0.1;
let t = uniform_t(5, dt);
let traj_data: Vec<f64> = (0..5).map(|i| i as f64 * dt).collect();
let y = Array2::from_shape_vec((5, 1), traj_data).expect("shape");
let cfg = OdeDiscoveryConfig {
n_generations: 3,
population_size: 10,
..Default::default()
};
let result = discover_ode_from_trajectory(t.view(), y.view(), &cfg);
assert!(
result.is_ok() || matches!(result, Err(OdeDiscoveryError::SymbolicError(_))),
"unexpected error variant: {:?}",
result.err().map(|e| e.to_string())
);
}
#[test]
fn test_single_state_trajectory() {
let dt = 0.1;
let n = 10;
let t = uniform_t(n, dt);
let traj_data: Vec<f64> = (0..n).map(|i| (-(i as f64) * dt).exp()).collect();
let y = Array2::from_shape_vec((n, 1), traj_data).expect("shape");
let cfg = OdeDiscoveryConfig {
n_generations: 5,
population_size: 20,
top_n: 2,
..Default::default()
};
let result = discover_ode_from_trajectory(t.view(), y.view(), &cfg);
assert!(
result.is_ok(),
"expected Ok, got {:?}",
result.err().map(|e| e.to_string())
);
if let Ok(formulas) = result {
assert!(
formulas.len() <= 1,
"single-state trajectory should produce at most 1 dimension"
);
}
}
}