Skip to content

Automatic units handling with UDUNITS inference and strict mode (closes #28) - #32

Merged
hdrake merged 9 commits into
mainfrom
units-handling
Jul 29, 2026
Merged

Automatic units handling with UDUNITS inference and strict mode (closes #28)#32
hdrake merged 9 commits into
mainfrom
units-handling

Conversation

@hdrake

@hdrake hdrake commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Automatic units handling (closes #28)

Every derived variable xbudget materializes now carries an inferred units
attribute, and an opt-in assert_unit_consistency=True mode verifies that each
budget reconciles to its declared units. Units compose through the typed tree
with real UDUNITS arithmetic via cf-units.

What's new

  • Unit inference (always on). Each operation infers its result's units from
    its inputs: a product multiplies operand units, a sum takes the common
    unit of its (dimensionally compatible) summands, a difference preserves
    units, a reciprocal inverts them, and a lateral_divergence carries the flux
    unit. Inputs' units come from each diagnostic's own units attribute; unknown
    (missing/unparseable) units propagate rather than being guessed. Results are
    stamped as a units attribute on every emitted variable.
  • A constants table. constants is now the one reserved top-level recipe
    key: shared scalars are declared once with {value:, units:} (e.g.
    seawater_density: {value: 1035., units: "kg m-3"}) and terms refer to them by
    name (density: "seawater_density"). References are substituted during
    parsing, so nothing downstream sees the table; a string operand resolves
    against it first and otherwise stays a diagnostic name. A bare number inline
    still works and stays dimensionless — only density and heat capacity needed
    annotating, since signs, salinity, and conversion factors are genuinely
    dimensionless.
  • assert_unit_consistency=True. collect_budgets(..., assert_unit_consistency=True)
    requires every budget that declares a top-level units (all shipped recipes
    now do: mass/salt → kg s-1, heat → W) to have its lhs/rhs roots infer
    units convertible to the declared ones, raising the new xbudget.UnitError
    otherwise. Left at its False default, a mismatch only warns.
  • Static inference + display. units.infer_units(node, lookup) answers the
    units question straight off the typed tree, without evaluating — which is what
    lets show_recipe(recipe, budget, data) and a notebook-displayed BudgetQuery
    annotate every node with its [units].
  • Query API. BudgetQuery.units(term) returns the stamped units of a
    materialized term; .budget_units(budget) returns the declared target.
  • Conventions. Practical salinity (psu), undefined in UDUNITS, is treated
    as dimensionless (PSS-78); offset temperature (degC) is dimensionalized to
    kelvin inside products, so heat-content terms reconcile to W.
  • cf-units is now a dependency (added to pyproject.toml and both env files).

Also on this branch

  • lateral_divergence rechunking. The chunking wrapper is now
    _lateral_divergence, and difference / lateral_divergence share
    _dataset_chunks / _single_chunk_along / _restore_chunks in evaluate.py.
    On the ECCO LLC90 budgets this is numerically identical and roughly halves
    wall clock. test_collect.py covers it on a two-tile face-connected synthetic
    grid — the first CI coverage of face-connected differencing.
  • Per-worktree conda envs documented. CLAUDE.md now requires development
    and testing in a git worktree to use a dedicated
    docs_env_xbudget_<branch-or-worktree-name> env rather than the shared
    docs_env_xbudget: an editable install binds an env to one checkout, so a
    shared env silently tests the wrong tree.

Verified

  • Full test suite green (136 → 160 with the new test_units.py; new units
    tests covering the arithmetic, the constants table, parsing, static
    inference, stamping, assert_unit_consistency pass/fail, and the query
    accessors — all CI-safe on the synthetic grid).
  • assert_unit_consistency=True passes on the real MOM6 example dataset
    all six sides reconcile (mass/salt → kg s-1, heat → W).
  • assert_unit_consistency=True passes on the real ECCO V4r4 (LLC90) dataset
    — mass/heat/salt all reconcile. The ECCO example notebooks attach units to the
    handful of derived inputs they build (dt, volcello, WVELMASS_interior,
    boundary_forcing_*); the helper functions in
    eccov4r4_budget_diagnostics.py already stamped theirs.
  • All four example notebooks re-executed with the updated engine; the three
    budget notebooks now call collect_budgets(..., assert_unit_consistency=True)
    and the derived variables display their inferred units.

Notes for review

  • The degC→K and psu→dimensionless conventions are physical choices encoded
    in xbudget/units.py — worth a look to confirm they match intent.

Scale factors: why the check is dimensional, not exact

Unit consistency is checked with is_convertible (same physical dimension),
not exact string equality. This is deliberate, and the salt budget is the
motivating case.

UDUNITS treats a leading numeric factor as a first-class part of a unit — a unit
spec is [number] [unit-expression], so 0.001, 1e-3, and 0.001 kg s-1 are
all valid units, where the number is a scale relative to the base unit. CF
encodes practical salinity exactly this way: ECCO's SALT/SALT_bounds carry
units 1e-3 (a salinity of 35 means 0.035 kg/kg), and UDUNITS knows the
conversion — Unit("1e-3").convert(35, "1") == 0.035.

Because that 1e-3 scale rides along through the salt-flux multiplication, the
ECCO salt roots infer units of 0.001 kg s-1, not a bare kg s-1. That is
the same dimension (mass/time) as kg s-1, differing only by a numeric scale
(Unit("0.001 kg s-1").is_convertible("kg s-1") is True), so the check accepts
it and the budget is confirmed to be a mass-per-time rate. The array values
are already correct SI kg/s (the recipe's explicit unit_conversion: 0.001
does the arithmetic); the 0.001 only appears in the composed unit string.

Requiring an exact kg s-1 string instead would false-fail on every legitimate
scale factor in the recipes (salinity's 1e-3, the unit_conversion: 0.001
constants), so the check is built to catch genuine dimensional errors (a term
that is kg m-2 s-1 or W when it should be kg s-1) while ignoring harmless
scale. MOM6 salinity carries psu (aliased to dimensionless "1", no scale),
so MOM6 salt infers a clean kg s-1; both models pass.

🤖 Generated with Claude Code

hdrake and others added 2 commits July 28, 2026 07:55
Every derived variable now carries an inferred `units` attribute, composed
through the typed tree with real UDUNITS arithmetic via cf-units: products
multiply units, sums require compatible summands, difference preserves,
reciprocal inverts, lateral_divergence carries the flux unit. Constants may
declare units via a {value, units} mapping; a bare number is dimensionless.

`collect_budgets(strict=True)` enforces that each budget declaring a top-level
`units` reconciles its lhs/rhs roots to it (else UnitError); non-strict warns.
BudgetQuery gains .units() and .budget_units(). All shipped recipes are
annotated and declare budget units; verified strict passes on the MOM6 example.

psu is treated as dimensionless (PSS-78); degC is dimensionalized in products
so heat-content terms reconcile to W.

Closes #28 (mechanism; ECCO pipeline units follow).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Attach UDUNITS units to the derived budget inputs the ECCO notebooks build
(dt, volcello, WVELMASS_interior, boundary_forcing_*) so unit inference has a
complete picture, and switch the three budget notebooks (MOM6 + both ECCO) to
collect_budgets(..., strict=True). Verified: strict passes on both the MOM6 and
ECCO V4r4 (LLC90) datasets; derived variables now display their inferred units.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@review-notebook-app

Copy link
Copy Markdown

Check out this pull request on  ReviewNB

See visual diffs & provide feedback on Jupyter Notebooks.


Powered by ReviewNB

@review-notebook-app

review-notebook-app Bot commented Jul 28, 2026

Copy link
Copy Markdown

View / edit / reply to this conversation on ReviewNB

hdrake commented on 2026-07-28T18:53:32Z
----------------------------------------------------------------

The recipe's __repr__ should include the units, maybe in square brackets after the term's name?


@review-notebook-app

review-notebook-app Bot commented Jul 28, 2026

Copy link
Copy Markdown

View / edit / reply to this conversation on ReviewNB

hdrake commented on 2026-07-28T18:53:32Z
----------------------------------------------------------------

Are there any var: null left? This might be a legacy comment.


@review-notebook-app

review-notebook-app Bot commented Jul 28, 2026

Copy link
Copy Markdown

View / edit / reply to this conversation on ReviewNB

hdrake commented on 2026-07-28T18:53:34Z
----------------------------------------------------------------

Line #34.    # to-do: add comparable chunking inside the difference and convergence routines 

Either do this or remove the comment.


@hdrake hdrake left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two comments:

Comment thread xbudget/recipes/ECCOV4r4_native.yaml Outdated
Comment thread xbudget/collect.py Outdated
…the repr

Five changes, one per review comment on the units-handling PR.

- Shared constants at the recipe root. A new top-level `constants` key declares
  a scalar once (`seawater_density: {value: 1035., units: "kg m-3"}`) and terms
  refer to it by name (`density: "seawater_density"`), instead of repeating the
  three-line mapping at every use — 36 of them in ECCOV4r4_native.yaml alone.
  References are substituted during parsing, so nothing downstream knows the
  table existed; a string operand resolves against it first and otherwise stays
  a diagnostic name.
- `strict` is now `assert_unit_consistency`, which says what it asserts.
- The recipe display annotates every node with its units in square brackets
  (`density: 1035. [kg m-3]`, `lateral [×] [W]`). Units enter at the leaves, so
  `show_recipe` takes an optional dataset/grid to read raw diagnostics' units
  from and the whole tree resolves; a `BudgetQuery` shown in a notebook prefers
  the units its run actually stamped. Backed by `units.infer_units`, which runs
  the evaluator's arithmetic statically over the typed tree.
- Dropped the "legacy comment" in the MOM6 notebook: MOM6.yaml has no `var:
  null` entries left, so the text now explains what it is really showing.
- Did the ECCO notebook's chunking to-do rather than deleting it.
  `lateral_divergence` now gets the same `allow_rechunk` treatment `difference`
  has always had, via three helpers the two now share. Verified identical to the
  byte on the real ECCO LLC90 budgets, and roughly half the wall clock.

`lateral_divergence` also gains its first CI test, on a two-tile face-connected
synthetic grid; previously it ran only against the data-gated ECCO notebooks.

Verified: full suite green (136 -> 157) with the MOM6 example dataset present,
so the characterization golden is included; `assert_unit_consistency=True`
passes on both the real MOM6 and ECCO V4r4 datasets; all three affected
notebooks re-executed; docs build clean under `-W`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
hdrake and others added 3 commits July 28, 2026 13:00
Review feedback on #33: `_divergence` was vaguer than the operation it wraps.
Its body calls the bare `collect.lateral_divergence`, which the docstring now
says explicitly so the two are not mistaken for each other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Address review of #32: shared recipe constants, assert_unit_consistency, units in the repr
…ference

An independent reviewer went through the units feature hunting for bugs and
tried to write a recipe for their own model from the docs alone. Four findings
were worth fixing, plus the doc gap that stopped them cold.

Correctness

- The stamped `units` on the ECCO salt budget were wrong by 1000x. ECCO's salt
  fluxes carry the salinity scale in their own units string (`1e-3 m3 s-1`), and
  the recipe's compensating `unit_conversion: 0.001` was a bare number — so the
  scale survived into the composed units while the values were already kg/s.
  Anyone reading the attribute got a thousandth of the real flux. The conversion
  constants now declare the reciprocal scale (`{value: 0.001, units: "1e3"}`,
  and `kg g-1` for the g->kg one), so value x units == 1 and the scale cancels.
  Values are untouched; only the label changed.

- `assert_unit_consistency` now compares magnitude, not just dimension, so it
  catches that class of error instead of passing it. `is_convertible` cannot see
  a numeric scale, which is exactly what a mis-declared conversion factor leaves
  behind. Both shipped models reconcile exactly under the stricter check. This
  reverses the "dimension, not exact units" decision documented in #32; the
  motivating case there (salinity's `1e-3`) is better handled by making the
  recipe cancel the scale than by declining to look at it.

- `lateral_divergence` without an `xgcm.Grid` raised `AttributeError: 'NoneType'
  object has no attribute 'axes'` — a regression from the rechunking in #33,
  which reads the grid before the guarded helper is called. Both grid-needing
  operations now share one `_require_grid`, and the divergence has the
  grid-guard test `difference` already had.

- `show_recipe` blanked the units of every term above a missing diagnostic,
  including the budget roots — on the raw ECCO dataset, all three rhs roots.
  `infer_units` takes an optional `available` predicate and, given one, mirrors
  what the evaluator builds (dropping operands it would drop) rather than what
  the recipe describes. A term that genuinely will not be built is still blank.

Smaller fixes

- A `constants` entry missing `value` raised a bare `KeyError`; a budget named
  `constants` raised the same. Both now say what is wrong, with the path.
- The `reciprocal` operand was the one leaf the HTML tree left unannotated.
- Missing diagnostics are reported before unit issues: an absent input causes
  unknown units, so `on_missing="raise"` now names the diagnostic, not the
  symptom.
- `units:` on a term gets its own warning instead of the generic "missing an
  enclosing product" hint, which pointed at the wrong fix.
- Rechunking is a no-op on unchunked data rather than silently returning dask.

Docs

- `recipes.md` gained the `xgcm.Grid` section it never had. The reviewer got
  through everything else from the docs and then had to read `tests/conftest.py`
  to learn that xgcm >= 0.10 wants `padding=`, which every older tutorial spells
  `boundary=`/`periodic=`.
- The "conversion factors are dimensionless" guidance led them straight into the
  bug above; it now says the opposite, with the worked pattern.
- Documented what the assertion does not check, that constants substitute only
  in sum/product operands, and that `aggregate()` returns metadata beside sides.

Verified: 178 tests pass (was 157) with both example datasets present;
`assert_unit_consistency=True` passes on real MOM6 and ECCO under the stricter
magnitude check, with ECCO closure residuals unchanged; three notebooks
re-executed; docs build clean under `-W`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
hdrake and others added 3 commits July 28, 2026 19:45
Editable installs bind a conda env to a single checkout, so sharing
docs_env_xbudget across worktrees silently tests the wrong tree. Require
a dedicated docs_env_xbudget_<branch-or-worktree-name> env instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Act on independent review: unit scale correctness, grid guard, lenient display inference
@hdrake
hdrake merged commit 6331884 into main Jul 29, 2026
5 of 7 checks passed
@hdrake
hdrake deleted the units-handling branch July 29, 2026 03:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Automatic handling of units

1 participant