Skip to main content

earthsci_ast/simulate/
api.rs

1use super::*;
2
3// ============================================================================
4// Public API surface (per gt-5ws design)
5// ============================================================================
6
7/// Why a solve stopped — the SciML `ReturnCode` vocabulary
8/// (`esm-libraries-spec.md` §2.5.3).
9///
10/// This REPLACES reading [`SolutionMetadata`]'s step and evaluation counters as
11/// a proxy for whether a run finished. The counters remain, as informative
12/// statistics; the answer to "did it reach `tspan.1`" is this enum and nothing
13/// else, and a caller never has to parse an error message to get it.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ReturnCode {
16    /// The integration reached the end of `tspan`.
17    Success,
18    /// The integrator hit the configured [`SolveOptions::maxiters`] cap first.
19    /// The trajectory returned is everything computed up to that point.
20    MaxIters,
21    /// The state left the finite range (a NaN or an infinity appeared), so the
22    /// remaining trajectory would be meaningless.
23    Unstable,
24    /// A callback or the progress observer asked the integrator to stop
25    /// ([`Flow::Cancel`]). Nothing went wrong: it is the caller's own decision.
26    Terminated,
27    /// The solver itself reported an error — a step failure, a build failure, a
28    /// nonlinear-solve failure.
29    Failure,
30}
31
32impl ReturnCode {
33    /// Whether this is [`ReturnCode::Success`] — the one code that means the
34    /// integration covered the whole of `tspan`.
35    pub fn is_success(self) -> bool {
36        matches!(self, ReturnCode::Success)
37    }
38
39    /// The canonical SciML spelling, for display and for JSON hosts.
40    pub fn name(self) -> &'static str {
41        match self {
42            ReturnCode::Success => "Success",
43            ReturnCode::MaxIters => "MaxIters",
44            ReturnCode::Unstable => "Unstable",
45            ReturnCode::Terminated => "Terminated",
46            ReturnCode::Failure => "Failure",
47        }
48    }
49}
50
51impl std::fmt::Display for ReturnCode {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        f.write_str(self.name())
54    }
55}
56
57/// The canonical default relative tolerance.
58///
59/// All three simulation-capable bindings defaulted differently — Rust `1e-6`,
60/// Python scipy's `1e-3`, Julia `1e-4` — so the same document solved with
61/// default options did not produce comparable trajectories. The ruling is to
62/// adopt **Julia's** pair, which is what this is; Rust's own default was two
63/// orders TIGHTER, so aligning loosens it.
64///
65/// These are the defaults for a *production* run, not for a conformance
66/// assertion. A test that pins a trajectory to a numerical threshold should
67/// pass an explicit `reltol`/`abstol` rather than lean on these — that is what
68/// the knobs are for, and it is what the in-tree fixtures do.
69pub const DEFAULT_RELTOL: f64 = 1e-4;
70
71/// The canonical default absolute tolerance — Julia's. Rust's was `1e-8`.
72/// See [`DEFAULT_RELTOL`].
73pub const DEFAULT_ABSTOL: f64 = 1e-6;
74
75/// Which solver family to use inside diffsol.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum Alg {
78    /// Backward Differentiation Formulas — implicit, default for stiff ODEs.
79    Bdf,
80    /// Singly Diagonally Implicit Runge-Kutta (TR-BDF2 tableau) — implicit,
81    /// alternative stiff solver.
82    Sdirk,
83    /// Explicit Runge-Kutta (Tsitouras 5(4)) — non-stiff.
84    Erk,
85}
86
87/// The raw trajectory the solver loop hands back: sample times, one state row
88/// per time, and why it stopped.
89#[cfg(feature = "solve")]
90pub(crate) type RawTrajectory = (Vec<f64>, Vec<Vec<f64>>, ReturnCode);
91
92impl Alg {
93    /// Parse the host-facing solver name, case-insensitively.
94    ///
95    /// Every host that lets a caller pick a solver receives it as a string —
96    /// from a JSON options object in the browser, from an HTTP request body on
97    /// a server — so the name→variant mapping belongs next to the enum rather
98    /// than being re-derived per target. A host that spells it differently, or
99    /// forgets to apply it at all, silently runs a DIFFERENT solver than the
100    /// one its caller asked for and was quoted for.
101    pub fn from_name(name: &str) -> Option<Self> {
102        match name.trim().to_ascii_lowercase().as_str() {
103            "bdf" => Some(Self::Bdf),
104            "sdirk" => Some(Self::Sdirk),
105            "erk" => Some(Self::Erk),
106            _ => None,
107        }
108    }
109
110    /// The canonical lowercase name — the inverse of [`Self::from_name`].
111    pub fn name(self) -> &'static str {
112        match self {
113            Self::Bdf => "bdf",
114            Self::Sdirk => "sdirk",
115            Self::Erk => "erk",
116        }
117    }
118}
119
120/// How far along an in-flight integration is, handed to
121/// [`SolveOptions::progress`] once per accepted step.
122///
123/// `t` advances non-uniformly: an adaptive solver crawls through a stiff
124/// startup and then takes large steps, so [`Progress::fraction`] is *not*
125/// linear in wall clock. A host driving a progress bar from it should expect
126/// the early part of a stiff run to look stalled, and is usually better off
127/// showing `step` alongside the bar so a slow start still reads as alive.
128#[derive(Debug, Clone, Copy, PartialEq)]
129pub struct Progress<'a> {
130    /// Start of the integration interval.
131    pub t0: f64,
132    /// Independent-variable value the integrator has reached.
133    pub t: f64,
134    /// End of the integration interval.
135    pub t_end: f64,
136    /// Accepted steps taken so far (`0` for the pre-loop report at `t0`).
137    pub step: usize,
138    /// The configured [`SolveOptions::maxiters`] cap, for context; `None` when
139    /// the run is uncapped.
140    pub maxiters: Option<usize>,
141    /// The integrator's state vector at `t`, in
142    /// [`crate::problem::EsmProblem::state_variable_names`] order.
143    ///
144    /// This is what makes a [`crate::problem::CallbackSet`] entry able to do
145    /// the job `esm-libraries-spec.md` §2.5.4 describes — write an output
146    /// stream, checkpoint, watch for a threshold — rather than only draw a
147    /// progress bar.
148    pub u: &'a [f64],
149}
150
151impl Progress<'_> {
152    /// Fraction of the integration interval covered, clamped to `[0, 1]`.
153    ///
154    /// Returns `0.0` for a degenerate (zero-length) interval rather than a NaN,
155    /// so a host can divide by it or feed it to a bar without a guard.
156    pub fn fraction(&self) -> f64 {
157        let span = self.t_end - self.t0;
158        if !span.is_finite() || span <= 0.0 {
159            return 0.0;
160        }
161        ((self.t - self.t0) / span).clamp(0.0, 1.0)
162    }
163}
164
165/// What a [`SolveOptions::progress`] observer wants the integrator to do
166/// next.
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub enum Flow {
169    /// Keep integrating.
170    Continue,
171    /// Stop now; [`run_solver`] unwinds with [`SimulateError::Cancelled`].
172    Cancel,
173}
174
175/// A progress observer. See [`SolveOptions::progress`].
176///
177/// The `Send + Sync` bound is dropped on `wasm32`, where the natural observer
178/// wraps a `js_sys::Function` (neither `Send` nor `Sync`, and harmlessly so on
179/// a single-threaded target). Keeping the bound on native means adding this
180/// field does not cost native callers `SolveOptions: Send + Sync`.
181#[cfg(target_arch = "wasm32")]
182pub type ProgressFn = std::sync::Arc<dyn for<'p> Fn(&Progress<'p>) -> Flow>;
183/// A progress observer. See [`SolveOptions::progress`].
184#[cfg(not(target_arch = "wasm32"))]
185pub type ProgressFn = std::sync::Arc<dyn for<'p> Fn(&Progress<'p>) -> Flow + Send + Sync>;
186
187/// Per-run knobs for [`crate::problem::solve`].
188///
189/// Every field carries the canonical SciML spelling (`API_SPEC.md` §4):
190/// `alg`, `abstol`, `reltol`, `saveat`, `maxiters`.
191#[derive(Clone)]
192pub struct SolveOptions {
193    /// Which solver algorithm to use. Defaults to [`Alg::Bdf`].
194    ///
195    /// Named `alg` — not `solver` — because `API_SPEC.md` §4 makes the SciML
196    /// spelling canonical in every binding.
197    pub alg: Alg,
198    /// Absolute INTEGRATION tolerance, or `None` for "caller has no opinion".
199    ///
200    /// `None` is not the same as [`DEFAULT_ABSTOL`]: esm-spec §2.2.2 puts the
201    /// document's `solver` block BETWEEN the call site and the binding default,
202    /// so the two must be distinguishable. A concrete `f64` here could not
203    /// express that — a caller who never touched the field was indis-
204    /// tinguishable from one who set it to `1e-6`, and the document could never
205    /// win. Resolved by [`crate::resolve_tolerances`]; read the effective value
206    /// with [`SolveOptions::abstol_or_default`].
207    ///
208    /// This is an INTEGRATION tolerance, a different quantity from
209    /// [`crate::Tolerance`], which is what an assertion is COMPARED at (§6.6.4).
210    pub abstol: Option<f64>,
211    /// Relative INTEGRATION tolerance, or `None` for "caller has no opinion".
212    /// See [`SolveOptions::abstol`] for why this is an `Option`.
213    pub reltol: Option<f64>,
214    /// Cap on accepted integrator steps, or `None` (the default) for no cap.
215    ///
216    /// `Some(n)` stops the run after `n` accepted steps with
217    /// [`ReturnCode::MaxIters`] and the trajectory computed so far. It counts
218    /// the integrator's time-loop steps, which is what SciML's `maxiters`
219    /// counts (`if integrator.iter > opts.maxiters`, SciMLBase
220    /// `integrator_interface.jl`); it is not a Newton or fixed-point iteration
221    /// limit, which implicit methods carry separately. `esm-libraries-spec.md`
222    /// §2.5.3 names the option but sets no default, and the bindings do not
223    /// agree on one: OrdinaryDiffEqCore defaults to `1_000_000` for adaptive
224    /// methods, and Python defaults to `None` but counts right-hand-side
225    /// evaluations rather than steps. This binding follows Python's default and
226    /// SciML's unit.
227    ///
228    /// An `Option`, like [`SolveOptions::abstol`], so "the caller asked for no
229    /// cap" is the default rather than a large number standing in for one.
230    pub maxiters: Option<usize>,
231    /// If `Some`, the solution is sampled (via dense output / interpolation)
232    /// at exactly these times. If `None`, the natural step times are
233    /// returned.
234    pub saveat: Option<Vec<f64>>,
235    /// Callbacks for THIS run.
236    ///
237    /// **`Some(set)` REPLACES the EsmProblem's callback set entirely** — it does
238    /// not append, merge or wrap (`esm-libraries-spec.md` §2.5.4). `None`
239    /// inherits the EsmProblem's set. To extend rather than replace, read the set
240    /// back with [`crate::problem::callbacks`] and
241    /// [`crate::problem::compose`] explicitly.
242    pub callback: Option<crate::problem::CallbackSet>,
243    /// If `Some`, called once before the first step and then after every
244    /// accepted step, with the interval covered so far. Returning
245    /// [`Flow::Cancel`] stops the integration with
246    /// [`SimulateError::Cancelled`].
247    ///
248    /// **Called on every step, deliberately unthrottled.** The integrator has
249    /// no portable clock to throttle against — `std::time::Instant::now()`
250    /// panics on `wasm32-unknown-unknown`, and taking one unconditionally is
251    /// what broke every array/PDE run in the browser until `bc52c5fa` — so the
252    /// rate limiting belongs to the host, which has a working clock. Keep the
253    /// observer cheap: a fast run can accept thousands of steps in well under a
254    /// second.
255    pub progress: Option<ProgressFn>,
256    /// Observed fields to expose as extra rows of the returned [`Solution`],
257    /// alongside the state (streaming-output-sinks RFC decision 8: output is
258    /// the state PLUS a caller-named subset of observeds, never every observed
259    /// by default).
260    ///
261    /// Empty by default, so a run that does not ask pays nothing and returns
262    /// exactly what it returns today. A named field is appended in the same
263    /// flat cell-key spelling the state uses — a scalar as `name`, an
264    /// array-shaped one as one row per cell, `name[i,j,…]`, 1-based and
265    /// column-major — which is precisely the `slot_names` shape
266    /// [`crate::derive_output_plan`] inverts back into dimension-labeled,
267    /// CF-coordinated output arrays.
268    ///
269    /// Names may be bare or `Model.`-qualified. A name that is not an observed
270    /// of this document is ignored here and diagnosed downstream by
271    /// [`crate::OutputError::UnknownObserved`], which names it.
272    ///
273    /// The runtime materializes the requested array-valued observeds it
274    /// otherwise skips.
275    pub output_observed: Vec<String>,
276}
277
278// Hand-written because `ProgressFn` is a trait object: it cannot derive Debug,
279// and a `SolveOptions` that no longer prints would be a regression for every
280// existing `{:?}` on a solver error path.
281impl std::fmt::Debug for SolveOptions {
282    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
283        f.debug_struct("SolveOptions")
284            .field("alg", &self.alg)
285            .field("abstol", &self.abstol)
286            .field("reltol", &self.reltol)
287            .field("maxiters", &self.maxiters)
288            .field("saveat", &self.saveat)
289            .field("callback", &self.callback)
290            .field(
291                "progress",
292                &self
293                    .progress
294                    .as_ref()
295                    .map(|_| "<observer>")
296                    .unwrap_or("None"),
297            )
298            .field("output_observed", &self.output_observed)
299            .finish()
300    }
301}
302
303impl Default for SolveOptions {
304    fn default() -> Self {
305        Self {
306            alg: Alg::Bdf,
307            // `None`, not the constant: "the caller expressed no opinion", so a
308            // document's §2.2 `solver` block can still supply a value. The
309            // constants remain the bottom of that chain.
310            abstol: None,
311            reltol: None,
312            // Uncapped unless the caller asks: a fixed step budget silently ends
313            // any integration long enough to need more steps than the budget.
314            maxiters: None,
315            saveat: None,
316            callback: None,
317            progress: None,
318            output_observed: Vec::new(),
319        }
320    }
321}
322
323impl SolveOptions {
324    /// The effective absolute tolerance: the caller's value, else
325    /// [`DEFAULT_ABSTOL`]. Use this at the point of USE when no document is in
326    /// hand; when one is, resolve the full §2.2.2 chain with
327    /// [`crate::resolve_tolerances`] instead.
328    pub fn abstol_or_default(&self) -> f64 {
329        self.abstol.unwrap_or(DEFAULT_ABSTOL)
330    }
331
332    /// The effective relative tolerance: the caller's value, else
333    /// [`DEFAULT_RELTOL`]. See [`SolveOptions::abstol_or_default`].
334    pub fn reltol_or_default(&self) -> f64 {
335        self.reltol.unwrap_or(DEFAULT_RELTOL)
336    }
337
338    /// Request `n` evenly spaced output samples across `[t0, t_end]`.
339    ///
340    /// Hosts almost always express "how much output do I want" as a count, not
341    /// as a time grid, so the grid construction lives here — an off-by-one in
342    /// the spacing is easy to write and impossible to notice in a plot.
343    /// `n` is clamped to at least 2, since a span needs both ends.
344    pub fn sample_evenly(&mut self, t0: f64, t_end: f64, n: usize) {
345        let n = n.max(2);
346        let span = t_end - t0;
347        self.saveat = Some(
348            (0..n)
349                .map(|i| t0 + span * (i as f64) / ((n - 1) as f64))
350                .collect(),
351        );
352    }
353}
354
355/// A simulation result.
356///
357/// `state[i][k]` is the value of state variable `state_variable_names[i]` at
358/// time `time[k]`.
359#[derive(Debug, Clone)]
360pub struct Solution {
361    /// Output time grid.
362    pub time: Vec<f64>,
363    /// State trajectories, indexed `[variable_index][time_index]`.
364    pub state: Vec<Vec<f64>>,
365    /// Names of the state variables, parallel to the rows of `state`.
366    pub state_variable_names: Vec<String>,
367    /// Why the integration stopped (`esm-libraries-spec.md` §2.5.3).
368    ///
369    /// [`ReturnCode::Success`] means the run reached `tspan.1`. Anything else
370    /// means it stopped early and the trajectory ends where it stopped — the
371    /// counters in [`Solution::metadata`] are statistics, not the answer to
372    /// that question.
373    pub retcode: ReturnCode,
374    /// Solver provenance and step counts.
375    pub metadata: SolutionMetadata,
376}
377
378impl Solution {
379    /// The trajectory of the variable named `name` — the documented way to read
380    /// a solution (`esm-libraries-spec.md` §2.5.7).
381    ///
382    /// Matches the exact (flattened, qualified) name first, then falls back to
383    /// the UNIQUE dotted-name tail, so a caller may write `"x"` for `"M.x"`
384    /// while an ambiguous bare name still returns `None` rather than an
385    /// arbitrary one of the candidates. Position-indexed access through
386    /// [`Solution::state`] remains available, but the flattened state ordering
387    /// is an implementation detail that coupling can change.
388    pub fn get(&self, name: &str) -> Option<&[f64]> {
389        self.index_of(name).map(|i| self.state[i].as_slice())
390    }
391
392    /// The ROW INDEX of the variable named `name`, under the same precedence
393    /// [`Self::get`] applies.
394    ///
395    /// Exposed beside `get` because a caller that wants one value at one time
396    /// needs the index, not the row: `sol.state[i][k]`. Resolving that by hand
397    /// — `position(|n| n == name)` — is an EXACT match, which is how a caller
398    /// ends up depending on whether the build qualified its names.
399    ///
400    /// The precedence, in order:
401    ///
402    /// 1. the exact stored spelling;
403    /// 2. the survivor of an `operator_compose` renaming match that DELETED
404    ///    this name (issue #230) — consulted after the exact match, so the map
405    ///    can never shadow a live row;
406    /// 3. a BARE `name` against the unique qualified row with that tail;
407    /// 4. a QUALIFIED `name` against the row it names once the solution's own
408    ///    [`SolutionMetadata::namespace`] is stripped from its front. The
409    ///    mirror of (3), and the arm a
410    ///    caller needs when a document's rows come from the array runtime's
411    ///    single-model build, which names its slots without the model's
412    ///    namespace while the flattened build qualifies them.
413    ///
414    /// An ambiguous tail resolves to nothing rather than to an arbitrary one
415    /// of the candidates, in both directions.
416    pub fn index_of(&self, name: &str) -> Option<usize> {
417        if let Some(i) = self.state_variable_names.iter().position(|n| n == name) {
418            return Some(i);
419        }
420        if let Some(survivor) = self.metadata.merged_variable_renames.get(name)
421            && let Some(i) = self.state_variable_names.iter().position(|n| n == survivor)
422        {
423            return Some(i);
424        }
425        if name.contains('.') {
426            // A qualified request reaches a row the build left unqualified only
427            // through the namespace it left off: `M.x` is this model's `x` (and
428            // `M.North.u` its mounted `North.u`), but `Other.x` is not.
429            let ns = self.metadata.namespace.as_deref()?;
430            let rest = name.strip_prefix(ns)?.strip_prefix('.')?;
431            return self.state_variable_names.iter().position(|n| n == rest);
432        }
433        let mut hit = None;
434        for (i, n) in self.state_variable_names.iter().enumerate() {
435            // A bare request matches a QUALIFIED row's tail.
436            let matches = n.contains('.') && n.rsplit('.').next() == Some(name);
437            if matches {
438                if hit.is_some() {
439                    return None; // ambiguous tail
440                }
441                hit = Some(i);
442            }
443        }
444        hit
445    }
446
447    /// [`Solution::get`], as a `Result` naming the variable that was not found.
448    pub fn variable(&self, name: &str) -> Result<&[f64], SimulateError> {
449        self.get(name)
450            .ok_or_else(|| SimulateError::InvalidParameter {
451                name: name.to_string(),
452            })
453    }
454
455    /// The value of variable `name` at output index `k`.
456    pub fn at(&self, name: &str, k: usize) -> Option<f64> {
457        self.get(name).and_then(|row| row.get(k).copied())
458    }
459
460    /// The final value of variable `name`.
461    pub fn final_value(&self, name: &str) -> Option<f64> {
462        self.get(name).and_then(|row| row.last().copied())
463    }
464
465    /// Every variable name carried by this solution.
466    pub fn variable_names(&self) -> &[String] {
467        &self.state_variable_names
468    }
469}
470
471impl std::ops::Index<&str> for Solution {
472    type Output = [f64];
473
474    /// `sol["M.x"]` — name-indexed access, panicking on an unknown or
475    /// ambiguous name. Use [`Solution::get`] when absence is expected.
476    fn index(&self, name: &str) -> &[f64] {
477        self.get(name).unwrap_or_else(|| {
478            panic!(
479                "no state variable '{name}' in this solution; have {:?}",
480                self.state_variable_names
481            )
482        })
483    }
484}
485
486/// Provenance metadata for a [`Solution`].
487#[derive(Debug, Clone, Default)]
488pub struct SolutionMetadata {
489    /// Solver algorithm name (e.g. `"Bdf"`, `"Sdirk"`, `"Erk"`).
490    pub alg: String,
491    /// Number of RHS function evaluations performed (best-effort, may be
492    /// zero in v1 if diffsol does not expose it).
493    pub n_rhs_calls: usize,
494    /// Number of Jacobian evaluations performed (best-effort).
495    pub n_jacobian_calls: usize,
496    /// Number of accepted integrator steps (best-effort).
497    pub n_accepted_steps: usize,
498    /// Number of rejected integrator steps (best-effort).
499    pub n_rejected_steps: usize,
500    /// `(rule name, reason)` for every rule the array driver could NOT compile
501    /// onto the vectorized tape. Always empty: a model with any such rule has
502    /// no tape, and every routing that builds one — the strict compilers at
503    /// construction, the historical routing (`compile_array`) at the start of
504    /// the solve — refuses it with
505    /// [`crate::CompileError::CompilerRefusedRule`] naming the
506    /// rule and the reason, rather than running the whole model on another
507    /// evaluator. Kept so the metadata shape (and `metadata.tapeFallbacks`
508    /// over the wasm/JS boundary) is stable; `compiler_report` and the
509    /// `tape_report` example name the rules of a build without solving it.
510    /// See `esm-spec.md` §9.6.10 for the authoring patterns that keep rules
511    /// vectorizable.
512    pub tape_fallbacks: Vec<(String, String)>,
513    /// Every state spelling an `operator_compose` renaming match DELETED,
514    /// mapped onto the survivor it was folded into (issue #230; carried from
515    /// `FlattenMetadata::merged_variable_renames`).
516    ///
517    /// It rides on the SOLUTION because the solution is the one object a caller
518    /// reading a trajectory by name holds — the flattened system is not in its
519    /// hand — and a caller naming `B.x` after the merge folded it into `A.x` is
520    /// naming a state that MOVED, not one that never existed.
521    /// [`Solution::get`] resolves through it, and so does the `esm simulate
522    /// --format grid` output plan. Empty for a document with no renaming merge,
523    /// which is the overwhelming majority.
524    pub merged_variable_renames: HashMap<String, String>,
525    /// The model namespace this solution's BARE rows belong to, when the build
526    /// named its slots without it (the array runtime's single-model build).
527    ///
528    /// [`Solution::get`] accepts `<namespace>.x` for a row the build named `x`,
529    /// and only under this namespace, so `Other.x` never reads another model's
530    /// `x`. `None` when every row is already qualified.
531    pub namespace: Option<String>,
532}