use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use serde_json::{Map, Value, json};
use crate::compile_error::CompileError;
use crate::compiled_rhs_adapter::resolve_fixture_path;
use crate::problem::{Compiler, ProblemOptions, Rhs, esm_problem, observed_trajectories, solve};
use crate::simulate::{Alg, SimulateError, Solution, SolveOptions};
use crate::types::EsmFile;
pub struct Args {
pub manifest: PathBuf,
pub output: PathBuf,
pub compiler: Compiler,
}
pub fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Args, String> {
let mut manifest: Option<PathBuf> = None;
let mut output: Option<PathBuf> = None;
let mut compiler: Option<Compiler> = None;
let mut it = args.into_iter();
while let Some(arg) = it.next() {
match arg.as_str() {
"--manifest" => manifest = it.next().map(PathBuf::from),
"--output" => output = it.next().map(PathBuf::from),
"--compiler" => {
let v = it.next().ok_or("--compiler needs a value")?;
compiler = Some(Compiler::parse_named(&v)?);
}
other => return Err(format!("unexpected argument {other:?}")),
}
}
match (manifest, output, compiler) {
(Some(manifest), Some(output), Some(compiler)) => Ok(Args {
manifest,
output,
compiler,
}),
_ => Err("--manifest, --output and --compiler are required".to_string()),
}
}
pub struct Report {
pub payload: Value,
pub failed: Vec<String>,
}
fn bare(name: &str) -> &str {
name.split_once('.').map(|x| x.1).unwrap_or(name)
}
fn time_key(t: f64) -> String {
format!("{t}")
}
fn num(v: f64) -> Value {
if v.is_finite() {
json!(v)
} else if v.is_nan() {
json!("NaN")
} else if v > 0.0 {
json!("Infinity")
} else {
json!("-Infinity")
}
}
fn nearest_index(times: &[f64], t: f64) -> Option<usize> {
(0..times.len()).min_by(|&a, &b| (times[a] - t).abs().total_cmp(&(times[b] - t).abs()))
}
fn solver_alg(file: &EsmFile) -> Alg {
match file.solver.as_ref().and_then(|s| s.stiffness.as_deref()) {
Some("high") => Alg::Bdf,
_ => Alg::Erk,
}
}
struct Run {
u0: HashMap<String, f64>,
p: HashMap<String, f64>,
tspan: (f64, f64),
saveat: Vec<f64>,
observed: Vec<String>,
}
fn number_map(v: Option<&Value>, what: &str) -> Result<HashMap<String, f64>, String> {
let Some(v) = v else {
return Ok(HashMap::new());
};
if v.is_null() {
return Ok(HashMap::new());
}
let obj = v
.as_object()
.ok_or_else(|| format!("{what} is not an object"))?;
obj.iter()
.map(|(k, val)| {
val.as_f64()
.map(|x| (k.clone(), x))
.ok_or_else(|| format!("{what}[{k:?}] is not a number"))
})
.collect()
}
fn f64_list(v: &Value, what: &str) -> Result<Vec<f64>, String> {
v.as_array()
.ok_or_else(|| format!("{what} is not an array"))?
.iter()
.map(|x| {
x.as_f64()
.ok_or_else(|| format!("{what} holds a non-number"))
})
.collect()
}
fn string_list(v: Option<&Value>, what: &str) -> Result<Vec<String>, String> {
let Some(v) = v else {
return Ok(Vec::new());
};
if v.is_null() {
return Ok(Vec::new());
}
v.as_array()
.ok_or_else(|| format!("{what} is not an array"))?
.iter()
.map(|x| {
x.as_str()
.map(str::to_string)
.ok_or_else(|| format!("{what} holds a non-string"))
})
.collect()
}
fn inline_test_run(file: &EsmFile, model: &str, test_id: &str) -> Result<Run, String> {
let models = file
.models
.as_ref()
.ok_or_else(|| format!("document declares no models, so it has no {model:?}"))?;
let m = models
.get(model)
.ok_or_else(|| format!("document has no model {model:?}"))?;
let test = m
.tests
.iter()
.flatten()
.find(|t| t.id == test_id)
.ok_or_else(|| format!("model {model:?} has no inline test {test_id:?}"))?;
let scalar_u0 = test.scalar_initial_conditions();
if let Some(ics) = &test.initial_conditions
&& ics.len() != scalar_u0.len()
{
return Err(format!(
"inline test {test_id:?} carries a non-scalar initial condition, which this \
adapter does not expand"
));
}
let scalar_p = test.scalar_parameter_overrides();
if let Some(po) = &test.parameter_overrides
&& po.len() != scalar_p.len()
{
return Err(format!(
"inline test {test_id:?} carries a non-scalar parameter override, which this \
adapter does not expand"
));
}
let mut saveat: Vec<f64> = test.assertions.iter().map(|a| a.time).collect();
saveat.sort_by(f64::total_cmp);
saveat.dedup();
if saveat.is_empty() {
return Err(format!("inline test {test_id:?} asserts nothing"));
}
Ok(Run {
u0: scalar_u0,
p: scalar_p,
tspan: (test.time_span.start, test.time_span.end),
saveat,
observed: Vec::new(),
})
}
fn fixture_run(fx: &Value, file: &EsmFile, model: &str) -> Result<Run, String> {
let tr = &fx["trajectory"];
match tr["from"].as_str() {
Some("manifest") => {
let tspan = f64_list(&tr["tspan"], "trajectory.tspan")?;
if tspan.len() != 2 {
return Err("trajectory.tspan must be [t0, t1]".to_string());
}
let mut run = Run {
u0: number_map(tr.get("initial_conditions"), "initial_conditions")?,
p: number_map(tr.get("parameter_overrides"), "parameter_overrides")?,
tspan: (tspan[0], tspan[1]),
saveat: f64_list(&tr["saveat"], "trajectory.saveat")?,
observed: string_list(tr.get("observed"), "trajectory.observed")?,
};
if run.saveat.is_empty() {
return Err("trajectory.saveat is empty".to_string());
}
run.observed.dedup();
Ok(run)
}
Some("inline_tests") => {
let test_id = tr["test_id"]
.as_str()
.ok_or("trajectory.test_id missing for a `from: inline_tests` fixture")?;
let mut run = inline_test_run(file, model, test_id)?;
run.observed = string_list(tr.get("observed"), "trajectory.observed")?;
Ok(run)
}
other => Err(format!(
"trajectory.from is {other:?}; the contract allows \"manifest\" or \"inline_tests\""
)),
}
}
fn state_rows(sol: &Solution, saveat: &[f64]) -> Result<Value, String> {
let mut rows = Map::new();
for &t in saveat {
let idx = nearest_index(&sol.time, t).ok_or("the solution has no time grid")?;
let mut row = Map::new();
for (r, name) in sol.state_variable_names.iter().enumerate() {
row.insert(bare(name).to_string(), num(sol.state[r][idx]));
}
rows.insert(time_key(t), Value::Object(row));
}
Ok(Value::Object(rows))
}
fn observed_series(
prob: &crate::problem::EsmProblem,
sol: &Solution,
run: &Run,
) -> Result<Value, String> {
let mut out = Map::new();
if run.observed.is_empty() {
return Ok(Value::Object(out));
}
let series = observed_trajectories(prob, sol, &run.observed)
.map_err(|e| format!("observed_trajectories: {e}"))?;
for (name, values) in series {
let mut m = Map::new();
for &t in &run.saveat {
let idx = nearest_index(&sol.time, t).ok_or("the solution has no time grid")?;
let v = values
.get(idx)
.copied()
.ok_or_else(|| format!("observed {name:?} has no sample at t={t}"))?;
m.insert(time_key(t), num(v));
}
out.insert(bare(&name).to_string(), Value::Object(m));
}
Ok(Value::Object(out))
}
enum Answer {
Entry(Value),
Unavailable(String),
}
fn run_fixture(fx: &Value, manifest_dir: &Path, compiler: Compiler) -> Result<Answer, String> {
let rel = fx["path"].as_str().ok_or("fixture.path missing")?;
let path = resolve_fixture_path(manifest_dir, rel)?;
let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
let file = crate::load_string(&text).map_err(|e| format!("load: {e:?}"))?;
let model = fx["model"].as_str().ok_or("fixture.model missing")?;
let run = fixture_run(fx, &file, model)?;
let integration = &fx["integration"];
let opts = SolveOptions {
alg: solver_alg(&file),
reltol: integration["reltol"].as_f64(),
abstol: integration["abstol"].as_f64(),
saveat: Some(run.saveat.clone()),
maxiters: Some(1_000_000),
..Default::default()
};
let prob = match esm_problem(
&file,
run.tspan,
ProblemOptions {
p: run.p.clone(),
u0: run.u0.clone(),
model_name: Some(model.to_string()),
rhs: Rhs::Always,
compiler: Some(compiler),
..Default::default()
},
) {
Ok(p) => p,
Err(SimulateError::Compile(CompileError::CompilerRefusedRule { rule, reason, .. })) => {
return Ok(Answer::Entry(json!({
"status": "refused",
"rule": rule,
"reason": reason,
})));
}
Err(SimulateError::CompilerUnavailable { details, .. }) => {
return Ok(Answer::Unavailable(details));
}
Err(e) => return Err(format!("SimulateError: {e}")),
};
let sol = match solve(&prob, &opts) {
Ok(s) => s,
Err(SimulateError::Compile(CompileError::CompilerRefusedRule { rule, reason, .. })) => {
return Ok(Answer::Entry(json!({
"status": "refused",
"rule": rule,
"reason": reason,
})));
}
Err(e) => return Err(format!("SimulateError: {e}")),
};
let state_order: Vec<String> = sol
.state_variable_names
.iter()
.map(|n| bare(n).to_string())
.collect();
Ok(Answer::Entry(json!({
"state_order": state_order,
"state": state_rows(&sol, &run.saveat)?,
"observed": observed_series(&prob, &sol, &run)?,
})))
}
pub fn run_manifest(manifest_path: &Path, compiler: Compiler) -> Result<Report, String> {
let text = fs::read_to_string(manifest_path)
.map_err(|e| format!("failed to read manifest {}: {e}", manifest_path.display()))?;
let manifest: Value =
serde_json::from_str(&text).map_err(|e| format!("failed to parse manifest: {e}"))?;
let abs = std::path::absolute(manifest_path).unwrap_or_else(|_| manifest_path.to_path_buf());
let manifest_dir = abs.parent().unwrap_or(Path::new(".")).to_path_buf();
let empty: Vec<Value> = Vec::new();
let mut fixtures = Map::new();
let mut failed = Vec::new();
for fx in manifest["fixtures"].as_array().unwrap_or(&empty) {
let id = fx["id"].as_str().unwrap_or("<unknown>").to_string();
let entry = match run_fixture(fx, &manifest_dir, compiler) {
Ok(Answer::Entry(v)) => v,
Ok(Answer::Unavailable(reason)) => {
return Ok(Report {
payload: json!({
"binding": "rust",
"compiler": compiler.as_str(),
"status": "unavailable",
"reason": reason,
}),
failed: Vec::new(),
});
}
Err(e) => {
eprintln!("fixture {id}: {e}");
failed.push(id.clone());
json!({ "error": e })
}
};
fixtures.insert(id, entry);
}
Ok(Report {
payload: json!({
"binding": "rust",
"compiler": compiler.as_str(),
"fixtures": Value::Object(fixtures),
}),
failed,
})
}