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}