use crate::flatten::{FlattenedSystem, flatten};
use crate::types::EsmFile;
use std::collections::{HashMap, HashSet};
use thiserror::Error;
#[cfg(feature = "solve")]
use diffsol::{OdeSolverMethod, Op, VectorHost};
mod api;
mod driver;
mod errors;
mod lhs;
pub(crate) mod override_keys;
pub use api::*;
pub use driver::*;
pub use errors::*;
pub use lhs::*;
pub(crate) use override_keys::*;
#[cfg(test)]
mod tests {
use super::*;
use crate::problem::{EsmProblem, ProblemOptions, esm_problem, solve};
fn problem_of(json: &str, p: HashMap<String, f64>) -> Result<EsmProblem, SimulateError> {
let file = crate::parse::load_string(json).expect("parse fixture");
esm_problem(
&file,
(0.0, 1.0),
ProblemOptions {
p,
..Default::default()
},
)
}
fn solution_with_rows(names: &[&str], namespace: Option<&str>) -> Solution {
Solution {
time: vec![0.0],
state: names.iter().map(|_| vec![0.0]).collect(),
state_variable_names: names.iter().map(|n| n.to_string()).collect(),
retcode: ReturnCode::Success,
metadata: SolutionMetadata {
namespace: namespace.map(str::to_string),
..Default::default()
},
}
}
#[test]
fn a_qualified_name_reaches_a_bare_row_only_through_its_own_namespace() {
let sol = solution_with_rows(&["x", "North.u"], Some("M"));
assert_eq!(sol.index_of("x"), Some(0));
assert_eq!(sol.index_of("M.x"), Some(0));
assert_eq!(sol.index_of("M.North.u"), Some(1));
assert_eq!(sol.index_of("Other.x"), None);
assert_eq!(sol.index_of("Typo.x"), None);
let unnamed = solution_with_rows(&["x"], None);
assert_eq!(unnamed.index_of("M.x"), None);
}
#[test]
fn algebraic_cycle_rejected() {
let json = r#"
{
"esm": "1.0.0",
"metadata": {
"name": "TestFixture"
},
"models": {
"M": {
"variables": {
"x": {
"type": "unknown",
"default": 0.0
},
"a": {
"type": "unknown",
"default": 1.0
},
"b": {
"type": "unknown",
"default": 1.0
}
},
"equations": [
{
"lhs": {
"op": "D",
"args": [
"x"
],
"wrt": "t"
},
"rhs": "a"
},
{
"lhs": "a",
"rhs": {
"op": "+",
"args": [
"b",
1.0
]
}
},
{
"lhs": "b",
"rhs": {
"op": "*",
"args": [
"a",
2.0
]
}
}
]
}
}
}
"#;
let err = problem_of(json, HashMap::new()).expect_err("cycle must be rejected");
let SimulateError::Compile(CompileError::ObservedCycle { cycle }) = &err else {
panic!("expected an observed cycle, got: {err:?}");
};
assert!(
cycle.iter().any(|v| v.ends_with('a')) && cycle.iter().any(|v| v.ends_with('b')),
"the cycle should name both vars: {cycle:?}"
);
}
#[test]
fn fn_op_interp_linear_in_a_scalar_model() {
let json = r#"
{
"esm": "1.0.0",
"metadata": {
"name": "FnFixture"
},
"models": {
"M": {
"variables": {
"x": {
"type": "unknown",
"default": 0.0
},
"code": {
"type": "parameter",
"default": 2.0
},
"looked_up": {
"type": "unknown"
}
},
"equations": [
{
"lhs": {
"op": "D",
"args": [
"x"
],
"wrt": "t"
},
"rhs": "looked_up"
},
{
"lhs": "looked_up",
"rhs": {
"op": "fn",
"name": "interp.linear",
"args": [
{
"op": "const",
"value": [
10.0,
20.0,
40.0,
80.0,
160.0
],
"args": []
},
{
"op": "const",
"value": [
0.0,
1.0,
2.0,
3.0,
4.0
],
"args": []
},
"code"
]
}
}
]
}
}
}
"#;
let prob = problem_of(json, HashMap::new()).expect("build succeeds");
let opts = SolveOptions {
abstol: Some(1e-12),
reltol: Some(1e-10),
saveat: Some(vec![0.0, 1.0]),
..Default::default()
};
let sol = solve(&prob, &opts).expect("simulate succeeds");
let x_idx = sol
.state_variable_names
.iter()
.position(|n| n.ends_with("x"))
.expect("x in solution");
assert!(
(sol.state[x_idx][1] - 40.0).abs() < 1e-6,
"x(1) should be 40.0 (dx/dt = interp.linear(...,2.0) = 40), got {}",
sol.state[x_idx][1]
);
let mut params = HashMap::new();
params.insert("M.code".to_string(), 0.5);
let prob2 = problem_of(json, params).expect("build succeeds");
let sol2 = solve(&prob2, &opts).expect("simulate succeeds");
assert!(
(sol2.state[x_idx][1] - 15.0).abs() < 1e-6,
"x(1) should be 15.0 at code=0.5, got {}",
sol2.state[x_idx][1]
);
}
#[test]
fn fn_op_datetime_scalar_arg() {
let json = r#"
{
"esm": "1.0.0",
"metadata": {
"name": "DatetimeFixture"
},
"models": {
"M": {
"variables": {
"x": {
"type": "unknown",
"default": 0.0
},
"yr": {
"type": "unknown"
}
},
"equations": [
{
"lhs": {
"op": "D",
"args": [
"x"
],
"wrt": "t"
},
"rhs": "yr"
},
{
"lhs": "yr",
"rhs": {
"op": "fn",
"name": "datetime.year",
"args": [
946684800.0
]
}
}
]
}
}
}
"#;
let prob = problem_of(json, HashMap::new()).expect("build succeeds");
let opts = SolveOptions {
abstol: Some(1e-12),
reltol: Some(1e-10),
saveat: Some(vec![0.0, 1.0]),
..Default::default()
};
let sol = solve(&prob, &opts).expect("simulate succeeds");
let x_idx = sol
.state_variable_names
.iter()
.position(|n| n.ends_with("x"))
.expect("x in solution");
assert!(
(sol.state[x_idx][1] - 2000.0).abs() < 1e-9,
"x(1) should be 2000.0 (dx/dt = datetime.year = 2000), got {}",
sol.state[x_idx][1]
);
}
fn algebraic_names_of(json: &str) -> Vec<String> {
let file = crate::parse::load_string(json).expect("parse fixture");
let flat = crate::flatten(&file).expect("flatten fixture");
let mut names = algebraic_state_names(&flat);
names.sort();
names
}
#[test]
fn a_bare_lhs_state_is_algebraic_and_a_derivative_one_is_not() {
let names = algebraic_names_of(
r#"
{
"esm": "1.0.0",
"metadata": {
"name": "TestFixture"
},
"models": {
"M": {
"variables": {
"D": {
"type": "unknown",
"default": 1.0
},
"G": {
"type": "unknown"
},
"k": {
"type": "parameter",
"default": 1.0
}
},
"equations": [
{
"lhs": {
"op": "D",
"args": [
"D"
],
"wrt": "t"
},
"rhs": {
"op": "*",
"args": [
{
"op": "-",
"args": [
"k"
]
},
"G"
]
}
},
{
"lhs": "G",
"rhs": "D"
}
]
}
}
}
"#,
);
assert_eq!(names, vec!["M.G".to_string()]);
}
#[test]
fn a_state_with_both_equations_is_refused_at_build() {
let file = crate::parse::load_string(
r#"
{
"esm": "1.0.0",
"metadata": {
"name": "TestFixture"
},
"models": {
"M": {
"variables": {
"x": {
"type": "unknown",
"default": 1.0
},
"k": {
"type": "parameter",
"default": 1.0
}
},
"equations": [
{
"lhs": {
"op": "D",
"args": [
"x"
],
"wrt": "t"
},
"rhs": "k"
},
{
"lhs": "x",
"rhs": {
"op": "*",
"args": [
"k",
2.0
]
}
}
]
}
}
}
"#,
)
.expect("parse fixture");
let err = crate::problem::esm_problem(&file, (0.0, 1.0), Default::default())
.expect_err("a doubly-defined unknown must not build");
let msg = err.to_string();
assert!(
msg.contains(crate::diagnostic::codes::EQUATION_COUNT_MISMATCH),
"{msg}"
);
assert!(msg.contains("'x'"), "{msg}");
assert!(msg.contains("D(x)/Dt ~ k"), "{msg}");
assert!(msg.contains("x ~ k * 2"), "{msg}");
let flat = crate::flatten(&file).expect("flatten fixture");
let route = crate::simulate_array::ArrayCompiled::from_flattened(&flat)
.err()
.map(|e| e.to_string())
.expect("the route must refuse, not build");
assert!(
route.contains(crate::diagnostic::codes::EQUATION_COUNT_MISMATCH),
"{route}"
);
let report = crate::validate(&file);
assert!(!report.is_valid, "validate accepted it");
assert!(
report.structural_errors.iter().any(|e| {
matches!(
e.code,
crate::validate::StructuralErrorCode::EquationCountMismatch
)
}),
"{:?}",
report.structural_errors
);
}
#[test]
fn a_bare_lhs_observed_unknown_is_reported() {
let names = algebraic_names_of(
r#"
{
"esm": "1.0.0",
"metadata": {
"name": "TestFixture"
},
"models": {
"M": {
"variables": {
"x": {
"type": "unknown",
"default": 1.0
},
"obs": {
"type": "unknown"
},
"k": {
"type": "parameter",
"default": 1.0
}
},
"equations": [
{
"lhs": {
"op": "D",
"args": [
"x"
],
"wrt": "t"
},
"rhs": "k"
},
{
"lhs": "obs",
"rhs": "x"
}
]
}
}
}
"#,
);
assert_eq!(names, vec!["M.obs".to_string()]);
}
#[test]
fn an_observed_unknown_needs_no_default() {
let json = r#"
{
"esm": "1.0.0",
"metadata": {
"name": "TestFixture"
},
"models": {
"M": {
"variables": {
"D": {
"type": "unknown",
"default": 1.0
},
"G": {
"type": "unknown"
},
"k": {
"type": "parameter",
"default": 1.0
}
},
"equations": [
{
"lhs": {
"op": "D",
"args": [
"D"
],
"wrt": "t"
},
"rhs": {
"op": "*",
"args": [
{
"op": "-",
"args": [
"k"
]
},
"G"
]
}
},
{
"lhs": "G",
"rhs": "D"
}
]
}
}
}
"#;
let prob = problem_of(json, HashMap::new()).expect("build succeeds");
let opts = SolveOptions {
saveat: Some(vec![0.0, 1.0]),
..Default::default()
};
let sol = solve(&prob, &opts)
.expect("a defaultless OBSERVED unknown must not block a simulation");
assert!(
!prob.state_variable_names().iter().any(|n| n.ends_with("G")),
"an observed unknown is eliminated, not integrated: {:?}",
prob.state_variable_names()
);
assert!(
sol.state_variable_names.iter().any(|n| n.ends_with("D")),
"{:?}",
sol.state_variable_names
);
assert!(
prob.observed_variable_names()
.iter()
.any(|n| n.ends_with("G")),
"G must be reported as an observed: {:?}",
prob.observed_variable_names()
);
}
#[test]
fn a_defaultless_differential_state_is_still_refused() {
let json = r#"
{
"esm": "1.0.0",
"metadata": {
"name": "TestFixture"
},
"models": {
"M": {
"variables": {
"D": {
"type": "unknown"
},
"k": {
"type": "parameter",
"default": 1.0
}
},
"equations": [
{
"lhs": {
"op": "D",
"args": [
"D"
],
"wrt": "t"
},
"rhs": {
"op": "*",
"args": [
{
"op": "-",
"args": [
"k"
]
},
"D"
]
}
}
]
}
}
}
"#;
let err = problem_of(json, HashMap::new())
.and_then(|prob| solve(&prob, &SolveOptions::default()))
.expect_err("a differential state with no initial value must be refused");
assert!(
matches!(
&err,
SimulateError::Compile(CompileError::MissingInitialValue { slot, .. }) if slot == "D"
),
"expected MissingInitialValue for D, got {err:?}"
);
}
#[test]
fn an_observed_unknowns_default_is_ignored() {
let json = r#"
{
"esm": "1.0.0",
"metadata": {
"name": "TestFixture"
},
"models": {
"M": {
"variables": {
"D": {
"type": "unknown",
"default": 1.0
},
"G": {
"type": "unknown",
"default": 99.0
},
"k": {
"type": "parameter",
"default": 1.0
}
},
"equations": [
{
"lhs": {
"op": "D",
"args": [
"D"
],
"wrt": "t"
},
"rhs": {
"op": "*",
"args": [
{
"op": "-",
"args": [
"k"
]
},
"G"
]
}
},
{
"lhs": "G",
"rhs": "D"
}
]
}
}
}
"#;
let prob = problem_of(json, HashMap::new()).expect("build succeeds");
let opts = SolveOptions {
abstol: Some(1e-12),
reltol: Some(1e-10),
saveat: Some(vec![0.0, 1.0]),
..Default::default()
};
let sol = solve(&prob, &opts).expect("simulate succeeds");
let d_idx = sol
.state_variable_names
.iter()
.position(|n| n.ends_with("D"))
.expect("D in solution");
assert!(
!prob.state_variable_names().iter().any(|n| n.ends_with("G")),
"G is observed, so it is eliminated rather than integrated: {:?}",
prob.state_variable_names()
);
assert!(
(sol.state[d_idx][0] - 1.0).abs() < 1e-12,
"D(0) should be 1.0, got {}",
sol.state[d_idx][0]
);
let expected = (-1.0_f64).exp();
assert!(
(sol.state[d_idx][1] - expected).abs() < 1e-6,
"D(1) ≈ exp(-1), got {}",
sol.state[d_idx][1]
);
}
}