Skip to main content

earthsci_ast/types/
components.rs

1use super::*;
2use indexmap::IndexMap;
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6/// Reaction network component
7#[derive(Debug, Clone, Default, Serialize, Deserialize)]
8pub struct ReactionSystem {
9    /// Academic citation or data source reference
10    #[serde(skip_serializing_if = "Option::is_none")]
11    pub reference: Option<Reference>,
12
13    /// Chemical species, keyed by species name
14    pub species: IndexMap<String, Species>,
15
16    /// Named parameters (rate constants, temperature, photolysis rates, etc.)
17    pub parameters: IndexMap<String, Parameter>,
18
19    /// Chemical reactions
20    pub reactions: Vec<Reaction>,
21
22    /// Additional algebraic or ODE constraints
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub constraint_equations: Option<Vec<Equation>>,
25
26    /// Discrete events
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub discrete_events: Option<Vec<DiscreteEvent>>,
29
30    /// Continuous events
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub continuous_events: Option<Vec<ContinuousEvent>>,
33
34    /// Named child reaction systems (subsystems), keyed by unique identifier
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub subsystems: Option<IndexMap<String, serde_json::Value>>,
37
38    /// System-level default numerical tolerance for inline tests (schema
39    /// `ReactionSystem.tolerance`, the [`Model::tolerance`] counterpart);
40    /// previously unmodelled here and so dropped on round trip — as were
41    /// `tests` and `analyses` below.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub tolerance: Option<Tolerance>,
44
45    /// Inline validation tests that exercise this reaction system in
46    /// isolation (schema `ReactionSystem.tests`, same `Test` entries as
47    /// [`Model::tests`]).
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub tests: Option<Vec<ModelTest>>,
50
51    /// Inline illustrative analyses of how to run this reaction system
52    /// (schema `ReactionSystem.analyses`, same `Analysis` entries as
53    /// [`Model::analyses`]).
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub analyses: Option<Vec<ModelAnalysis>>,
56}
57
58/// Chemical species in a reaction system. Keyed by name in the parent map.
59#[derive(Debug, Clone, Default, Serialize, Deserialize)]
60pub struct Species {
61    /// Physical units
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub units: Option<String>,
64
65    /// Default/initial concentration
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub default: Option<f64>,
68
69    /// The unit the `default` VALUE is expressed in, when it is not the
70    /// species' declared `units` (schema `Species.default_units`); previously
71    /// unmodelled here and so dropped on round trip. See
72    /// [`ModelVariable::default_units`].
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub default_units: Option<String>,
75
76    /// Brief description
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub description: Option<String>,
79
80    /// Reservoir species: participates in reactions but held fixed (no ODE).
81    /// Maps to Catalyst's `isconstantspecies=true`.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub constant: Option<bool>,
84}
85
86/// Parameter in a reaction system.
87///
88/// Carries the same value machinery as a model parameter (schema
89/// `Parameter`): a fixed `default` or a `distribution`, with an optional
90/// `update` saying when it refreshes — so the field set mirrors the
91/// parameter-side of [`ModelVariable`]. This struct previously modelled only
92/// `units`/`default`/`description`, silently DROPPING the rest on a
93/// `parse → emit` round trip (a `{kind: "data", …}` update block among them).
94#[derive(Debug, Clone, Default, Serialize, Deserialize)]
95pub struct Parameter {
96    /// Physical units
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub units: Option<String>,
99
100    /// Default/initial value
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub default: Option<f64>,
103
104    /// The unit the `default` VALUE is expressed in, when it is not the
105    /// parameter's declared `units`. See [`ModelVariable::default_units`].
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub default_units: Option<String>,
108
109    /// Brief description
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub description: Option<String>,
112
113    /// Arrayed-parameter shape: ordered index-set names from the
114    /// document-scoped `index_sets` registry. `None` means scalar. See
115    /// [`ModelVariable::shape`].
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub shape: Option<Vec<String>>,
118
119    /// Draw the value from a distribution instead of fixing it at `default`
120    /// (mutually exclusive with `default`). See [`ModelVariable::distribution`].
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub distribution: Option<Distribution>,
123
124    /// When this parameter refreshes and what it refreshes from
125    /// (esm-spec §5.4). See [`ModelVariable::update`].
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub update: Option<ParameterUpdateSpec>,
128}
129
130/// Chemical reaction
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct Reaction {
133    /// Unique reaction identifier
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub id: Option<String>,
136
137    /// Human-readable reaction name
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub name: Option<String>,
140
141    /// Reactant species and stoichiometry. May be null for source reactions (∅ → X).
142    /// Schema requires this field to be present (possibly null).
143    #[serde(default)]
144    pub substrates: Option<Vec<StoichiometricEntry>>,
145
146    /// Product species and stoichiometry. May be null for sink reactions (X → ∅).
147    /// Schema requires this field to be present (possibly null).
148    #[serde(default)]
149    pub products: Option<Vec<StoichiometricEntry>>,
150
151    /// Rate law expression
152    pub rate: Expr,
153
154    /// Academic citation or data source reference
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub reference: Option<Reference>,
157}
158
159/// Species with stoichiometric coefficient.
160///
161/// Coefficients may be fractional (e.g. `0.87 CH2O` in atmospheric
162/// chemistry) as well as integer. The coefficient MUST be positive and
163/// finite: NaN, ±∞, zero and negative values are rejected when the document
164/// loads ([`load_string`](crate::load_string), [`load_path`](crate::load_path)).
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct StoichiometricEntry {
167    /// Species name
168    pub species: String,
169
170    /// Stoichiometric coefficient (positive finite number; serialized as `stoichiometry`)
171    #[serde(
172        rename = "stoichiometry",
173        default = "default_stoichiometry",
174        serialize_with = "serialize_stoichiometry"
175    )]
176    pub coefficient: f64,
177}
178
179fn default_stoichiometry() -> f64 {
180    1.0
181}
182
183/// Emit a stoichiometric coefficient in ESM canonical-number form
184/// (CONFORMANCE_SPEC.md §5.5.3.1 rule 1): an integral, `i64`-representable
185/// value becomes an INTEGER literal, so the overwhelmingly common `1` stays
186/// `1` across a parse / re-emit cycle instead of becoming `1.0`. Derived serde
187/// would emit the trailing `.0` and diverge from all four sibling bindings,
188/// each of which normalizes this field (Julia and Python `_emit_stoich`, Go
189/// `canonicalFloat64String`, TypeScript implicitly via `JSON.stringify`).
190///
191/// A by-reference adapter and nothing more: `serialize_with` hands the field
192/// by reference, while the shared [`serialize_canonical_f64`] takes a value.
193fn serialize_stoichiometry<S: serde::Serializer>(
194    n: &f64,
195    serializer: S,
196) -> Result<S::Ok, S::Error> {
197    serialize_canonical_f64(*n, serializer)
198}
199
200/// Generic, runtime-agnostic description of an external data source
201/// (esm-spec §8).
202///
203/// A `DataSource` is pure I/O (RFC pure-io-data-loaders §4.1): it locates,
204/// reads, decodes, slices and filters bytes and nothing else. From esm 1.0.0 it
205/// **exposes no variables and is not a component** — not a coupling endpoint,
206/// not a subsystem, not a scoped-name path root. A model consumes it by
207/// declaring a PARAMETER whose [`ParameterUpdate::Data`] names this entry and
208/// binds one of its file variables ([`DataSourceBinding`]); the parameter owns
209/// the units. Grid geometry a source reads arrives the same way, as ordinary
210/// parameters, and is transformed downstream by `faq` nodes.
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct DataSource {
213    /// Structural kind of the dataset. Scientific role (emissions,
214    /// meteorology, elevation, ...) is not schema-validated and belongs in
215    /// `metadata.tags`.
216    pub kind: DataSourceKind,
217
218    /// File discovery configuration.
219    pub source: DataSourceLocation,
220
221    /// Temporal coverage and record layout. ABSENT means non-time-varying,
222    /// which is what refines a `data`-updated parameter's cadence seed from
223    /// DISCRETE down to CONST (CONFORMANCE_SPEC.md §5.7.2).
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub temporal: Option<DataSourceTemporal>,
226
227    /// Reproducibility contract — endian / float_format / integer_width
228    /// (esm-spec §8.9.2). A binding that cannot honor the declared
229    /// contract MUST reject the file at load.
230    #[serde(skip_serializing_if = "Option::is_none", default)]
231    pub determinism: Option<DataSourceDeterminism>,
232
233    /// Format-specific DECODE options, passed through to the format reader
234    /// verbatim (esm-spec §8.9.1). Held as raw JSON — the set of keys is the
235    /// bound reader's, not the schema's.
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub reader_options: Option<serde_json::Value>,
238
239    /// Source-level default slicing (esm-spec §8.9.2), overridable per
240    /// parameter by [`DataSourceBinding::select`]. Raw JSON passthrough.
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub select: Option<serde_json::Value>,
243
244    /// Which records are real (esm-spec §8.9.3). Raw JSON passthrough.
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub record_filter: Option<serde_json::Value>,
247
248    /// A source that discovers its own size (esm-spec §8.9.4). Raw JSON
249    /// passthrough.
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub extent: Option<serde_json::Value>,
252
253    /// Academic citation or data source reference.
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub reference: Option<Reference>,
256
257    /// Free-form metadata about the data source. Tags convey scientific role.
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub metadata: Option<DataSourceMetadata>,
260}
261
262/// Structural kind of a data loader dataset.
263#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
264#[serde(rename_all = "snake_case")]
265pub enum DataSourceKind {
266    /// Gridded dataset.
267    Grid,
268    /// Point / observational dataset.
269    Points,
270    /// Static dataset (no time dimension).
271    Static,
272}
273
274/// Reproducibility contract a loader advertises to bindings
275/// (esm-spec §8.9.2).
276#[derive(Debug, Clone, Default, Serialize, Deserialize)]
277pub struct DataSourceDeterminism {
278    #[serde(skip_serializing_if = "Option::is_none", default)]
279    pub endian: Option<String>,
280    #[serde(skip_serializing_if = "Option::is_none", default)]
281    pub float_format: Option<String>,
282    #[serde(skip_serializing_if = "Option::is_none", default)]
283    pub integer_width: Option<u32>,
284}
285
286/// File discovery configuration. Describes how to locate data files at
287/// runtime via URL templates with date/variable substitutions.
288#[derive(Debug, Clone, Serialize, Deserialize)]
289pub struct DataSourceLocation {
290    /// Jinja-style URL template with substitutions. Supported:
291    /// `{date:<strftime>}` (e.g. `{date:%Y%m%d}`), `{var}`, `{sector}`,
292    /// `{species}`. Custom substitutions are allowed and must be passed
293    /// through by the runtime.
294    pub url_template: String,
295
296    /// Ordered fallback URL templates. Runtime tries each in order.
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub mirrors: Option<Vec<String>>,
299}
300
301/// Temporal coverage and record layout for a data source.
302#[derive(Debug, Clone, Default, Serialize, Deserialize)]
303pub struct DataSourceTemporal {
304    /// ISO 8601 datetime — first timestamp available from this source.
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub start: Option<String>,
307
308    /// ISO 8601 datetime — last timestamp available from this source.
309    #[serde(skip_serializing_if = "Option::is_none")]
310    pub end: Option<String>,
311
312    /// ISO 8601 duration describing how much time one file covers.
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub file_period: Option<String>,
315
316    /// ISO 8601 duration describing spacing between samples within a file.
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub frequency: Option<String>,
319
320    /// Number of time records per file. `"auto"` means read from file at
321    /// runtime.
322    #[serde(skip_serializing_if = "Option::is_none")]
323    pub records_per_file: Option<RecordsPerFile>,
324
325    /// Name of the time coordinate variable in the file. Used when
326    /// `records_per_file` is absent or `"auto"`.
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub time_variable: Option<String>,
329}
330
331/// Number of records per file — an integer, or the literal `"auto"`.
332#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
333#[serde(untagged)]
334pub enum RecordsPerFile {
335    /// Fixed count (`>= 1`).
336    Count(u32),
337    /// `"auto"` — read from file at runtime.
338    Auto(AutoRecords),
339}
340
341/// Carrier for the `"auto"` literal in [`RecordsPerFile`].
342#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
343#[serde(rename_all = "lowercase")]
344pub enum AutoRecords {
345    /// Runtime discovers the record count from file metadata.
346    Auto,
347}
348
349/// Multiplicative factor (number) or Expression AST used to convert source-
350/// file values to the declared units.
351#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
352#[serde(untagged)]
353// Boxing the large variant would change the wire-facing construction/match
354// ergonomics on one of the crate's most-touched types for a size win that
355// profiling has not justified; when a variant IS boxed the field carries its
356// own rationale (see AssertionReference::Expression).
357#[allow(clippy::large_enum_variant)]
358pub enum UnitConversion {
359    /// Simple multiplicative factor.
360    Factor(f64),
361    /// Expression AST applied to the source value.
362    Expression(Expr),
363}
364
365/// Free-form metadata about a data loader.
366///
367/// The `tags` field is conventional for expressing scientific role
368/// (e.g. `"emissions"`, `"reanalysis"`) and is not schema-validated.
369/// Additional fields are preserved as raw JSON via `extra`.
370#[derive(Debug, Clone, Default, Serialize, Deserialize)]
371pub struct DataSourceMetadata {
372    /// Scientific role tags (freeform).
373    #[serde(skip_serializing_if = "Option::is_none")]
374    pub tags: Option<Vec<String>>,
375
376    /// Additional, loader-specific metadata fields.
377    #[serde(flatten)]
378    pub extra: HashMap<String, serde_json::Value>,
379}
380
381/// Runtime operator reference
382#[derive(Debug, Clone, Serialize, Deserialize)]
383pub struct Operator {
384    /// Registered identifier the runtime uses to find the implementation
385    pub operator_id: String,
386
387    /// Variables required by the operator
388    pub needed_vars: Vec<String>,
389
390    /// Variables the operator modifies
391    #[serde(skip_serializing_if = "Option::is_none")]
392    pub modifies: Option<Vec<String>>,
393
394    /// Academic citation or data source reference
395    #[serde(skip_serializing_if = "Option::is_none")]
396    pub reference: Option<Reference>,
397
398    /// Implementation-specific configuration
399    #[serde(skip_serializing_if = "Option::is_none")]
400    pub config: Option<serde_json::Value>,
401
402    /// Brief description
403    #[serde(skip_serializing_if = "Option::is_none")]
404    pub description: Option<String>,
405}
406
407/// A `variable_map` coupling `transform` (esm-spec §10.4): either one of the
408/// NAMED transform strings (`"param_to_var"`, `"identity"`,
409/// `"additive"`, `"multiplicative"`, `"conversion_factor"`) or an Expression
410/// evaluated on the source value(s) in the flattened coupled system's scope
411/// (the regridding form).
412///
413/// On the wire an Expression transform is always an operator-node OBJECT: the
414/// degenerate bare-reference and literal Expression spellings are not
415/// admissible (the named string transforms already cover bare replacement,
416/// and the string space is reserved for them), so a JSON string deserializes
417/// to [`VariableMapTransform::Named`], an object to
418/// [`VariableMapTransform::Expression`], and a bare number is rejected.
419#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
420#[serde(untagged)]
421// Boxing the large variant would change the wire-facing construction/match
422// ergonomics on one of the crate's most-touched types for a size win that
423// profiling has not justified; when a variant IS boxed the field carries its
424// own rationale (see AssertionReference::Expression).
425#[allow(clippy::large_enum_variant)]
426pub enum VariableMapTransform {
427    /// Legacy named transform string.
428    Named(String),
429    /// Expression transform: an operator node whose variable references are
430    /// fully scoped and which MUST reference the entry's `from` variable
431    /// (esm-spec §10.4). Template invocations inside it are expanded at load.
432    Expression(ExpressionNode),
433}
434
435impl VariableMapTransform {
436    /// The named transform string, if this is the legacy string form.
437    pub fn as_named(&self) -> Option<&str> {
438        match self {
439            VariableMapTransform::Named(s) => Some(s.as_str()),
440            VariableMapTransform::Expression(_) => None,
441        }
442    }
443
444    /// The Expression operator node, if this is the expression form.
445    pub fn as_expression(&self) -> Option<&ExpressionNode> {
446        match self {
447            VariableMapTransform::Named(_) => None,
448            VariableMapTransform::Expression(node) => Some(node),
449        }
450    }
451
452    /// Whether this is the expression form.
453    pub fn is_expression(&self) -> bool {
454        matches!(self, VariableMapTransform::Expression(_))
455    }
456}
457
458impl std::fmt::Display for VariableMapTransform {
459    /// Named transforms display as their string; expression transforms as the
460    /// fixed token `expression` (matching the Julia / Python provenance
461    /// descriptions in `coupling_rules_applied`).
462    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
463        match self {
464            VariableMapTransform::Named(s) => write!(f, "{s}"),
465            VariableMapTransform::Expression(_) => write!(f, "expression"),
466        }
467    }
468}
469
470/// Coupling entry with discriminated union based on type field
471#[derive(Debug, Clone, Serialize, Deserialize)]
472#[serde(tag = "type", rename_all = "snake_case")]
473// Boxing the large variant would change the wire-facing construction/match
474// ergonomics on one of the crate's most-touched types for a size win that
475// profiling has not justified; when a variant IS boxed the field carries its
476// own rationale (see AssertionReference::Expression).
477#[allow(clippy::large_enum_variant)]
478pub enum CouplingEntry {
479    /// Operator composition coupling
480    OperatorCompose {
481        /// The two systems to compose
482        systems: Vec<String>,
483        /// Variable mappings when LHS variables don't have matching names
484        #[serde(skip_serializing_if = "Option::is_none")]
485        translate: Option<serde_json::Value>,
486        /// Spatial-lift strategy for the merged state ODEs (esm-spec §10.5).
487        /// `"pointwise"` array-ifies each merged reaction+operator state ODE onto
488        /// the operator's grid (the flattener's pointwise lift); `None` leaves the
489        /// merged 0-D equations as-is.
490        #[serde(skip_serializing_if = "Option::is_none")]
491        lifting: Option<String>,
492        /// The entry's MERGE INTENT (esm-libraries-spec §4.7.1 step 5).
493        /// TRI-STATE, which is why it is an `Option`: `None` is NOT `false`.
494        ///
495        /// * `None` — the author has not said. A zero-merge is then
496        ///   [`FlattenError::OperatorComposeNoMerge`](crate::FlattenError::OperatorComposeNoMerge), an ERROR (such an entry
497        ///   is indistinguishable from one that is absent); a partial merge is a
498        ///   warning.
499        /// * `Some(true)` — the `systems[1]` equations are CONTRIBUTIONS and
500        ///   every one must land; any shortfall, partial included, is
501        ///   [`FlattenError::OperatorComposeRequireMatchUnmatched`](crate::FlattenError::OperatorComposeRequireMatchUnmatched).
502        /// * `Some(false)` — a standalone-contributing operator, DECLARED.
503        ///   Unmatched equations are expected and nothing is reported.
504        ///
505        /// `skip_serializing_if = "Option::is_none"` drops only the absent case,
506        /// so an explicit `false` survives the round trip — dropping it would
507        /// silently re-arm the zero-merge refusal on every document that opted
508        /// out.
509        #[serde(default, skip_serializing_if = "Option::is_none")]
510        require_match: Option<bool>,
511        /// Optional description
512        #[serde(skip_serializing_if = "Option::is_none")]
513        description: Option<String>,
514    },
515    /// Bi-directional coupling via explicit ConnectorSystem equations
516    Couple {
517        /// The two systems involved in coupling
518        systems: Vec<String>,
519        /// Connector definition with equations
520        connector: serde_json::Value,
521        /// Strategy for mapping between 0-D and spatial systems — one of
522        /// `pointwise`, `broadcast`, `mean`, `integral`. Schema-declared on
523        /// `CouplingCouple` exactly as on `CouplingOperatorCompose`; carried
524        /// so an authored value survives parse → emit.
525        #[serde(skip_serializing_if = "Option::is_none")]
526        lifting: Option<String>,
527        /// Optional description
528        #[serde(skip_serializing_if = "Option::is_none")]
529        description: Option<String>,
530    },
531    /// Variable mapping between systems
532    VariableMap {
533        /// Source variable (scoped reference)
534        from: String,
535        /// Target parameter (scoped reference)
536        to: String,
537        /// How the mapping is applied: a named transform string or an
538        /// Expression operator node (esm-spec §10.4).
539        transform: VariableMapTransform,
540        /// Conversion factor (for the scaling transforms only — not
541        /// permitted with an Expression transform)
542        #[serde(skip_serializing_if = "Option::is_none")]
543        factor: Option<f64>,
544        /// Strategy for mapping between 0-D and spatial systems — one of
545        /// `pointwise`, `broadcast`, `mean`, `integral`. Schema-declared on
546        /// `CouplingVariableMap` exactly as on `CouplingOperatorCompose`;
547        /// carried so an authored value survives parse → emit.
548        #[serde(skip_serializing_if = "Option::is_none")]
549        lifting: Option<String>,
550        /// Optional description
551        #[serde(skip_serializing_if = "Option::is_none")]
552        description: Option<String>,
553    },
554    /// Apply operator to system
555    OperatorApply {
556        /// Operator reference
557        operator: String,
558        /// Optional description
559        #[serde(skip_serializing_if = "Option::is_none")]
560        description: Option<String>,
561    },
562    /// Callback coupling
563    Callback {
564        /// Registered identifier for the callback
565        callback_id: String,
566        /// Configuration parameters
567        #[serde(skip_serializing_if = "Option::is_none")]
568        config: Option<serde_json::Value>,
569        /// Optional description
570        #[serde(skip_serializing_if = "Option::is_none")]
571        description: Option<String>,
572    },
573    /// Event-based coupling
574    Event {
575        /// Whether this is a continuous or discrete event
576        event_type: String,
577        /// Human-readable identifier
578        #[serde(skip_serializing_if = "Option::is_none")]
579        name: Option<String>,
580        /// Condition expressions (zero-crossing for continuous, boolean for discrete)
581        #[serde(skip_serializing_if = "Option::is_none")]
582        conditions: Option<Vec<Expr>>,
583        /// Trigger specification (for discrete events)
584        #[serde(skip_serializing_if = "Option::is_none")]
585        trigger: Option<DiscreteEventTrigger>,
586        /// Affect equations. esm 1.0.0: `affects` is the ONLY affect channel.
587        /// The 0.x `functional_affect` handler descriptor and the
588        /// `discrete_parameters` list are gone (RFC unified-variable-model D5)
589        /// — a parameter that changes during a run carries its own `update`
590        /// block, so every affect LHS names an unknown. The schema's
591        /// `CouplingEvent` def is `additionalProperties: false`, so either key
592        /// is a schema-layer rejection rather than a silently dropped field.
593        #[serde(skip_serializing_if = "Option::is_none")]
594        affects: Option<Vec<AffectEquation>>,
595        /// Separate affects for negative-going zero crossings
596        #[serde(skip_serializing_if = "Option::is_none")]
597        affect_neg: Option<Vec<AffectEquation>>,
598        /// Which side of the root the event lands on (esm-spec §5.2)
599        #[serde(skip_serializing_if = "Option::is_none")]
600        root_find: Option<RootFindDirection>,
601        /// Whether to reinitialize the system after the event
602        #[serde(skip_serializing_if = "Option::is_none")]
603        reinitialize: Option<bool>,
604        /// Brief description
605        #[serde(skip_serializing_if = "Option::is_none")]
606        description: Option<String>,
607    },
608    /// Reuse of a coupling-library file (esm-spec §10.9, §10.10): imports the
609    /// library named by `ref` and binds each of its declared roles to an
610    /// assembly component. Expands at flatten into concrete
611    /// `variable_map`/`couple`/`operator_compose`/`event` edges by substituting
612    /// bound actuals for role names; the entry itself round-trips intact.
613    CouplingImport {
614        /// §4.7 reference to a coupling-library file (a document with a
615        /// top-level `coupling_roles` map). `ref` is a Rust keyword, so the
616        /// field is spelled `reference` and renamed on the wire.
617        #[serde(rename = "ref")]
618        reference: String,
619        /// Total map from every library role name to a scoped component
620        /// reference in the assembly (esm-spec §10.10.1).
621        #[serde(default, skip_serializing_if = "Option::is_none")]
622        bind: Option<HashMap<String, String>>,
623        /// Optional description
624        #[serde(skip_serializing_if = "Option::is_none")]
625        description: Option<String>,
626    },
627}
628
629/// A coupling-library formal component role (esm-spec §10.9). Present only in
630/// a coupling-library file's top-level `coupling_roles` map; each entry carries
631/// an optional human-readable description. Roles are formal parameters (names,
632/// not types), bound to actual components at a `coupling_import`.
633#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
634pub struct CouplingRole {
635    /// Human-readable description of the role.
636    #[serde(default, skip_serializing_if = "Option::is_none")]
637    pub description: Option<String>,
638}
639
640/// Spatial/temporal domain specification
641#[derive(Debug, Clone, Serialize, Deserialize)]
642pub struct Domain {
643    /// Name of the independent (time) variable
644    #[serde(skip_serializing_if = "Option::is_none")]
645    pub independent_variable: Option<String>,
646
647    /// Temporal domain
648    #[serde(skip_serializing_if = "Option::is_none")]
649    pub temporal: Option<serde_json::Value>,
650
651    /// Floating point precision
652    #[serde(skip_serializing_if = "Option::is_none")]
653    pub element_type: Option<String>,
654
655    /// Array backend identifier
656    #[serde(skip_serializing_if = "Option::is_none")]
657    pub array_type: Option<String>,
658}
659
660#[cfg(test)]
661mod coupling_field_tests {
662    use super::*;
663
664    #[test]
665    fn test_operator_compose_new_fields() {
666        // Test OperatorCompose with new systems field
667        let json = r#"{
668            "type": "operator_compose",
669            "systems": ["system1", "system2"]
670        }"#;
671
672        let entry: CouplingEntry = serde_json::from_str(json).unwrap();
673        match entry {
674            CouplingEntry::OperatorCompose { systems, .. } => {
675                assert_eq!(systems, vec!["system1", "system2"]);
676            }
677            _ => panic!("Expected OperatorCompose variant"),
678        }
679    }
680
681    #[test]
682    fn test_couple_new_fields() {
683        // Test Couple with new systems field
684        let json = r#"{
685            "type": "couple",
686            "systems": ["system1", "system2"],
687            "connector": {
688                "equations": []
689            }
690        }"#;
691
692        let entry: CouplingEntry = serde_json::from_str(json).unwrap();
693        match entry {
694            CouplingEntry::Couple { systems, .. } => {
695                assert_eq!(systems, vec!["system1", "system2"]);
696            }
697            _ => panic!("Expected Couple variant"),
698        }
699    }
700
701    #[test]
702    fn test_variable_map_new_fields() {
703        // Test VariableMap with new from/to fields
704        let json = r#"{
705            "type": "variable_map",
706            "from": "source.var",
707            "to": "target.param",
708            "transform": "identity"
709        }"#;
710
711        let entry: CouplingEntry = serde_json::from_str(json).unwrap();
712        match entry {
713            CouplingEntry::VariableMap {
714                from,
715                to,
716                transform,
717                ..
718            } => {
719                assert_eq!(from, "source.var");
720                assert_eq!(to, "target.param");
721                assert_eq!(
722                    transform,
723                    crate::types::VariableMapTransform::Named("identity".to_string())
724                );
725            }
726            _ => panic!("Expected VariableMap variant"),
727        }
728    }
729
730    #[test]
731    fn test_coupling_serialization_round_trip() {
732        // Test serialization round-trip
733        let coupling = CouplingEntry::OperatorCompose {
734            lifting: None,
735            systems: vec!["sys1".to_string(), "sys2".to_string()],
736            translate: None,
737            require_match: None,
738            description: None,
739        };
740
741        let serialized = serde_json::to_string(&coupling).unwrap();
742        let deserialized: CouplingEntry = serde_json::from_str(&serialized).unwrap();
743
744        match deserialized {
745            CouplingEntry::OperatorCompose { systems, .. } => {
746                assert_eq!(systems, vec!["sys1", "sys2"]);
747            }
748            _ => panic!("Round-trip failed"),
749        }
750    }
751}