Skip to main content

earthsci_ast/types/
model.rs

1use super::*;
2use indexmap::IndexMap;
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6/// A `default` / override VALUE: either a scalar, or inline row-major nested
7/// array data (esm-spec §6.3, §6.6.2 — the `NumericArrayLiteral` of the schema).
8///
9/// A SHAPED variable's `default`, and a shaped variable's entry in a test's
10/// `parameter_overrides` / `initial_conditions`, may carry the whole field as a
11/// nested JSON array whose nesting matches the declared `shape` after
12/// metaparameter folding; a mismatch is a load-time error. A scalar in the same
13/// position keeps its broadcast meaning — the one value applies to every element.
14/// This is what lets a column test supply its θ, q_v, u, v, p profiles inline
15/// rather than through a per-regime generated rewrite-rule library.
16///
17/// `#[serde(untagged)]`, so a scalar round-trips as a bare JSON number and array
18/// data as the nested array it was authored as.
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20#[serde(untagged)]
21pub enum InlineValue {
22    /// A single number — a 0-D value, or the broadcast value of a shaped one.
23    Scalar(f64),
24    /// One axis of row-major nested array data.
25    Array(Vec<InlineValue>),
26}
27
28/// A flattened inline array: its per-axis extents and its values in ROW-MAJOR
29/// order (last index fastest), as [`InlineValue::to_dense`] produces them.
30pub type DenseInlineArray = (Vec<usize>, Vec<f64>);
31
32impl From<f64> for InlineValue {
33    fn from(v: f64) -> Self {
34        InlineValue::Scalar(v)
35    }
36}
37
38impl InlineValue {
39    /// The scalar this value carries, or `None` when it is array data.
40    pub fn as_scalar(&self) -> Option<f64> {
41        match self {
42            InlineValue::Scalar(v) => Some(*v),
43            InlineValue::Array(_) => None,
44        }
45    }
46
47    /// Whether this value is inline ARRAY data rather than a scalar.
48    pub fn is_array(&self) -> bool {
49        matches!(self, InlineValue::Array(_))
50    }
51
52    /// Flatten row-major nested array data into `(shape, values)` — the dense
53    /// buffer plus its per-axis extents, with the values in ROW-MAJOR order
54    /// (last index fastest), exactly as the JSON nesting reads.
55    ///
56    /// # Errors
57    ///
58    /// A string naming the defect when an axis is ragged (sibling arrays of
59    /// different lengths or mixed scalar/array siblings) — the schema admits
60    /// the shape, so the check has to live here.
61    pub fn to_dense(&self) -> Result<DenseInlineArray, String> {
62        let mut shape = Vec::new();
63        let mut probe = self;
64        while let InlineValue::Array(items) = probe {
65            shape.push(items.len());
66            match items.first() {
67                Some(first) => probe = first,
68                None => break,
69            }
70        }
71        let mut out = Vec::new();
72        flatten_inline(self, 0, &shape, &mut out)?;
73        Ok((shape, out))
74    }
75}
76
77/// Row-major flatten of `value` at nesting `depth`, checking every axis against
78/// the `shape` the first branch established.
79fn flatten_inline(
80    value: &InlineValue,
81    depth: usize,
82    shape: &[usize],
83    out: &mut Vec<f64>,
84) -> Result<(), String> {
85    match value {
86        InlineValue::Scalar(v) => {
87            if depth != shape.len() {
88                return Err(format!(
89                    "inline array data is ragged: a number appears at nesting depth {depth} where an array of {} elements is expected",
90                    shape[depth]
91                ));
92            }
93            out.push(*v);
94            Ok(())
95        }
96        InlineValue::Array(items) => {
97            if depth >= shape.len() {
98                return Err(
99                    "inline array data is ragged: an array appears where a number is expected"
100                        .to_string(),
101                );
102            }
103            if items.len() != shape[depth] {
104                return Err(format!(
105                    "inline array data is ragged: axis {depth} has both {} and {} elements",
106                    shape[depth],
107                    items.len()
108                ));
109            }
110            for item in items {
111                flatten_inline(item, depth + 1, shape, out)?;
112            }
113            Ok(())
114        }
115    }
116}
117
118/// A document-scoped index set declared in a model's `index_sets` registry
119/// (RFC semiring-faq-unified-ir §5.2 / §8). Unifies ESM `domain.spatial` grid
120/// dims and ESI categorical index sets under one shape. `kind` selects which
121/// optional fields are meaningful (enforced by the schema's kind-conditional
122/// `allOf`):
123/// - `interval`    → `size`
124/// - `categorical` → `members`
125/// - `derived`     → `from_faq`
126/// - `ragged`      → `of`, `offsets`, `values`
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
128pub struct IndexSet {
129    /// `"interval" | "categorical" | "derived" | "ragged"`.
130    pub kind: String,
131
132    /// Dense size for `kind: "interval"`.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub size: Option<i64>,
135
136    /// Enumerated members for `kind: "categorical"`.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub members: Option<Vec<serde_json::Value>>,
139
140    /// Source FAQ-node id for `kind: "derived"`.
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub from_faq: Option<String>,
143
144    /// Members-fed-back-as-const-factor for `kind: "derived"` (projection-
145    /// pushdown Phase 2b Hook 1, CONFORMANCE_SPEC §5.5.6).
146    ///
147    /// Names a model `parameter` const factor that the build fills with this
148    /// set's materialized value-invention MEMBERS — the invented 1-based
149    /// full-grid ids, `vi.members[from_faq]` — so an in-model gather
150    /// `index(W, index(<member_factor>, c))` can pull the full-grid rows the
151    /// compact derived axis selects. There is no `member(set, c)` IR op; this
152    /// feedback IS the mechanism.
153    ///
154    /// `None` for every non-feedback set, so ordinary sets round-trip
155    /// byte-identically. Mirrors the Julia reference's `IndexSet.member_factor`
156    /// and the `member_factor` property of esm-schema.json. Without it the Rust
157    /// binding silently DROPPED a normative field on parse, so a parse→emit
158    /// round-trip of an overlap-gated document (the shared
159    /// `overlap_gate_point_in_rect.esm` fixture among them) lost it.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub member_factor: Option<String>,
162
163    /// Parent index sets for `kind: "ragged"`.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub of: Option<Vec<String>>,
166
167    /// CSR/length backing-factor name for `kind: "ragged"`.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub offsets: Option<String>,
170
171    /// Member backing-factor name for `kind: "ragged"`.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub values: Option<String>,
174}
175
176/// Document-scoped, OPTIONAL solver hints (esm-spec §2.2).
177///
178/// Numerics the document knows about ITSELF, which each binding maps to its own
179/// integrator. Every field is ADVISORY: a binding may ignore any or all of them
180/// and still conform. Advisory governs the MECHANISM, never the OUTCOME — a
181/// binding that ignores every field and still converges conforms; the
182/// CONFORMANCE_SPEC §5.9 requirement to integrate successfully and agree within
183/// the error band is untouched by this block and is not excused by it.
184///
185/// `abstol` / `reltol` are INTEGRATION tolerances and are a different quantity
186/// from [`Tolerance`], which is what an assertion is COMPARED at (§6.6.4). They
187/// resolve on independent chains and neither substitutes for the other.
188///
189/// Every field is `Option` because each is independently optional and absence
190/// is NOT a default value: a document omitting `stiffness` has not declared its
191/// stiffness, and a binding must not read absence as an assertion about it.
192#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
193#[serde(deny_unknown_fields)]
194pub struct Solver {
195    /// The author's declaration of the system's stiffness: `"low"`,
196    /// `"moderate"` or `"high"`. A binding MAY select an implicit / BDF-family
197    /// integrator on `"high"`.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub stiffness: Option<String>,
200
201    /// Absolute INTEGRATION tolerance the document asks for.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub abstol: Option<f64>,
204
205    /// Relative INTEGRATION tolerance the document asks for.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub reltol: Option<f64>,
208
209    /// Advisory operator-splitting convention: `"none"`, `"lie"` or
210    /// `"strang"`. Carries no prescribed substep structure.
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub splitting: Option<String>,
213
214    /// Reference to an external run-configuration document (esm-spec §2.2.6),
215    /// held exactly as authored. EarthSciAST never opens it; the resolved
216    /// location comes from [`crate::solver_external_location`] and is never
217    /// written back. Arrives at esm 1.3.0.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub external: Option<String>,
220
221    /// The interval the document is meant to be simulated over (esm-spec
222    /// §2.2.7), held exactly as authored: a timestamp stays the string and a
223    /// number stays the number written. Its value in binary64 seconds comes
224    /// from [`crate::resolve_time_span`] and is never written back. Arrives at
225    /// esm 1.3.0.
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub time_span: Option<SolverTimeSpan>,
228}
229
230/// `solver.time_span` (esm-spec §2.2.7): the `{start, end}` shape of
231/// `tests[].time_span` ([`TimeSpan`]), except that each bound may also be an
232/// RFC 3339 UTC timestamp, so it is a separate type rather than `f64` bounds.
233#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
234#[serde(deny_unknown_fields)]
235pub struct SolverTimeSpan {
236    /// Beginning of the interval.
237    pub start: TimePoint,
238    /// End of the interval; after `start` once both are converted to seconds.
239    pub end: TimePoint,
240}
241
242/// One bound of a [`SolverTimeSpan`], kept in its authored form.
243#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
244#[serde(untagged)]
245pub enum TimePoint {
246    /// Seconds of the independent variable, in the document's time units. A
247    /// [`serde_json::Number`] so an integer bound emits as the integer written.
248    Seconds(serde_json::Number),
249    /// An RFC 3339 UTC timestamp (`YYYY-MM-DDThh:mm:ss(.f+)?Z`): seconds from
250    /// the document's time origin to that instant, counted as POSIX time.
251    Timestamp(String),
252}
253
254/// One entry of the document-scoped [`EsmFile::coordinates`] registry
255/// (RFC streaming-output-sinks §8.3): marks an existing data array — or an
256/// inline literal vector — as a physical coordinate and attaches CF metadata.
257///
258/// Exactly one of `source` and `values` is present. The coordinate's shape
259/// comes from its source, so it is NOT attached to any single axis: that one
260/// rule covers rectilinear (1-D monotonic → CF *dimension* coordinate),
261/// unstructured (1-D over a shared dimension) and curvilinear (2-D `lat(y,x)`)
262/// grids, the latter two emitting CF *auxiliary* coordinates.
263#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
264#[serde(deny_unknown_fields)]
265pub struct Coordinate {
266    /// Name of an existing data array (model variable, parameter, or loader
267    /// field) supplying this coordinate's values. Mutually exclusive with
268    /// `values`.
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub source: Option<String>,
271
272    /// Inline literal 1-D coordinate vector for the simple rectilinear case,
273    /// mirroring [`FunctionTableAxis::values`]. Mutually exclusive with
274    /// `source`.
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub values: Option<Vec<f64>>,
277
278    /// CF standard name (e.g. `"latitude"`), emitted verbatim.
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub standard_name: Option<String>,
281
282    /// CF/UDUNITS units string (e.g. `"degrees_north"`). Advisory at load
283    /// time, matching [`FunctionTableAxis::units`].
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub units: Option<String>,
286
287    /// CF axis role (`"X"` / `"Y"` / `"Z"` / `"T"`) for a 1-D monotonic
288    /// dimension coordinate; absent for auxiliary coordinates, which have no
289    /// single axis.
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub axis: Option<String>,
292}
293
294/// ODE-based model component
295///
296/// # `name` / `description` are NOT schema keys
297///
298/// The schema's `Model` is `additionalProperties: false` and declares neither
299/// `name` (a model's name is its key in the `models` map) nor `description`.
300/// Both fields below are therefore schema-INVALID the moment they are set, and
301/// `save()` will happily emit a document that fails its own schema. They are
302/// harmless only while they stay `None`, which is what every load-parsed model
303/// leaves them as — no deserializer ever fills them, because the wire form has
304/// no such keys.
305///
306/// They are not dead, though: [`crate::update_model_metadata`] — a tier-
307/// `extension`, Rust-only entry in `api-surface.json` — sets both, so its ONLY
308/// observable effect is to make a valid document invalid. `graph.rs` also reads
309/// `name` for a component-node label, where it is always `None` in practice.
310/// They are part of the published API surface, so they stay; leave them
311/// `None`.
312#[derive(Debug, Clone, Default, Serialize, Deserialize)]
313pub struct Model {
314    /// Human-readable model name. NOT a schema key — see the type-level note.
315    #[serde(skip_serializing_if = "Option::is_none")]
316    pub name: Option<String>,
317
318    /// Academic citation or data source reference
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub reference: Option<Reference>,
321
322    /// State variables, parameters, and observed quantities (keyed by name)
323    pub variables: IndexMap<String, ModelVariable>,
324
325    /// Differential equations
326    pub equations: Vec<Equation>,
327
328    /// Discrete events
329    #[serde(skip_serializing_if = "Option::is_none")]
330    pub discrete_events: Option<Vec<DiscreteEvent>>,
331
332    /// Continuous events
333    #[serde(skip_serializing_if = "Option::is_none")]
334    pub continuous_events: Option<Vec<ContinuousEvent>>,
335
336    /// Named child models (subsystems), keyed by unique identifier
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub subsystems: Option<IndexMap<String, serde_json::Value>>,
339
340    /// Brief description. NOT a schema key — see the type-level note.
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub description: Option<String>,
343
344    /// Model-level default numerical tolerance for inline tests.
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub tolerance: Option<Tolerance>,
347
348    /// Inline validation tests that exercise this model in isolation.
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub tests: Option<Vec<ModelTest>>,
351
352    /// Inline illustrative analyses of how to run this model (schema
353    /// `Model.analyses`); previously unmodelled here and so dropped on
354    /// round trip.
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub analyses: Option<Vec<ModelAnalysis>>,
357
358    /// Equations that hold only at t=0 (initialization-only, not time-stepped).
359    /// Introduced for aerosol equilibrium / plume-rise style models (gt-ebuq).
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub initialization_equations: Option<Vec<Equation>>,
362
363    /// Initial-guess seeds for nonlinear solvers during initialization, keyed
364    /// by variable name. Values may be numeric literals or Expression graphs.
365    #[serde(default, skip_serializing_if = "Option::is_none")]
366    pub guesses: Option<HashMap<String, serde_json::Value>>,
367
368    /// MTK system-kind discriminator: "ode" (default), "nonlinear", "sde", "pde".
369    #[serde(default, skip_serializing_if = "Option::is_none")]
370    pub system_kind: Option<String>,
371}
372
373/// Variable within a model
374#[derive(Debug, Clone, Serialize, Deserialize)]
375pub struct ModelVariable {
376    /// Variable type
377    #[serde(rename = "type")]
378    pub var_type: VariableType,
379
380    /// Physical units
381    #[serde(skip_serializing_if = "Option::is_none")]
382    pub units: Option<String>,
383
384    /// Default/initial value: a scalar, or — for a SHAPED variable — inline
385    /// row-major nested array data (esm-spec §6.3). See [`InlineValue`].
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub default: Option<InlineValue>,
388
389    /// The unit the `default` VALUE is expressed in, when it is not the
390    /// variable's declared `units` (schema `ModelVariable.default_units`).
391    ///
392    /// The schema's contract is that `default` is given in the declared `units`,
393    /// so a `default_units` naming a DIFFERENT unit is a defect: `units: "K"`
394    /// with `default: 25.0, default_units: "degC"` means the stored number is 25
395    /// but the variable actually reads 298.15. Rust did not model the field at
396    /// all, so the mismatch was silently dropped on load.
397    #[serde(skip_serializing_if = "Option::is_none")]
398    pub default_units: Option<String>,
399
400    /// Floating-point precision of THIS variable and of the arithmetic over it,
401    /// overriding the document-wide `domain.element_type` (esm-spec §11.3.1).
402    ///
403    /// This is NOT [`ModelVariable::var_type`]. That field is the variable's
404    /// semantic role (`unknown` / `parameter`); this one is its element type.
405    ///
406    /// It exists for the split a single document-wide precision cannot express.
407    /// A relational model may be binary32 in its floating-point *quantities* —
408    /// reproducing a `real*4` reference — while its join keys are integers
409    /// binary32 cannot represent: a ten-digit category code is ~2.26e9, 135× above
410    /// binary32's exact-integer limit of 2^24, so under a document-wide
411    /// `Float32` two distinct codes collapse onto one and a `join.on` over them
412    /// merges unrelated rows. Declaring the key columns `Float64` keeps them
413    /// exact while the quantities follow the document.
414    ///
415    /// `None` means the document's `domain.element_type`. See
416    /// [`crate::precision_infer`] for how the declaration propagates through an
417    /// expression and what it refuses.
418    #[serde(default, skip_serializing_if = "Option::is_none")]
419    pub element_type: Option<String>,
420
421    /// Brief description
422    #[serde(skip_serializing_if = "Option::is_none")]
423    pub description: Option<String>,
424
425    /// Arrayed-variable shape: ordered dimension names drawn from the
426    /// enclosing model's domain.spatial. `None` means scalar.
427    /// See discretization RFC §10.2.
428    #[serde(skip_serializing_if = "Option::is_none")]
429    pub shape: Option<Vec<String>>,
430
431    /// Staggered-grid location tag (e.g., "cell_center", "edge_normal",
432    /// "vertex"). `None` means no explicit staggering.
433    /// See discretization RFC §10.2.
434    #[serde(skip_serializing_if = "Option::is_none")]
435    pub location: Option<String>,
436
437    /// Parameter-only: draw the value from a distribution instead of fixing it
438    /// at `default` (esm-spec §6.3, schema `ModelVariable.distribution`).
439    /// Mutually exclusive with `default`. With no `update` the draw happens
440    /// ONCE at setup (`sampled_parameters`); with `update.kind: "wiener"` it is
441    /// redrawn every step with √dt scaling (`brownian_parameters`), which is
442    /// what promotes the enclosing model to an SDE. This subsumes the 0.x
443    /// `brownian` type's `noise_kind` / `correlation_group` tags: correlated
444    /// noise is one vector-valued parameter carrying a `cov` matrix, which the
445    /// tags never actually gave.
446    #[serde(default, skip_serializing_if = "Option::is_none")]
447    pub distribution: Option<Distribution>,
448
449    /// Parameter-only: when this parameter refreshes and what from
450    /// (esm-spec §5.4). Absent means it never changes after setup. One
451    /// mechanism replacing three 0.x constructs — the `brownian` type, the
452    /// `discrete` type's `refresh` trigger, and the `discrete_parameters`
453    /// event lists with their `functional_affect`.
454    #[serde(default, skip_serializing_if = "Option::is_none")]
455    pub update: Option<ParameterUpdateSpec>,
456}
457
458/// The bare variable: every optional field absent, `var_type` a `parameter`.
459/// A manual impl because [`VariableType`] has no derivable default — `type`
460/// is a required field on the wire — and of the two kinds only a parameter is
461/// coherent with nothing else set (an unknown with no defining equation is a
462/// structural error). Construction sites are still expected to state the type
463/// they mean: `ModelVariable { var_type: .., ..Default::default() }`.
464impl Default for ModelVariable {
465    fn default() -> Self {
466        ModelVariable {
467            var_type: VariableType::Parameter,
468            units: None,
469            default: None,
470            default_units: None,
471            element_type: None,
472            description: None,
473            shape: None,
474            location: None,
475            distribution: None,
476            update: None,
477        }
478    }
479}
480
481impl ModelVariable {
482    /// This variable's SCALAR `default`, or `None` when it has none or carries
483    /// inline array data instead (esm-spec §6.3).
484    ///
485    /// Every consumer that seeds one f64 slot reads through here, so a shaped
486    /// variable whose whole column is authored inline never lands a bogus number
487    /// in a scalar slot — the array channel picks it up instead
488    /// ([`crate::simulate_array`]).
489    pub fn default_scalar(&self) -> Option<f64> {
490        self.default.as_ref().and_then(InlineValue::as_scalar)
491    }
492
493    /// This variable's `default` as inline ARRAY data, flattened row-major with
494    /// its per-axis extents. `None` when the default is absent or scalar.
495    ///
496    /// # Errors
497    ///
498    /// A string naming the defect when the authored array is ragged.
499    pub fn default_array(&self) -> Option<Result<DenseInlineArray, String>> {
500        match self.default.as_ref() {
501            Some(v @ InlineValue::Array(_)) => Some(v.to_dense()),
502            _ => None,
503        }
504    }
505
506    /// Visit every Expression this variable carries, in a stable order.
507    ///
508    /// From esm 1.0.0 a variable has no `expression` field — an unknown's
509    /// definition is an EQUATION. What is left on the variable is the
510    /// parameter's `update`: each rule's `when` trigger, its `expression`
511    /// value form, and the `unit_conversion` of a `from` binding. All three
512    /// are Expression positions and so are subject to reference integrity
513    /// (esm-spec §4.9.5), which is what this walk exists to serve.
514    pub fn for_each_expression(&self, f: &mut impl FnMut(&Expr)) {
515        let Some(spec) = &self.update else { return };
516        for rule in spec.rules() {
517            if let Some(when) = rule.when() {
518                f(when);
519            }
520            let Some(value) = rule.value() else { continue };
521            if let Some(expr) = &value.expression {
522                f(expr);
523            }
524            if let Some(UnitConversion::Expression(expr)) =
525                value.from.as_ref().and_then(|b| b.unit_conversion.as_ref())
526            {
527                f(expr);
528            }
529        }
530    }
531
532    /// As [`ModelVariable::for_each_expression`], but also handing the callback
533    /// the JSON-pointer suffix of the SITE the expression came from, relative to
534    /// the variable's `update`.
535    ///
536    /// A diagnostic must name the field it is about. Reporting every one of a
537    /// parameter's update expressions at a bare `.../update` is ambiguous the
538    /// moment the ordered ARRAY form is used (esm-spec §5.4) — and that form
539    /// exists precisely because parameters written by two or more events are
540    /// common — so `when`, `expression` and `from/unit_conversion` all have to
541    /// be distinguishable, per rule. The suffixes match the other four
542    /// bindings: `""` for the single-rule object form and `/i` for entry `i` of
543    /// the array form, then the site (`/when`, `/expression`,
544    /// `/from/unit_conversion`).
545    pub fn for_each_expression_at(&self, f: &mut impl FnMut(&Expr, &str)) {
546        let Some(spec) = &self.update else { return };
547        let indexed = matches!(spec, ParameterUpdateSpec::Several(_));
548        for (i, rule) in spec.rules().iter().enumerate() {
549            let rule_path = if indexed {
550                format!("/{i}")
551            } else {
552                String::new()
553            };
554            if let Some(when) = rule.when() {
555                f(when, &format!("{rule_path}/when"));
556            }
557            let Some(value) = rule.value() else { continue };
558            if let Some(expr) = &value.expression {
559                f(expr, &format!("{rule_path}/expression"));
560            }
561            if let Some(UnitConversion::Expression(expr)) =
562                value.from.as_ref().and_then(|b| b.unit_conversion.as_ref())
563            {
564                f(expr, &format!("{rule_path}/from/unit_conversion"));
565            }
566        }
567    }
568
569    /// Mutable counterpart of [`ModelVariable::for_each_expression`], for the
570    /// namespacing / renaming / range-resolution passes.
571    pub fn for_each_expression_mut(&mut self, f: &mut impl FnMut(&mut Expr)) {
572        let Some(spec) = &mut self.update else { return };
573        let rules: &mut [ParameterUpdate] = match spec {
574            ParameterUpdateSpec::Single(rule) => std::slice::from_mut(rule),
575            ParameterUpdateSpec::Several(rules) => rules,
576        };
577        for rule in rules {
578            if let Some(when) = rule.when_mut() {
579                f(when);
580            }
581            let Some(value) = rule.value_mut() else {
582                continue;
583            };
584            if let Some(expr) = &mut value.expression {
585                f(expr);
586            }
587            if let Some(UnitConversion::Expression(expr)) =
588                value.from.as_mut().and_then(|b| b.unit_conversion.as_mut())
589            {
590                f(expr);
591            }
592        }
593    }
594
595    /// Error-propagating [`ModelVariable::for_each_expression_mut`]: visits
596    /// the same expression set in the same order and returns the FIRST error
597    /// `f` raises (later expressions are not passed to `f`, so they are left
598    /// unmodified). Defined in terms of `for_each_expression_mut` itself, so
599    /// the two can never disagree about which fields carry expressions.
600    pub fn try_for_each_expression_mut<E>(
601        &mut self,
602        f: &mut impl FnMut(&mut Expr) -> Result<(), E>,
603    ) -> Result<(), E> {
604        let mut first_err: Option<E> = None;
605        self.for_each_expression_mut(&mut |expr| {
606            if first_err.is_none()
607                && let Err(e) = f(expr)
608            {
609                first_err = Some(e);
610            }
611        });
612        match first_err {
613            Some(e) => Err(e),
614            None => Ok(()),
615        }
616    }
617
618    /// The `data_sources` keys this variable's update rules read from.
619    pub fn update_sources(&self) -> Vec<&str> {
620        self.update
621            .iter()
622            .flat_map(|spec| spec.rules())
623            .filter_map(|rule| rule.data_source())
624            .collect()
625    }
626}
627
628/// A parameter's value drawn from a probability distribution rather than fixed
629/// (esm-spec §6.3, schema `Distribution`). The closed set is `normal`,
630/// `lognormal`, `uniform` — bindings implement exactly these and reject
631/// anything else.
632///
633/// Univariate when the location parameter is a number, multivariate when it is
634/// an array — in which case `cov` gives the full covariance matrix and the
635/// parameter's `shape` must agree.
636#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
637#[serde(tag = "kind", rename_all = "snake_case")]
638pub enum Distribution {
639    /// Gaussian. Exactly one of `std` (independent components) or `cov`.
640    Normal {
641        /// Mean — a number (scalar) or one entry per component (vector).
642        mean: DistributionParam,
643        /// Standard deviation of INDEPENDENT components. Excludes `cov`.
644        #[serde(default, skip_serializing_if = "Option::is_none")]
645        std: Option<DistributionParam>,
646        /// Full covariance matrix, row-major. Excludes `std`.
647        #[serde(default, skip_serializing_if = "Option::is_none")]
648        cov: Option<CovarianceMatrix>,
649    },
650    /// Log-normal: `ln(value)` is normal with mean `mu` and spread
651    /// `sigma` / `cov` (both on the LOG scale).
652    Lognormal {
653        /// Mean of the underlying normal (log scale).
654        mu: DistributionParam,
655        /// Standard deviation of the underlying normal. Excludes `cov`.
656        #[serde(default, skip_serializing_if = "Option::is_none")]
657        sigma: Option<DistributionParam>,
658        /// Full covariance matrix on the log scale. Excludes `sigma`.
659        #[serde(default, skip_serializing_if = "Option::is_none")]
660        cov: Option<CovarianceMatrix>,
661    },
662    /// Uniform on `[low, high]`. Components are independent by construction, so
663    /// there is no covariance form.
664    Uniform {
665        /// Lower bound(s).
666        low: DistributionParam,
667        /// Upper bound(s).
668        high: DistributionParam,
669    },
670}
671
672impl Distribution {
673    /// The distribution's `kind` string, as it appears on the wire.
674    pub fn kind(&self) -> &'static str {
675        match self {
676            Distribution::Normal { .. } => "normal",
677            Distribution::Lognormal { .. } => "lognormal",
678            Distribution::Uniform { .. } => "uniform",
679        }
680    }
681
682    /// The LOCATION parameter (`mean` / `mu` / `low`), whose form decides
683    /// whether the distribution is univariate or multivariate.
684    pub fn location(&self) -> &DistributionParam {
685        match self {
686            Distribution::Normal { mean, .. } => mean,
687            Distribution::Lognormal { mu, .. } => mu,
688            Distribution::Uniform { low, .. } => low,
689        }
690    }
691
692    /// True when the location parameter is an array — the schema's
693    /// univariate/multivariate discriminator.
694    pub fn is_multivariate(&self) -> bool {
695        self.location().is_vector()
696    }
697
698    /// The full covariance matrix, when this distribution carries one.
699    pub fn cov(&self) -> Option<&CovarianceMatrix> {
700        match self {
701            Distribution::Normal { cov, .. } | Distribution::Lognormal { cov, .. } => cov.as_ref(),
702            Distribution::Uniform { .. } => None,
703        }
704    }
705}
706
707/// A distribution's location/scale parameter: a number for a scalar parameter,
708/// an array for a vector-valued one.
709#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
710#[serde(untagged)]
711pub enum DistributionParam {
712    /// Scalar (univariate) form.
713    Scalar(f64),
714    /// Per-component (multivariate) form.
715    Vector(Vec<f64>),
716}
717
718impl DistributionParam {
719    /// True when this is the multivariate (array) form.
720    pub fn is_vector(&self) -> bool {
721        matches!(self, DistributionParam::Vector(_))
722    }
723}
724
725/// Symmetric positive-semidefinite covariance matrix, row-major: entry
726/// `[i][j]` is the covariance of components `i` and `j`.
727pub type CovarianceMatrix = Vec<Vec<f64>>;
728
729/// A parameter's update behaviour (esm-spec §5.4, schema
730/// `ParameterUpdateSpec`): EITHER a single rule, OR an ordered array of two or
731/// more applied in declaration order.
732///
733/// A single rule MUST be the object form — a one-element array is invalid — so
734/// every update set has exactly one spelling and the round trip is stable.
735#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
736#[serde(untagged)]
737// The single form carries a whole `ParameterUpdate` inline while the array form
738// carries a `Vec`, so the variants differ in size. Boxing the single one would
739// pay an allocation on the COMMON spelling — most parameters carry one rule —
740// to shrink a type that lives once per variable and is never in a hot loop, and
741// it would cost the wire-facing `ParameterUpdateSpec::Single(rule)` match
742// ergonomics on the way. Same call as `DiscreteEventTrigger` above.
743#[allow(clippy::large_enum_variant)]
744pub enum ParameterUpdateSpec {
745    /// The object form: exactly one rule.
746    Single(ParameterUpdate),
747    /// The array form: two or more rules, applied in declaration order.
748    Several(Vec<ParameterUpdate>),
749}
750
751impl ParameterUpdateSpec {
752    /// The rules, in declaration order. The single form yields a one-element
753    /// slice, so every consumer reads one shape.
754    pub fn rules(&self) -> &[ParameterUpdate] {
755        match self {
756            ParameterUpdateSpec::Single(rule) => std::slice::from_ref(rule),
757            ParameterUpdateSpec::Several(rules) => rules,
758        }
759    }
760
761    /// True iff ANY rule is `wiener` — the `brownian_parameters` test
762    /// (esm-spec §6.3.1). The schema forbids `wiener` inside the array form,
763    /// so in practice only the single form can answer true.
764    pub fn is_wiener(&self) -> bool {
765        self.rules()
766            .iter()
767            .any(|r| matches!(r, ParameterUpdate::Wiener))
768    }
769
770    /// True iff any rule requires the variable to declare a `shape` —
771    /// `schedule`, `data`, or `remesh` (esm-spec §5.4 "Cadence"): such a
772    /// parameter is a buffer whose extent is fixed at setup.
773    pub fn requires_shape(&self) -> bool {
774        self.rules().iter().any(|r| {
775            matches!(
776                r,
777                ParameterUpdate::Schedule { .. }
778                    | ParameterUpdate::Data { .. }
779                    | ParameterUpdate::Remesh { .. }
780            )
781        })
782    }
783}
784
785/// One update rule: WHEN a parameter refreshes, and WHAT from
786/// (esm-spec §5.4). Every kind except `wiener` carries exactly one value form
787/// — `expression`, `from`, or `handler` — held in the flattened
788/// [`UpdateValue`]; `wiener` carries none, because the parameter's
789/// `distribution` IS its value.
790#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
791#[serde(tag = "kind", rename_all = "snake_case")]
792pub enum ParameterUpdate {
793    /// A driving Wiener (Brownian) process: the parameter's `distribution` is
794    /// resampled every step with √dt increment scaling. Any such parameter
795    /// promotes the enclosing model from an ODE system to an SDE system.
796    Wiener,
797    /// Time-driven refresh at preset `times` and/or on a periodic `interval`
798    /// (at least one of the two is present) — the MTK `PresetTimeCallback` /
799    /// `tstops` analogue.
800    Schedule {
801        /// Explicit simulation times (the tstops) at which to refresh.
802        #[serde(default, skip_serializing_if = "Option::is_none")]
803        times: Option<Vec<f64>>,
804        /// Periodic refresh interval in simulation time units.
805        #[serde(default, skip_serializing_if = "Option::is_none")]
806        interval: Option<f64>,
807        /// Offset from t=0 for the first periodic refresh.
808        #[serde(default, skip_serializing_if = "Option::is_none")]
809        initial_offset: Option<f64>,
810        /// The value form.
811        #[serde(flatten)]
812        value: UpdateValue,
813    },
814    /// Refresh at the end of any timestep at which the boolean `when` is true.
815    Condition {
816        /// Boolean expression tested at the end of each timestep.
817        when: Expr,
818        /// The value form.
819        #[serde(flatten)]
820        value: UpdateValue,
821    },
822    /// Refresh when `when` crosses zero, located by root-finding.
823    Crossing {
824        /// Expression whose zero crossing triggers the refresh.
825        when: Expr,
826        /// Which crossings count: `up`, `down`, or `any` (the default).
827        #[serde(default, skip_serializing_if = "Option::is_none")]
828        direction: Option<String>,
829        /// The value form.
830        #[serde(flatten)]
831        value: UpdateValue,
832    },
833    /// Refresh when the named `data_sources` entry advances a record.
834    Data {
835        /// Key of the `data_sources` entry driving the refresh. MUST resolve
836        /// (`data_source_undefined`).
837        source: String,
838        /// The value form.
839        #[serde(flatten)]
840        value: UpdateValue,
841    },
842    /// Refresh on a mesh-topology change (AMR refinement, moving mesh).
843    Remesh {
844        /// Optional name of the remesh hook. Absent ⇒ any remesh event.
845        #[serde(default, skip_serializing_if = "Option::is_none")]
846        hook: Option<String>,
847        /// The value form.
848        #[serde(flatten)]
849        value: UpdateValue,
850    },
851}
852
853impl ParameterUpdate {
854    /// The rule's `kind` string, as it appears on the wire.
855    pub fn kind(&self) -> &'static str {
856        match self {
857            ParameterUpdate::Wiener => "wiener",
858            ParameterUpdate::Schedule { .. } => "schedule",
859            ParameterUpdate::Condition { .. } => "condition",
860            ParameterUpdate::Crossing { .. } => "crossing",
861            ParameterUpdate::Data { .. } => "data",
862            ParameterUpdate::Remesh { .. } => "remesh",
863        }
864    }
865
866    /// The rule's value form, or `None` for `wiener` (which has none).
867    pub fn value(&self) -> Option<&UpdateValue> {
868        match self {
869            ParameterUpdate::Wiener => None,
870            ParameterUpdate::Schedule { value, .. }
871            | ParameterUpdate::Condition { value, .. }
872            | ParameterUpdate::Crossing { value, .. }
873            | ParameterUpdate::Data { value, .. }
874            | ParameterUpdate::Remesh { value, .. } => Some(value),
875        }
876    }
877
878    /// Mutable access to the rule's value form, or `None` for `wiener`.
879    pub fn value_mut(&mut self) -> Option<&mut UpdateValue> {
880        match self {
881            ParameterUpdate::Wiener => None,
882            ParameterUpdate::Schedule { value, .. }
883            | ParameterUpdate::Condition { value, .. }
884            | ParameterUpdate::Crossing { value, .. }
885            | ParameterUpdate::Data { value, .. }
886            | ParameterUpdate::Remesh { value, .. } => Some(value),
887        }
888    }
889
890    /// The boolean/zero-crossing trigger expression (`condition` / `crossing`).
891    pub fn when(&self) -> Option<&Expr> {
892        match self {
893            ParameterUpdate::Condition { when, .. } | ParameterUpdate::Crossing { when, .. } => {
894                Some(when)
895            }
896            _ => None,
897        }
898    }
899
900    /// Mutable access to the trigger expression (`condition` / `crossing`).
901    pub fn when_mut(&mut self) -> Option<&mut Expr> {
902        match self {
903            ParameterUpdate::Condition { when, .. } | ParameterUpdate::Crossing { when, .. } => {
904                Some(when)
905            }
906            _ => None,
907        }
908    }
909
910    /// The `data_sources` key this rule refreshes from, for the `data` kind.
911    pub fn data_source(&self) -> Option<&str> {
912        match self {
913            ParameterUpdate::Data { source, .. } => Some(source.as_str()),
914            _ => None,
915        }
916    }
917}
918
919/// The value form of an update rule: exactly one of `expression`, `from`, or
920/// `handler` (esm-spec §5.4, "What: exactly one value form"). Modelled as three
921/// options rather than an enum so the flattened round trip stays verbatim; the
922/// "exactly one" constraint is a schema-layer check.
923#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
924pub struct UpdateValue {
925    /// The new value, computed symbolically (§4).
926    #[serde(default, skip_serializing_if = "Option::is_none")]
927    pub expression: Option<Expr>,
928
929    /// The new value, read from the `data_sources` entry named by
930    /// `update.source` (§8).
931    #[serde(default, skip_serializing_if = "Option::is_none")]
932    pub from: Option<DataSourceBinding>,
933
934    /// The new value, computed by a registered handler (§5.5).
935    #[serde(default, skip_serializing_if = "Option::is_none")]
936    pub handler: Option<FunctionalUpdate>,
937}
938
939/// A registered handler computing a parameter's new value when its update
940/// fires (esm-spec §5.5). The 0.x event `functional_affect`, relocated onto the
941/// parameter it writes — which is why it needs no `modified_params` list: it
942/// writes exactly one thing, the parameter carrying it.
943#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
944pub struct FunctionalUpdate {
945    /// Registered identifier for the handler implementation.
946    pub handler_id: String,
947
948    /// Unknowns read by the handler. Absent means none.
949    #[serde(default, skip_serializing_if = "Option::is_none")]
950    pub read_vars: Option<Vec<String>>,
951
952    /// Parameters read by the handler. Absent means none.
953    #[serde(default, skip_serializing_if = "Option::is_none")]
954    pub read_params: Option<Vec<String>>,
955
956    /// Handler-specific configuration, passed through verbatim.
957    #[serde(default, skip_serializing_if = "Option::is_none")]
958    pub config: Option<serde_json::Value>,
959}
960
961/// Binds a parameter to ONE variable of a `data_sources` entry (esm-spec §8.5).
962/// The 0.x `DataLoaderVariable` minus `units`: the units are the parameter's
963/// own, declared once on the parameter instead of twice.
964#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
965pub struct DataSourceBinding {
966    /// Name of the variable in the source file supplying the values.
967    pub file_variable: String,
968
969    /// Multiplicative factor or Expression AST reaching the parameter's
970    /// declared `units`.
971    #[serde(default, skip_serializing_if = "Option::is_none")]
972    pub unit_conversion: Option<UnitConversion>,
973
974    /// Text-label decoding table, passed through verbatim.
975    #[serde(default, skip_serializing_if = "Option::is_none")]
976    pub codes: Option<serde_json::Value>,
977
978    /// Per-parameter override of the source-level `select`.
979    #[serde(default, skip_serializing_if = "Option::is_none")]
980    pub select: Option<serde_json::Value>,
981
982    /// Brief description.
983    #[serde(default, skip_serializing_if = "Option::is_none")]
984    pub description: Option<String>,
985
986    /// Academic citation or data source reference.
987    #[serde(default, skip_serializing_if = "Option::is_none")]
988    pub reference: Option<Reference>,
989}
990
991/// Type of model variable (esm-spec §6.3). There are **two** declared types
992/// and no third: everything else a solver needs — which unknowns are ODE
993/// states, which parameters are Brownian — is DERIVED by
994/// [`crate::classification`], never declared.
995#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
996#[serde(rename_all = "snake_case")]
997pub enum VariableType {
998    /// A quantity the solver solves for. Its behaviour is stated by the
999    /// model's `equations` and NOWHERE else — there is no `expression` field
1000    /// on a variable. Subsumes the 0.x `state` and `observed` types; which of
1001    /// the two an unknown is follows from the form of the equation LHS that
1002    /// defines it ([`crate::classification::ode_states`] /
1003    /// [`crate::classification::observed_unknowns`] /
1004    /// [`crate::classification::algebraic_unknowns`]).
1005    Unknown,
1006    /// A quantity supplied to the solver. Its value is `default` or a
1007    /// `distribution`, optionally refreshed by an `update` (§5.4). Subsumes
1008    /// the 0.x `parameter`, `brownian` and `discrete` types
1009    /// ([`crate::classification::brownian_parameters`] /
1010    /// [`crate::classification::discrete_parameters`] /
1011    /// [`crate::classification::sampled_parameters`] /
1012    /// [`crate::classification::constant_parameters`]).
1013    Parameter,
1014}
1015
1016/// Differential equation
1017#[derive(Debug, Clone, Serialize, Deserialize)]
1018pub struct Equation {
1019    /// Left-hand side expression
1020    pub lhs: Expr,
1021
1022    /// Right-hand side expression
1023    pub rhs: Expr,
1024
1025    /// Author's annotation on this equation (schema `Equation._comment`).
1026    /// The schema allows it explicitly alongside `additionalProperties:
1027    /// false`, so it is normative document content and must survive
1028    /// `parse → emit` — the field exists purely for that pass-through.
1029    #[serde(rename = "_comment", default, skip_serializing_if = "Option::is_none")]
1030    pub comment: Option<String>,
1031}
1032
1033impl Default for Equation {
1034    fn default() -> Self {
1035        Equation {
1036            lhs: Expr::Integer(0),
1037            rhs: Expr::Integer(0),
1038            comment: None,
1039        }
1040    }
1041}