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}