earthsci_ast/data_output.rs
1//! Simulation-output derivation — the Rust mirror of `EarthSciAST.jl`'s
2//! `src/data_output.jl` (streaming-output-sinks RFC §7–§9).
3//!
4//! A solved model hands back a **flat** state vector whose elements are named
5//! by the column-major cell-key scheme `name[i,j,…]` (1-based, dim 0 varying
6//! fastest — see `simulate_array::compile::build_slot_tables`). A Zarr writer
7//! wants the opposite: **dimension-labeled, row-major (C-order) arrays** with
8//! CF metadata. This module is the inversion between the two, plus the
9//! document-derived metadata that turns positional axes into real `index_sets`
10//! names and CF coordinates.
11//!
12//! It is deliberately I/O-free and writer-agnostic: it produces a *plan*
13//! ([`OutputPlan`]), never a store. The plan's field shapes are chosen to drop
14//! straight into `earthsciio::format::OutputSchema` / `WriteVar` / `WriteCoord`
15//! (see [`OutputPlan`] docs for the exact correspondence), but nothing here
16//! links EarthSciIO — the same plan feeds any writer, native or wasm.
17//!
18//! ## Layers (each independently useful, in dependency order)
19//!
20//! 1. [`parse_cell_key`] + [`derive_output_gridding`] — the **flat→gridded
21//! inversion** (§7). Language-neutral: the cell-key scheme is identical in
22//! Julia, Python and Rust, so this inversion is specified once and must
23//! agree across all three.
24//! 2. [`derive_output_meta`] — the document slice a writer needs: real axis
25//! names (a variable's declared `shape`), per-axis lengths, CF variable
26//! attributes, the additive `coordinates` registry (§8.3), and the time-axis
27//! name.
28//! 3. [`plan_dimension_coordinates`] — CF **dimension coordinates** (values +
29//! `standard_name` / `units` / `axis`) resolved from that registry (§8).
30//! 4. [`group_gridding_by_grid`] — partition variables into **grids** by their
31//! spatial-dim signature (§9). Grids are emergent, never declared.
32//! 5. [`derive_output_plan`] — all of the above, plus the RFC decision-8 rule
33//! that output is the state **plus a caller-named subset** of observed
34//! fields (not every observed by default).
35//!
36//! ## wasm32
37//!
38//! This module must compile for `wasm32-unknown-unknown` — the browser runner
39//! *is* this crate compiled to wasm. It therefore uses no filesystem, no
40//! threads, and **no wall clock**: `std::time::Instant::now()` aborts a wasm32
41//! module with a bare `unreachable`, which once killed every spatial run in the
42//! browser. There is no timing or instrumentation here, deliberately.
43//!
44//! ## Cross-language agreement with the Julia reference
45//!
46//! This module and `EarthSciAST.jl`'s `src/data_output.jl` are the same
47//! derivation in two languages, and they are gated as such: the corpus at
48//! `tests/conformance/output_derivation/` drives both from one set of `.esm`
49//! fixtures + flat slot names and asserts one committed golden per case
50//! (`tests/output_derivation_conformance.rs` here, the
51//! `output_derivation_conformance_test.jl` twin there). Four behaviours below
52//! were divergences the two implementations carried silently until that corpus
53//! existed; RFC §16.12 records the reconciliation and the evidence. **All four
54//! resolved in this module's favour, and the Julia now matches.**
55//!
56//! * **Scalars carry no synthetic axis.** `data_output.jl` used to give a
57//! scalar the singleton shape `[1]` with the positional dim name
58//! `"<base>_d0"`, which made every scalar its own single-member grid under
59//! `group_gridding_by_grid`: 66 scalar states meant 66 length-1 dimensions
60//! named after their own variables and 66 separate grids. Here a scalar has
61//! `shape == []` and `dim_names == []`, so it is genuinely
62//! 0-spatial-dimensional, every scalar lands in ONE grid, and its on-disk
63//! dims are exactly `[time_dim]` — what CF, Zarr and xarray all expect, and a
64//! rank the store already contains (the `time` coordinate is one). Positional
65//! `"<base>_d0"` naming is retained verbatim for *gridded* variables with no
66//! declared `shape`, where it is load-bearing.
67//! * **The record axis leads.** [`VarPlan::dims`] is the record axis followed by
68//! the spatial axes. Julia's `ZarrSink` used to append the record axis last.
69//! Record-first is CF §2.4's T,Z,Y,X recommendation, the order a NetCDF
70//! unlimited dimension requires, the order EarthSciIO's shared
71//! `write_spec.json` already pins for all three writers, and the layout that
72//! keeps one appended record contiguous on disk.
73//! * **Density is validated.** The Julia used to derive each axis length as the
74//! max cell index and never check that the cells cover the grid, so a gap left
75//! an uninitialized hole and a repeat silently dropped a slot. An incomplete
76//! or double-covered grid is [`OutputError::SparseGrid`] /
77//! [`OutputError::DuplicateCell`].
78//! * **Namespaced lookups.** The Julia used to look a var_map base name up in a
79//! table keyed by *bare* variable name, so a flattened `Model.u` silently fell
80//! back to positional axis names. [`OutputMeta`] registers both the bare and
81//! the `Model.`-qualified key (dropping a bare key that two models would make
82//! ambiguous) and additionally falls back to the last dotted segment.
83//!
84//! One difference remains, and is not a divergence in behaviour:
85//! **`standard_name` is not retained per variable.** `derive_output_meta` in
86//! Julia copies `units` / `standard_name` / `description` off the raw variable
87//! dict. Neither `ModelVariable` nor `esm-schema.json`'s `ModelVariable` has a
88//! `standard_name` property — `additionalProperties` is `false` — so no
89//! schema-valid document can carry one and the typed Rust binding models
90//! `units` and `description` only.
91
92use std::collections::{BTreeMap, BTreeSet, HashSet};
93
94use serde_json::{Map as JsonMap, Value as JsonValue};
95
96use crate::types::{Coordinate, EsmFile, IndexSet, VariableType};
97
98/// The element type every output array carries. The flat solver state is
99/// `f64` throughout, so this is currently a constant rather than a per-variable
100/// property; it exists so a writer can name a dtype without hard-coding one.
101pub const DTYPE_FLOAT64: &str = "float64";
102
103/// Fallback name for the record axis when the document declares no
104/// `domain.independent_variable`.
105pub const DEFAULT_TIME_DIM: &str = "time";
106
107// --------------------------------------------------------------------------
108// Errors
109// --------------------------------------------------------------------------
110
111/// Failure deriving an [`OutputPlan`] — the output-side mirror of the loader
112/// seam's errors. Every variant names the offending variable or dimension:
113/// a mislabeled axis is silently wrong science, so these fail loudly.
114#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
115pub enum OutputError {
116 /// A scalar (bracket-free) cell key appeared more than once.
117 #[error(
118 "scalar variable '{base}' maps to {count} flat indices; a scalar cell key must be unique"
119 )]
120 DuplicateScalar { base: String, count: usize },
121
122 /// Cells of one variable disagreed on how many axes the variable has.
123 #[error(
124 "variable '{base}' has cells of differing dimensionality ({got} vs {expected}); a gridded variable must be rectangular"
125 )]
126 RaggedRank {
127 base: String,
128 got: usize,
129 expected: usize,
130 },
131
132 /// A cell key carried a 0 (or negative) index; cell keys are 1-based.
133 #[error("variable '{base}' has cell index 0 on axis {axis}; cell keys are 1-based")]
134 ZeroIndex { base: String, axis: usize },
135
136 /// The variable's cells do not cover its grid.
137 #[error(
138 "variable '{base}' supplies {got} cell(s) for a {shape:?} grid of {want}; a gridded output variable must be dense"
139 )]
140 SparseGrid {
141 base: String,
142 shape: Vec<usize>,
143 got: usize,
144 want: usize,
145 },
146
147 /// Two flat slots claimed the same grid cell.
148 #[error("variable '{base}' maps two flat slots to grid cell {cell:?}")]
149 DuplicateCell { base: String, cell: Vec<usize> },
150
151 /// The document's declared `shape` has a different rank than the flat state.
152 #[error(
153 "output metadata for '{base}' declares {declared} dim(s) {dims:?}, but the flat state grids it to {actual} axis/axes {shape:?}"
154 )]
155 DimCountMismatch {
156 base: String,
157 dims: Vec<String>,
158 declared: usize,
159 shape: Vec<usize>,
160 actual: usize,
161 },
162
163 /// A declared index set's length disagrees with the recovered axis length.
164 #[error(
165 "output metadata: '{base}' axis {axis} is index set '{dim}' of size {size}, but the flat state grids that axis to length {len}"
166 )]
167 DimLengthMismatch {
168 base: String,
169 axis: usize,
170 dim: String,
171 size: usize,
172 len: usize,
173 },
174
175 /// Two variables on one grid disagree about a shared dimension's length.
176 #[error(
177 "dimension '{dim}' has length {a} on variable '{first}' but length {b} on variable '{second}'"
178 )]
179 DimLengthConflict {
180 dim: String,
181 first: String,
182 a: usize,
183 second: String,
184 b: usize,
185 },
186
187 /// A spatial dimension collides with the record (time) axis name.
188 #[error(
189 "variable '{base}' has a spatial dimension named '{dim}', which collides with the record axis"
190 )]
191 TimeDimCollision { base: String, dim: String },
192
193 /// A `coordinates` entry's inline `values` length does not match its axis.
194 #[error("coordinate '{name}' supplies {got} value(s) but dimension '{name}' has length {want}")]
195 CoordLengthMismatch {
196 name: String,
197 got: usize,
198 want: usize,
199 },
200
201 /// The caller asked for an observed field that has no slot in the flat
202 /// state. Silently dropping a requested output is worse than refusing.
203 #[error(
204 "requested observed output '{name}' has no slot in the flat state; the runner must evaluate and append it before the plan is derived"
205 )]
206 UnknownObserved { name: String },
207
208 /// A requested output names no variable exactly, and its last dotted
209 /// segment is shared by more than one variable, so it designates none of
210 /// them (CONFORMANCE_SPEC §5.17.4). Code `ambiguous_output_name`.
211 #[error(
212 "requested output '{name}' names no variable exactly, and its last segment is shared by {candidates:?}; name one of them in full"
213 )]
214 AmbiguousRequest {
215 name: String,
216 candidates: Vec<String>,
217 },
218
219 /// A scatter/gather call was handed a buffer of the wrong length.
220 #[error("variable '{base}': {what} buffer holds {got} element(s), need {want}")]
221 BufferLength {
222 base: String,
223 what: &'static str,
224 got: usize,
225 want: usize,
226 },
227}
228
229impl OutputError {
230 /// The registered diagnostic code (a value of [`crate::ERROR_CODES`]) for
231 /// the variants that carry one.
232 #[must_use]
233 pub fn code(&self) -> Option<&'static str> {
234 match self {
235 Self::AmbiguousRequest { .. } => Some(crate::diagnostic::codes::AMBIGUOUS_OUTPUT_NAME),
236 _ => None,
237 }
238 }
239}
240
241// --------------------------------------------------------------------------
242// §7 — the flat→gridded inversion
243// --------------------------------------------------------------------------
244
245/// Split a flat state-element name into `(base, 1-based indices)`, or `None`
246/// when it is not a well-formed cell key (no bracket suffix, an empty base
247/// name, or a non-integer index). The exact inverse of the `"{name}[{i},{j}]"`
248/// formatting in `simulate_array::compile::build_slot_tables`, and the Rust
249/// twin of Julia's `_parse_cell_key` / Python's equivalent.
250///
251/// Matches the Julia regex `^([^\[]+)\[([0-9]+(?:,[0-9]+)*)\]$`: the base name
252/// may not itself contain `[`, so the FIRST bracket opens the index list.
253///
254/// ```
255/// use earthsci_ast::parse_cell_key;
256/// assert_eq!(parse_cell_key("u[2,3]"), Some(("u", vec![2, 3])));
257/// assert_eq!(parse_cell_key("Model.u[7]"), Some(("Model.u", vec![7])));
258/// assert_eq!(parse_cell_key("plain"), None);
259/// ```
260pub fn parse_cell_key(key: &str) -> Option<(&str, Vec<usize>)> {
261 let open = key.find('[')?;
262 if open == 0 {
263 return None; // empty variable name
264 }
265 let inner = key.strip_suffix(']')?.get(open + 1..)?;
266 if inner.is_empty() {
267 return None;
268 }
269 let mut indices = Vec::new();
270 for part in inner.split(',') {
271 if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) {
272 return None;
273 }
274 indices.push(part.parse::<usize>().ok()?);
275 }
276 Some((&key[..open], indices))
277}
278
279/// The gridded layout of one output base-variable, derived from the flat
280/// state-element names (RFC §7).
281///
282/// The inversion is baked into [`Self::flat_indices`]: it is indexed by
283/// **row-major (C-order) offset** within [`Self::shape`] and holds the
284/// *column-major* flat state index that feeds that cell. Scattering is
285/// therefore a single linear pass with the transposition already applied —
286/// `out[o] = u[flat_indices[o]]` — which is exactly the layout
287/// `earthsciio::format::WriteVar::data` requires.
288#[derive(Debug, Clone, PartialEq, Eq)]
289pub struct VarGridding {
290 /// The variable's base name — the cell key minus its `[…]` suffix, so
291 /// possibly namespaced (`"Model.u"`) depending on the build path.
292 pub base: String,
293 /// The gridded spatial shape, in cell-key axis order. **Empty** for a
294 /// scalar variable (see the module-level divergence note).
295 pub shape: Vec<usize>,
296 /// One name per axis of [`Self::shape`]: the variable's declared
297 /// `index_sets` names once [`OutputMeta`] has been applied, otherwise the
298 /// positional placeholders `"<base>_d0"`, `"<base>_d1"`, ….
299 pub dim_names: Vec<String>,
300 /// Flat state index of each grid cell, indexed by row-major offset.
301 /// Length is `shape.iter().product()` (1 for a scalar).
302 pub flat_indices: Vec<usize>,
303}
304
305impl VarGridding {
306 /// Number of grid cells (1 for a scalar).
307 #[must_use]
308 pub fn n_cells(&self) -> usize {
309 self.flat_indices.len()
310 }
311
312 /// True when the variable has no spatial axes.
313 #[must_use]
314 pub fn is_scalar(&self) -> bool {
315 self.shape.is_empty()
316 }
317
318 /// The 0-based grid position of the cell at row-major offset `cell`.
319 /// The Rust counterpart of Julia's `VarGridding.cart` entries (which are
320 /// 1-based `CartesianIndex`es).
321 #[must_use]
322 pub fn cart(&self, cell: usize) -> Vec<usize> {
323 let mut out = vec![0usize; self.shape.len()];
324 let mut rem = cell;
325 for d in (0..self.shape.len()).rev() {
326 let n = self.shape[d].max(1);
327 out[d] = rem % n;
328 rem /= n;
329 }
330 out
331 }
332
333 /// Scatter one flat state slab into `out`, a row-major array of
334 /// [`Self::shape`]. The Rust twin of Julia's `scatter_grid!`.
335 ///
336 /// # Errors
337 /// [`OutputError::BufferLength`] when `out` is not exactly `n_cells()`
338 /// long, or `flat_u` does not reach one of the variable's slots.
339 pub fn scatter(&self, flat_u: &[f64], out: &mut [f64]) -> Result<(), OutputError> {
340 self.scatter_record(flat_u, out, 0)
341 }
342
343 /// Scatter one flat state slab into record `record` of a
344 /// `[time, …spatial]` row-major buffer — the whole-trajectory form, where
345 /// `out` holds `n_records * n_cells()` values and record `r` occupies
346 /// `out[r * n_cells() .. (r + 1) * n_cells()]`.
347 ///
348 /// # Errors
349 /// [`OutputError::BufferLength`] when the record does not fit in `out`, or
350 /// `flat_u` does not reach one of the variable's slots.
351 pub fn scatter_record(
352 &self,
353 flat_u: &[f64],
354 out: &mut [f64],
355 record: usize,
356 ) -> Result<(), OutputError> {
357 let n = self.n_cells();
358 let start = record * n;
359 let have = out.len();
360 let slot = out
361 .get_mut(start..start + n)
362 .ok_or_else(|| OutputError::BufferLength {
363 base: self.base.clone(),
364 what: "gridded output",
365 got: have,
366 want: start + n,
367 })?;
368 for (o, &fi) in self.flat_indices.iter().enumerate() {
369 slot[o] = *flat_u.get(fi).ok_or_else(|| self.short_state(flat_u, fi))?;
370 }
371 Ok(())
372 }
373
374 /// The inverse of [`Self::scatter`] — read a gridded slab back into the
375 /// flat state (the checkpoint-restart direction, RFC §10). The Rust twin of
376 /// Julia's `gather_flat!`.
377 ///
378 /// # Errors
379 /// [`OutputError::BufferLength`] when `grid` is not `n_cells()` long or
380 /// `flat_u` does not reach one of the variable's slots.
381 pub fn gather(&self, grid: &[f64], flat_u: &mut [f64]) -> Result<(), OutputError> {
382 if grid.len() != self.n_cells() {
383 return Err(OutputError::BufferLength {
384 base: self.base.clone(),
385 what: "gridded input",
386 got: grid.len(),
387 want: self.n_cells(),
388 });
389 }
390 for (o, &fi) in self.flat_indices.iter().enumerate() {
391 let have = flat_u.len();
392 let cell = flat_u.get_mut(fi).ok_or(OutputError::BufferLength {
393 base: self.base.clone(),
394 what: "flat state",
395 got: have,
396 want: fi + 1,
397 })?;
398 *cell = grid[o];
399 }
400 Ok(())
401 }
402
403 fn short_state(&self, flat_u: &[f64], fi: usize) -> OutputError {
404 OutputError::BufferLength {
405 base: self.base.clone(),
406 what: "flat state",
407 got: flat_u.len(),
408 want: fi + 1,
409 }
410 }
411}
412
413/// Scatter a flat state slab into a row-major gridded buffer. Free-function
414/// form of [`VarGridding::scatter`], mirroring Julia's `scatter_grid!`.
415///
416/// # Errors
417/// See [`VarGridding::scatter`].
418pub fn scatter(g: &VarGridding, flat_u: &[f64], out: &mut [f64]) -> Result<(), OutputError> {
419 g.scatter(flat_u, out)
420}
421
422/// Read a row-major gridded buffer back into the flat state. Free-function
423/// form of [`VarGridding::gather`], mirroring Julia's `gather_flat!`.
424///
425/// # Errors
426/// See [`VarGridding::gather`].
427pub fn gather(g: &VarGridding, grid: &[f64], flat_u: &mut [f64]) -> Result<(), OutputError> {
428 g.gather(grid, flat_u)
429}
430
431/// Invert the flat state-element names into one [`VarGridding`] per output
432/// base-variable (RFC §7), the flat index of a name being its position in
433/// `slot_names` — the shape `Solution::state_variable_names` already has.
434///
435/// Base-variable order is sorted by base name, matching Julia, so a derived
436/// plan is reproducible and cross-language comparable.
437///
438/// # Errors
439/// See [`OutputError`]: a ragged rank, a duplicated scalar key, a 0 index, or
440/// a grid the cells do not densely cover.
441pub fn derive_output_gridding(slot_names: &[String]) -> Result<Vec<VarGridding>, OutputError> {
442 derive_output_gridding_from_map(slot_names.iter().enumerate().map(|(i, n)| (n.as_str(), i)))
443}
444
445/// [`derive_output_gridding`] over an explicit `(name, flat index)` mapping —
446/// the direct analog of Julia's `derive_output_gridding(var_map)`, for callers
447/// whose flat index is not the position in a slice.
448///
449/// # Errors
450/// See [`derive_output_gridding`].
451pub fn derive_output_gridding_from_map<'a, I>(entries: I) -> Result<Vec<VarGridding>, OutputError>
452where
453 I: IntoIterator<Item = (&'a str, usize)>,
454{
455 // base => [(flat index, 1-based cell indices)]
456 let mut groups: BTreeMap<String, Vec<(usize, Vec<usize>)>> = BTreeMap::new();
457 for (key, idx) in entries {
458 let (base, inds) = match parse_cell_key(key) {
459 Some((b, i)) => (b.to_string(), i),
460 None => (key.to_string(), Vec::new()),
461 };
462 groups.entry(base).or_default().push((idx, inds));
463 }
464
465 // BTreeMap iteration is sorted by base name — Julia's `sort!(collect(keys))`.
466 groups
467 .into_iter()
468 .map(|(base, entries)| grid_one_variable(base, entries))
469 .collect()
470}
471
472fn grid_one_variable(
473 base: String,
474 entries: Vec<(usize, Vec<usize>)>,
475) -> Result<VarGridding, OutputError> {
476 let ndim = entries.iter().map(|(_, i)| i.len()).max().unwrap_or(0);
477
478 if ndim == 0 {
479 if entries.len() != 1 {
480 return Err(OutputError::DuplicateScalar {
481 base,
482 count: entries.len(),
483 });
484 }
485 let flat = entries[0].0;
486 return Ok(VarGridding {
487 base,
488 shape: Vec::new(),
489 dim_names: Vec::new(),
490 flat_indices: vec![flat],
491 });
492 }
493
494 let mut shape = vec![0usize; ndim];
495 for (_, inds) in &entries {
496 if inds.len() != ndim {
497 return Err(OutputError::RaggedRank {
498 base,
499 got: inds.len(),
500 expected: ndim,
501 });
502 }
503 for (d, &i) in inds.iter().enumerate() {
504 if i == 0 {
505 return Err(OutputError::ZeroIndex { base, axis: d });
506 }
507 shape[d] = shape[d].max(i);
508 }
509 }
510
511 let total: usize = shape.iter().product();
512 if entries.len() != total {
513 return Err(OutputError::SparseGrid {
514 base,
515 shape,
516 got: entries.len(),
517 want: total,
518 });
519 }
520
521 // The inversion itself: place each flat slot at its ROW-MAJOR offset, so
522 // the resulting vector is already in the C order a Zarr writer wants while
523 // the flat state it reads from stays column-major.
524 let mut flat_indices = vec![usize::MAX; total];
525 for (flat, inds) in &entries {
526 let mut off = 0usize;
527 for (d, &i) in inds.iter().enumerate() {
528 off = off * shape[d] + (i - 1);
529 }
530 if flat_indices[off] != usize::MAX {
531 return Err(OutputError::DuplicateCell {
532 base,
533 cell: inds.clone(),
534 });
535 }
536 flat_indices[off] = *flat;
537 }
538
539 let dim_names = (0..ndim).map(|d| format!("{base}_d{d}")).collect();
540 Ok(VarGridding {
541 base,
542 shape,
543 dim_names,
544 flat_indices,
545 })
546}
547
548// --------------------------------------------------------------------------
549// §7/§8 — document-derived output metadata
550// --------------------------------------------------------------------------
551
552/// The document slice a streaming writer needs so it can name axes and emit CF
553/// coordinates (RFC §7–§8) without importing the document model. Distilled from
554/// an [`EsmFile`] by [`derive_output_meta`]; the Rust mirror of Julia's
555/// `OutputMeta`.
556///
557/// The `var_*` maps are keyed by BOTH the bare variable name and its
558/// `Model.`-qualified form, because a flat state's element names are namespaced
559/// on the coupled/flattened path and bare on the single-model path. A bare key
560/// that two models would make ambiguous is dropped, so an ambiguous lookup
561/// falls back to positional axis names rather than guessing.
562#[derive(Debug, Clone, Default, PartialEq)]
563pub struct OutputMeta {
564 /// Model keys present in the document, sorted.
565 pub model_names: Vec<String>,
566 /// Index-set name → axis length. `0` marks a length only the build knows
567 /// (`derived` / `ragged` sets), which callers treat as "unconstrained".
568 pub index_sets: BTreeMap<String, usize>,
569 /// Variable name → its declared `shape` (ordered index-set names). Absent
570 /// for scalars.
571 pub var_dims: BTreeMap<String, Vec<String>>,
572 /// Variable name → retained CF attributes (`units`, `description`).
573 pub var_attrs: BTreeMap<String, JsonMap<String, JsonValue>>,
574 /// Variable name → DECLARED kind (`unknown` / `parameter`, esm-spec §6.3).
575 pub var_types: BTreeMap<String, VariableType>,
576 /// The names that are OBSERVED unknowns, keyed exactly as `var_types` is,
577 /// so observed fields can be gated on the caller's request list (RFC
578 /// decision 8).
579 ///
580 /// Derived through [`crate::classification::observed_unknowns`], not read
581 /// off a declared type: from esm 1.0.0 "observed" is a property of the
582 /// equation that defines the unknown, and the declaration says only
583 /// `unknown`.
584 pub observed_unknowns: std::collections::BTreeSet<String>,
585 /// The additive document-scoped `coordinates` registry (§8.3), verbatim.
586 pub coordinates: BTreeMap<String, Coordinate>,
587 /// Record-axis name: `domain.independent_variable`, else `"time"`.
588 pub time_dim: String,
589}
590
591impl OutputMeta {
592 /// Look a flat base name up in one of the `var_*` tables: exact first, then
593 /// the last dotted segment (a nested namespace the exact keys do not
594 /// cover).
595 fn lookup<'m, T>(&self, table: &'m BTreeMap<String, T>, base: &str) -> Option<&'m T> {
596 if let Some(v) = table.get(base) {
597 return Some(v);
598 }
599 let bare = base.rsplit('.').next()?;
600 if bare == base {
601 return None;
602 }
603 table.get(bare)
604 }
605
606 /// The declared dimension names of `base`, if the document has them.
607 #[must_use]
608 pub fn dims_of(&self, base: &str) -> Option<&Vec<String>> {
609 self.lookup(&self.var_dims, base)
610 }
611
612 /// The declared kind of `base`, if the document has it.
613 #[must_use]
614 pub fn type_of(&self, base: &str) -> Option<&VariableType> {
615 self.lookup(&self.var_types, base)
616 }
617
618 /// Whether `base` is an OBSERVED unknown (esm-spec §6.3.1), by the same
619 /// exact-then-last-segment lookup the other tables use.
620 #[must_use]
621 pub fn is_observed(&self, base: &str) -> bool {
622 if self.observed_unknowns.contains(base) {
623 return true;
624 }
625 match base.rsplit('.').next() {
626 Some(bare) if bare != base => self.observed_unknowns.contains(bare),
627 _ => false,
628 }
629 }
630
631 /// The retained CF attributes of `base`.
632 #[must_use]
633 pub fn attrs_of(&self, base: &str) -> Option<&JsonMap<String, JsonValue>> {
634 self.lookup(&self.var_attrs, base)
635 }
636}
637
638/// Static axis length of an index set: `size` for `interval`, member count for
639/// `categorical`, and `size` (or 0 = "known only after the build") otherwise.
640fn index_set_axis_len(is: &IndexSet) -> usize {
641 match is.kind.as_str() {
642 "categorical" => is.members.as_ref().map_or(0, Vec::len),
643 // `interval` and anything else fall back to a declared `size`.
644 _ => usize::try_from(is.size.unwrap_or(0)).unwrap_or(0),
645 }
646}
647
648/// Distill a run document into [`OutputMeta`] — the Rust mirror of Julia's
649/// `derive_output_meta`. Reads only the document (no build artifacts), so it
650/// composes with [`derive_output_gridding`].
651#[must_use]
652pub fn derive_output_meta(doc: &EsmFile) -> OutputMeta {
653 let mut meta = OutputMeta {
654 time_dim: doc
655 .domain
656 .as_ref()
657 .and_then(|d| d.independent_variable.clone())
658 .unwrap_or_else(|| DEFAULT_TIME_DIM.to_string()),
659 ..OutputMeta::default()
660 };
661
662 if let Some(sets) = &doc.index_sets {
663 for (name, is) in sets {
664 meta.index_sets.insert(name.clone(), index_set_axis_len(is));
665 }
666 }
667
668 if let Some(coords) = &doc.coordinates {
669 for (name, c) in coords {
670 meta.coordinates.insert(name.clone(), c.clone());
671 }
672 }
673
674 let Some(models) = &doc.models else {
675 return meta;
676 };
677 meta.model_names = models.keys().cloned().collect();
678 meta.model_names.sort();
679
680 // Bare names claimed by more than one model are ambiguous; only the
681 // qualified key survives for those.
682 let mut bare_counts: BTreeMap<&str, usize> = BTreeMap::new();
683 for model in models.values() {
684 for vn in model.variables.keys() {
685 *bare_counts.entry(vn.as_str()).or_insert(0) += 1;
686 }
687 }
688
689 for mname in &meta.model_names {
690 let model = &models[mname];
691 // Which unknowns are OBSERVED is derived from this model's equations
692 // (esm-spec §6.3.1), once per model.
693 let class = crate::classification::Classification::of(model);
694 let mut vnames: Vec<&String> = model.variables.keys().collect();
695 vnames.sort();
696 for vn in vnames {
697 let v = &model.variables[vn];
698 let qualified = format!("{mname}.{vn}");
699 let ambiguous = bare_counts.get(vn.as_str()).copied().unwrap_or(0) > 1;
700
701 let mut keys: Vec<String> = vec![qualified];
702 if !ambiguous {
703 keys.push(vn.clone());
704 }
705
706 let mut attrs = JsonMap::new();
707 if let Some(u) = &v.units {
708 attrs.insert("units".to_string(), JsonValue::from(u.clone()));
709 }
710 if let Some(d) = &v.description {
711 attrs.insert("description".to_string(), JsonValue::from(d.clone()));
712 }
713
714 for key in keys {
715 if let Some(shape) = &v.shape {
716 meta.var_dims.insert(key.clone(), shape.clone());
717 }
718 if !attrs.is_empty() {
719 meta.var_attrs.insert(key.clone(), attrs.clone());
720 }
721 if class.is_observed(vn) {
722 meta.observed_unknowns.insert(key.clone());
723 }
724 meta.var_types.insert(key, v.var_type);
725 }
726 }
727 }
728
729 meta
730}
731
732/// Rename one variable's axes from the positional `"<base>_d0"` placeholders to
733/// its REAL declared dim names (RFC §7 → §8) — the Rust mirror of Julia's
734/// `_name_gridding`. A variable the document does not describe keeps its
735/// positional names.
736///
737/// # Errors
738/// [`OutputError::DimCountMismatch`] / [`OutputError::DimLengthMismatch`] when
739/// the document and the built state disagree about a variable's shape. A
740/// mismatch must fail loudly rather than mislabel an axis.
741pub fn apply_output_meta(g: VarGridding, meta: &OutputMeta) -> Result<VarGridding, OutputError> {
742 let Some(dims) = meta.dims_of(&g.base) else {
743 return Ok(g); // scalar / undeclared → positional
744 };
745 if dims.len() != g.shape.len() {
746 let actual = g.shape.len();
747 return Err(OutputError::DimCountMismatch {
748 base: g.base,
749 dims: dims.clone(),
750 declared: dims.len(),
751 shape: g.shape,
752 actual,
753 });
754 }
755 for (d, dim) in dims.iter().enumerate() {
756 if let Some(&size) = meta.index_sets.get(dim)
757 && size != 0
758 && size != g.shape[d]
759 {
760 return Err(OutputError::DimLengthMismatch {
761 base: g.base,
762 axis: d,
763 dim: dim.clone(),
764 size,
765 len: g.shape[d],
766 });
767 }
768 }
769 Ok(VarGridding {
770 dim_names: dims.clone(),
771 ..g
772 })
773}
774
775/// [`derive_output_gridding`] followed by [`apply_output_meta`] on every
776/// variable — the doc-aware form, mirroring Julia's two-argument
777/// `derive_output_gridding(var_map, meta)`.
778///
779/// # Errors
780/// See [`derive_output_gridding`] and [`apply_output_meta`].
781pub fn derive_output_gridding_with_meta(
782 slot_names: &[String],
783 meta: &OutputMeta,
784) -> Result<Vec<VarGridding>, OutputError> {
785 derive_output_gridding(slot_names)?
786 .into_iter()
787 .map(|g| apply_output_meta(g, meta))
788 .collect()
789}
790
791// --------------------------------------------------------------------------
792// §9 — grids are emergent
793// --------------------------------------------------------------------------
794
795/// Partition output variables into GRIDS by their spatial-dim signature
796/// (RFC §9): variables over the same set of axes form one grid, so an
797/// atmosphere over `[lev, lat, lon]` and an ocean over `[depth, lat, lon]`
798/// split by construction. The signature is the SORTED SET of a variable's
799/// `dim_names`, so axis order does not fragment a grid, and every scalar
800/// (empty signature) lands in one shared 0-D grid. Groups come back in
801/// first-seen signature order.
802#[must_use]
803pub fn group_gridding_by_grid(gridding: Vec<VarGridding>) -> Vec<Vec<VarGridding>> {
804 let mut order: Vec<Vec<String>> = Vec::new();
805 let mut groups: BTreeMap<Vec<String>, Vec<VarGridding>> = BTreeMap::new();
806 for g in gridding {
807 let sig: Vec<String> = g
808 .dim_names
809 .iter()
810 .cloned()
811 .collect::<BTreeSet<_>>()
812 .into_iter()
813 .collect();
814 if !groups.contains_key(&sig) {
815 order.push(sig.clone());
816 }
817 groups.entry(sig).or_default().push(g);
818 }
819 order
820 .into_iter()
821 .filter_map(|sig| groups.remove(&sig))
822 .collect()
823}
824
825// --------------------------------------------------------------------------
826// §8 — CF dimension coordinates
827// --------------------------------------------------------------------------
828
829/// A CF **dimension coordinate** (§8.3): a 1-D coordinate variable named
830/// exactly like its dimension, its values, and its CF attributes. Field-for-
831/// field the shape of `earthsciio::format::WriteCoord`.
832#[derive(Debug, Clone, PartialEq)]
833pub struct CoordPlan {
834 /// Coordinate (and dimension) name.
835 pub name: String,
836 /// The coordinate values, one per grid index along its dimension.
837 pub values: Vec<f64>,
838 /// CF attributes: `standard_name` / `units` / `axis`, when declared.
839 pub attrs: JsonMap<String, JsonValue>,
840}
841
842/// Resolve the `coordinates` registry (§8.3) into the CF **dimension
843/// coordinates** for a set of [`VarGridding`]s — the Rust mirror of Julia's
844/// `plan_dimension_coordinates`.
845///
846/// For each distinct spatial dim, in first-seen order, a registry entry KEYED
847/// BY THAT DIM NAME carrying inline `values` produces a [`CoordPlan`] with the
848/// entry's `standard_name` / `units` / `axis`. Entries whose key is not a dim
849/// name (CF *auxiliary* coordinates) and `source`-backed entries are skipped:
850/// they resolve against build artifacts, not the document, and are the
851/// documented next drop-in on the same registry. A dim with no entry emits no
852/// coordinate — a bare integer axis.
853///
854/// # Errors
855/// [`OutputError::CoordLengthMismatch`] when an entry's `values` length does
856/// not match the axis it names.
857pub fn plan_dimension_coordinates(
858 gridding: &[VarGridding],
859 meta: &OutputMeta,
860) -> Result<Vec<CoordPlan>, OutputError> {
861 let mut order: Vec<&str> = Vec::new();
862 let mut dimlen: BTreeMap<&str, usize> = BTreeMap::new();
863 for g in gridding {
864 for (d, name) in g.dim_names.iter().enumerate() {
865 if !dimlen.contains_key(name.as_str()) {
866 order.push(name.as_str());
867 }
868 dimlen.insert(name.as_str(), g.shape[d]);
869 }
870 }
871
872 let mut out = Vec::new();
873 for name in order {
874 let Some(entry) = meta.coordinates.get(name) else {
875 continue;
876 };
877 // v1 handles the inline-`values` dimension-coordinate path only.
878 let Some(values) = &entry.values else {
879 continue;
880 };
881 let want = dimlen[name];
882 if values.len() != want {
883 return Err(OutputError::CoordLengthMismatch {
884 name: name.to_string(),
885 got: values.len(),
886 want,
887 });
888 }
889 let mut attrs = JsonMap::new();
890 if let Some(v) = &entry.standard_name {
891 attrs.insert("standard_name".to_string(), JsonValue::from(v.clone()));
892 }
893 if let Some(v) = &entry.units {
894 attrs.insert("units".to_string(), JsonValue::from(v.clone()));
895 }
896 if let Some(v) = &entry.axis {
897 attrs.insert("axis".to_string(), JsonValue::from(v.clone()));
898 }
899 out.push(CoordPlan {
900 name: name.to_string(),
901 values: values.clone(),
902 attrs,
903 });
904 }
905 Ok(out)
906}
907
908// --------------------------------------------------------------------------
909// The plan
910// --------------------------------------------------------------------------
911
912/// One output variable: what a writer declares, plus the inversion that fills
913/// it. Field-for-field the shape of `earthsciio::format::WriteVar` once
914/// [`Self::gridding`] has scattered the trajectory into `data`.
915#[derive(Debug, Clone, PartialEq)]
916pub struct VarPlan {
917 /// On-disk variable name (the flat base name).
918 pub name: String,
919 /// Ordered on-disk dimension names: the record axis first, then the
920 /// variable's spatial axes. A scalar's dims are exactly `[time_dim]`.
921 pub dims: Vec<String>,
922 /// CF attributes retained from the document (`units`, `description`).
923 pub attrs: JsonMap<String, JsonValue>,
924 /// Element type; [`DTYPE_FLOAT64`] throughout (the flat state is `f64`).
925 pub dtype: &'static str,
926 /// The flat→gridded inversion for this variable.
927 pub gridding: VarGridding,
928}
929
930impl VarPlan {
931 /// Number of values one output record contributes (1 for a scalar).
932 #[must_use]
933 pub fn values_per_record(&self) -> usize {
934 self.gridding.n_cells()
935 }
936}
937
938/// One grid: a set of variables sharing a spatial-dim signature, with the
939/// coordinates and dimension lengths that describe it (RFC §9). The RFC's
940/// recommended decomposition is one Sink — one store or group — per grid.
941///
942/// Converting to `earthsciio::format::OutputSchema` is mechanical:
943/// `OutputSchema::dims` is `[(time_dim, n_records)]` followed by
944/// [`Self::dims`]; `coords` is [`Self::coords`] plus a `WriteCoord` for the
945/// time axis built from the output times; `vars` is one `WriteVar` per
946/// [`VarPlan`] whose `data` is [`VarPlan::gridding`] scattered across every
947/// record. The record count is deliberately NOT part of the plan: a plan is
948/// derived once and stays valid however many records get written.
949#[derive(Debug, Clone, PartialEq)]
950pub struct GridPlan {
951 /// Ordered SPATIAL dims of this grid, `name → length`. Empty for the
952 /// scalar grid. The record axis is not included; see [`Self::time_dim`].
953 pub dims: Vec<(String, usize)>,
954 /// The growable record-axis name (`domain.independent_variable`).
955 pub time_dim: String,
956 /// CF dimension coordinates for this grid's axes (possibly empty).
957 pub coords: Vec<CoordPlan>,
958 /// The grid's output variables, ordered by name.
959 pub vars: Vec<VarPlan>,
960}
961
962impl GridPlan {
963 /// Length of `dim` on this grid.
964 #[must_use]
965 pub fn dim_len(&self, dim: &str) -> Option<usize> {
966 self.dims
967 .iter()
968 .find(|(n, _)| n == dim)
969 .map(|&(_, len)| len)
970 }
971}
972
973/// The full derived output plan: every grid the run produces.
974#[derive(Debug, Clone, PartialEq)]
975pub struct OutputPlan {
976 /// One entry per emergent grid, in first-seen signature order.
977 pub grids: Vec<GridPlan>,
978}
979
980impl OutputPlan {
981 /// Total number of output variables across every grid.
982 #[must_use]
983 pub fn n_vars(&self) -> usize {
984 self.grids.iter().map(|g| g.vars.len()).sum()
985 }
986
987 /// Find a variable by name across the grids.
988 #[must_use]
989 pub fn var(&self, name: &str) -> Option<&VarPlan> {
990 self.grids
991 .iter()
992 .find_map(|g| g.vars.iter().find(|v| v.name == name))
993 }
994}
995
996/// Derive the whole output plan for a run.
997///
998/// * `doc` — the (flattened) run document: axis names, axis lengths, CF
999/// attributes, and the `coordinates` registry.
1000/// * `slot_names` — the flat state-element names, index = flat index. This is
1001/// `Solution::state_variable_names` / `ArrayCompiled::state_variable_names`,
1002/// including any observed rows the runner appended.
1003/// * `observed` — the caller-named observed/derived fields to write alongside
1004/// the state (RFC decision 8). Output is state PLUS these, not every observed
1005/// field. A name selects the variable it names exactly; failing that, the
1006/// ONE variable whose last dotted segment equals the name's (so `flux`
1007/// selects `Box.flux`). A name an `operator_compose` merge deleted is NOT
1008/// resolved here: the caller resolves it through `merged_variable_renames`
1009/// first (CONFORMANCE_SPEC §5.17.4).
1010///
1011/// # Errors
1012/// [`OutputError::UnknownObserved`] when a requested observed field has no slot
1013/// in `slot_names`; [`OutputError::AmbiguousRequest`] when a request names no
1014/// variable exactly and its last segment is shared; otherwise see [`derive_output_gridding`],
1015/// [`apply_output_meta`] and [`plan_dimension_coordinates`].
1016pub fn derive_output_plan(
1017 doc: &EsmFile,
1018 slot_names: &[String],
1019 observed: &[String],
1020) -> Result<OutputPlan, OutputError> {
1021 let meta = derive_output_meta(doc);
1022 let gridding = derive_output_gridding_with_meta(slot_names, &meta)?;
1023
1024 // RFC decision 8: state is always written; an observed field only when the
1025 // caller named it. A requested name that designates no single variable is an
1026 // error — silently dropping, or silently choosing, a requested output is the
1027 // failure mode this refuses.
1028 let bases: Vec<&str> = gridding.iter().map(|g| g.base.as_str()).collect();
1029 let mut requested: HashSet<String> = HashSet::new();
1030 for name in observed {
1031 match match_output_request(name, &bases) {
1032 RequestMatch::Named(base) => {
1033 requested.insert(base.to_string());
1034 }
1035 RequestMatch::Ambiguous(candidates) => {
1036 return Err(OutputError::AmbiguousRequest {
1037 name: name.clone(),
1038 candidates: candidates.into_iter().map(str::to_string).collect(),
1039 });
1040 }
1041 RequestMatch::Missing => {
1042 return Err(OutputError::UnknownObserved { name: name.clone() });
1043 }
1044 }
1045 }
1046 let kept: Vec<VarGridding> = gridding
1047 .into_iter()
1048 .filter(|g| !meta.is_observed(&g.base) || requested.contains(&g.base))
1049 .collect();
1050
1051 let mut grids = Vec::new();
1052 for group in group_gridding_by_grid(kept) {
1053 grids.push(build_grid_plan(group, &meta)?);
1054 }
1055 Ok(OutputPlan { grids })
1056}
1057
1058/// What an output request designates among `candidates`.
1059pub(crate) enum RequestMatch<'a> {
1060 /// The one candidate the request names.
1061 Named(&'a str),
1062 /// No candidate is named exactly, and these (sorted) share the request's
1063 /// last dotted segment.
1064 Ambiguous(Vec<&'a str>),
1065 /// Nothing matches.
1066 Missing,
1067}
1068
1069/// The output-request rule of CONFORMANCE_SPEC §5.17.4: the candidate the
1070/// request names exactly; failing that, the ONE candidate whose last dotted
1071/// segment equals the request's. Mirrors Julia's `_resolve_output_requests`.
1072pub(crate) fn match_output_request<'a>(request: &str, candidates: &[&'a str]) -> RequestMatch<'a> {
1073 if let Some(exact) = candidates.iter().find(|c| **c == request) {
1074 return RequestMatch::Named(exact);
1075 }
1076 let last_segment = |n: &str| n.rsplit('.').next().unwrap_or(n).to_string();
1077 let tail = last_segment(request);
1078 let mut hits: Vec<&'a str> = candidates
1079 .iter()
1080 .copied()
1081 .filter(|c| last_segment(c) == tail)
1082 .collect();
1083 hits.sort_unstable();
1084 hits.dedup();
1085 match hits.len() {
1086 0 => RequestMatch::Missing,
1087 1 => RequestMatch::Named(hits[0]),
1088 _ => RequestMatch::Ambiguous(hits),
1089 }
1090}
1091
1092fn build_grid_plan(group: Vec<VarGridding>, meta: &OutputMeta) -> Result<GridPlan, OutputError> {
1093 // Dimensions first: a length conflict between two variables on one grid is
1094 // the more fundamental error, so it must win over a coordinate that merely
1095 // disagrees with one of the two.
1096 let mut dims: Vec<(String, usize)> = Vec::new();
1097 let mut owner: BTreeMap<String, String> = BTreeMap::new();
1098 for g in &group {
1099 for (d, name) in g.dim_names.iter().enumerate() {
1100 if name == &meta.time_dim {
1101 return Err(OutputError::TimeDimCollision {
1102 base: g.base.clone(),
1103 dim: name.clone(),
1104 });
1105 }
1106 match dims.iter().find(|(n, _)| n == name) {
1107 Some(&(_, len)) if len != g.shape[d] => {
1108 return Err(OutputError::DimLengthConflict {
1109 dim: name.clone(),
1110 first: owner[name].clone(),
1111 a: len,
1112 second: g.base.clone(),
1113 b: g.shape[d],
1114 });
1115 }
1116 Some(_) => {}
1117 None => {
1118 dims.push((name.clone(), g.shape[d]));
1119 owner.insert(name.clone(), g.base.clone());
1120 }
1121 }
1122 }
1123 }
1124
1125 let coords = plan_dimension_coordinates(&group, meta)?;
1126
1127 let vars = group
1128 .into_iter()
1129 .map(|g| {
1130 let mut var_dims = Vec::with_capacity(g.dim_names.len() + 1);
1131 var_dims.push(meta.time_dim.clone());
1132 var_dims.extend(g.dim_names.iter().cloned());
1133 VarPlan {
1134 name: g.base.clone(),
1135 dims: var_dims,
1136 attrs: meta.attrs_of(&g.base).cloned().unwrap_or_default(),
1137 dtype: DTYPE_FLOAT64,
1138 gridding: g,
1139 }
1140 })
1141 .collect();
1142
1143 Ok(GridPlan {
1144 dims,
1145 time_dim: meta.time_dim.clone(),
1146 coords,
1147 vars,
1148 })
1149}
1150
1151// --------------------------------------------------------------------------
1152// Tests
1153// --------------------------------------------------------------------------
1154
1155#[cfg(test)]
1156mod tests {
1157 use super::*;
1158
1159 fn names(v: &[&str]) -> Vec<String> {
1160 v.iter().map(|s| (*s).to_string()).collect()
1161 }
1162
1163 /// Column-major slot names for `shape`, exactly as
1164 /// `simulate_array::compile::build_slot_tables` emits them (dim 0 fastest).
1165 fn col_major_slots(base: &str, shape: &[usize]) -> Vec<String> {
1166 let total: usize = shape.iter().product();
1167 (0..total)
1168 .map(|flat| {
1169 let mut rem = flat;
1170 let mut idx = Vec::with_capacity(shape.len());
1171 for &n in shape {
1172 idx.push((rem % n) + 1);
1173 rem /= n;
1174 }
1175 let joined: Vec<String> = idx.iter().map(usize::to_string).collect();
1176 format!("{base}[{}]", joined.join(","))
1177 })
1178 .collect()
1179 }
1180
1181 #[test]
1182 fn parse_cell_key_round_trips_and_rejects() {
1183 assert_eq!(parse_cell_key("u[3]"), Some(("u", vec![3])));
1184 assert_eq!(parse_cell_key("u[2,3]"), Some(("u", vec![2, 3])));
1185 assert_eq!(parse_cell_key("M.u[1,2,3]"), Some(("M.u", vec![1, 2, 3])));
1186 // Not cell keys.
1187 assert_eq!(parse_cell_key("u"), None);
1188 assert_eq!(parse_cell_key("u[]"), None);
1189 assert_eq!(parse_cell_key("[1]"), None);
1190 assert_eq!(parse_cell_key("u[a]"), None);
1191 assert_eq!(parse_cell_key("u[1,]"), None);
1192 assert_eq!(parse_cell_key("u[1"), None);
1193 // The Julia regex forbids `[` in the base name, so the FIRST bracket
1194 // opens the index list and a doubled suffix is not a cell key.
1195 assert_eq!(parse_cell_key("u[1][2]"), None);
1196 }
1197
1198 /// The load-bearing test: a NON-SQUARE 2-D grid, where a row/column-major
1199 /// mix-up is actually visible (a square grid passes a transposed
1200 /// implementation). `c` lives on a 3x2 grid; the flat state is column-major
1201 /// (dim 0 fastest) and the output must be row-major (dim 1 fastest).
1202 #[test]
1203 fn inversion_transposes_a_non_square_2d_grid() {
1204 let slots = col_major_slots("c", &[3, 2]);
1205 assert_eq!(
1206 slots,
1207 names(&["c[1,1]", "c[2,1]", "c[3,1]", "c[1,2]", "c[2,2]", "c[3,2]"])
1208 );
1209
1210 let g = &derive_output_gridding(&slots).unwrap()[0];
1211 assert_eq!(g.shape, vec![3, 2]);
1212 assert_eq!(g.dim_names, names(&["c_d0", "c_d1"]));
1213 // Row-major offset o holds the COLUMN-major flat index of that cell.
1214 // A transposed implementation would produce [0, 1, 2, 3, 4, 5].
1215 assert_eq!(g.flat_indices, vec![0, 3, 1, 4, 2, 5]);
1216
1217 // Encode the cell in the value: u at (i, j) = 10 * i + j (1-based).
1218 let mut u = vec![0.0; 6];
1219 for (flat, name) in slots.iter().enumerate() {
1220 let (_, idx) = parse_cell_key(name).unwrap();
1221 u[flat] = (10 * idx[0] + idx[1]) as f64;
1222 }
1223 let mut out = vec![0.0; 6];
1224 g.scatter(&u, &mut out).unwrap();
1225 assert_eq!(out, vec![11.0, 12.0, 21.0, 22.0, 31.0, 32.0]);
1226
1227 // ... and the C-order indexing arithmetic agrees cell by cell.
1228 for i in 0..3 {
1229 for j in 0..2 {
1230 assert_eq!(out[i * 2 + j], (10 * (i + 1) + (j + 1)) as f64);
1231 }
1232 }
1233 }
1234
1235 /// Rank 3 with three DIFFERENT axis lengths: every axis permutation and
1236 /// every stride mistake changes the answer.
1237 #[test]
1238 fn inversion_handles_a_rank_3_grid_with_distinct_axis_lengths() {
1239 let shape = [2usize, 3, 4];
1240 let slots = col_major_slots("f", &shape);
1241 assert_eq!(slots.len(), 24);
1242 assert_eq!(slots[0], "f[1,1,1]");
1243 assert_eq!(slots[1], "f[2,1,1]"); // dim 0 fastest in the flat state
1244 assert_eq!(slots[2], "f[1,2,1]");
1245 assert_eq!(slots[23], "f[2,3,4]");
1246
1247 let g = &derive_output_gridding(&slots).unwrap()[0];
1248 assert_eq!(g.shape, vec![2, 3, 4]);
1249 assert_eq!(g.dim_names, names(&["f_d0", "f_d1", "f_d2"]));
1250
1251 let u: Vec<f64> = (0..24).map(|i| i as f64).collect();
1252 let mut out = vec![0.0; 24];
1253 g.scatter(&u, &mut out).unwrap();
1254
1255 // out is C-order over [2,3,4]; u is column-major over the same shape.
1256 for i in 0..2 {
1257 for j in 0..3 {
1258 for k in 0..4 {
1259 let row = (i * 3 + j) * 4 + k;
1260 let col = i + 2 * (j + 3 * k);
1261 assert_eq!(out[row], col as f64, "cell ({i},{j},{k})");
1262 assert_eq!(g.cart(row), vec![i, j, k]);
1263 }
1264 }
1265 }
1266
1267 // gather is the exact inverse (the restart-read direction).
1268 let mut back = vec![0.0; 24];
1269 g.gather(&out, &mut back).unwrap();
1270 assert_eq!(back, u);
1271 }
1272
1273 #[test]
1274 fn scalars_are_zero_dimensional_and_share_one_grid() {
1275 let slots = names(&["a", "b"]);
1276 let gs = derive_output_gridding(&slots).unwrap();
1277 assert_eq!(gs.len(), 2);
1278 for g in &gs {
1279 assert!(g.is_scalar());
1280 assert!(g.shape.is_empty());
1281 assert!(g.dim_names.is_empty());
1282 assert_eq!(g.n_cells(), 1);
1283 }
1284 assert_eq!(gs[0].flat_indices, vec![0]);
1285 assert_eq!(gs[1].flat_indices, vec![1]);
1286
1287 let groups = group_gridding_by_grid(gs);
1288 assert_eq!(groups.len(), 1, "every scalar shares the 0-D grid");
1289 assert_eq!(groups[0].len(), 2);
1290 }
1291
1292 #[test]
1293 fn variables_are_sorted_and_grouped_by_spatial_signature() {
1294 let mut slots = Vec::new();
1295 slots.extend(col_major_slots("ocn.t", &[2, 2])); // [ocn.t_d0, ocn.t_d1]
1296 slots.extend(col_major_slots("atm.q", &[3, 2]));
1297 slots.push("scalar".to_string());
1298
1299 let gs = derive_output_gridding(&slots).unwrap();
1300 let bases: Vec<&str> = gs.iter().map(|g| g.base.as_str()).collect();
1301 assert_eq!(bases, vec!["atm.q", "ocn.t", "scalar"]); // sorted by base
1302
1303 // Positional dim names are per-variable, so the two fields are two
1304 // grids and the scalar is a third.
1305 let groups = group_gridding_by_grid(gs);
1306 assert_eq!(groups.len(), 3);
1307 assert_eq!(groups[0][0].base, "atm.q");
1308 assert_eq!(groups[1][0].base, "ocn.t");
1309 assert_eq!(groups[2][0].base, "scalar");
1310 }
1311
1312 #[test]
1313 fn shared_axis_names_collapse_two_variables_into_one_grid() {
1314 // Same axes, different order: the signature is the sorted SET, so axis
1315 // order must not fragment the grid (RFC §9).
1316 let a = VarGridding {
1317 base: "a".into(),
1318 shape: vec![2, 3],
1319 dim_names: names(&["lat", "lon"]),
1320 flat_indices: (0..6).collect(),
1321 };
1322 let b = VarGridding {
1323 base: "b".into(),
1324 shape: vec![3, 2],
1325 dim_names: names(&["lon", "lat"]),
1326 flat_indices: (6..12).collect(),
1327 };
1328 let groups = group_gridding_by_grid(vec![a, b]);
1329 assert_eq!(groups.len(), 1);
1330 assert_eq!(groups[0].len(), 2);
1331 }
1332
1333 #[test]
1334 fn malformed_griddings_are_refused() {
1335 // A scalar key appearing twice.
1336 let err = derive_output_gridding_from_map([("a", 0usize), ("a", 1usize)]).unwrap_err();
1337 assert!(matches!(err, OutputError::DuplicateScalar { .. }), "{err}");
1338
1339 // Cells of differing rank.
1340 let err = derive_output_gridding(&names(&["u[1]", "u[1,2]"])).unwrap_err();
1341 assert!(matches!(err, OutputError::RaggedRank { .. }), "{err}");
1342
1343 // A hole in the grid (5 cells declared over a 3x2 extent).
1344 let err =
1345 derive_output_gridding(&names(&["u[1,1]", "u[2,1]", "u[3,1]", "u[1,2]", "u[2,2]"]))
1346 .unwrap_err();
1347 assert!(matches!(err, OutputError::SparseGrid { .. }), "{err}");
1348
1349 // A 0 index (cell keys are 1-based).
1350 let err = derive_output_gridding(&names(&["u[0]"])).unwrap_err();
1351 assert!(matches!(err, OutputError::ZeroIndex { .. }), "{err}");
1352 }
1353
1354 #[test]
1355 fn scatter_refuses_a_wrong_sized_buffer() {
1356 let slots = col_major_slots("c", &[3, 2]);
1357 let g = &derive_output_gridding(&slots).unwrap()[0];
1358 let u = vec![1.0; 6];
1359 let mut too_small = vec![0.0; 5];
1360 assert!(matches!(
1361 g.scatter(&u, &mut too_small).unwrap_err(),
1362 OutputError::BufferLength { .. }
1363 ));
1364 let mut trajectory = vec![0.0; 12];
1365 g.scatter_record(&u, &mut trajectory, 1).unwrap();
1366 assert!(matches!(
1367 g.scatter_record(&u, &mut trajectory, 2).unwrap_err(),
1368 OutputError::BufferLength { .. }
1369 ));
1370 }
1371}