Skip to content

Act on independent review: unit scale correctness, grid guard, lenient display inference - #34

Merged
hdrake merged 1 commit into
units-handlingfrom
units-review-followups
Jul 29, 2026
Merged

Act on independent review: unit scale correctness, grid guard, lenient display inference#34
hdrake merged 1 commit into
units-handlingfrom
units-review-followups

Conversation

@hdrake

@hdrake hdrake commented Jul 28, 2026

Copy link
Copy Markdown
Owner

An independent reviewer audited the units feature (both #32 and #33) for bugs and
separately tried to write a recipe for their own model using only the docs. This
acts on what was worth acting on. Targets units-handling (#32), since #33 was
merged into it while this was in progress.

Everything here reproduces — each fix landed with the failing case first.

Correctness

The ECCO salt budget's stamped units were wrong by 1000×

ECCO's salt fluxes carry the salinity scale in their own units string
(ADVx_SLT is 1e-3 m3 s-1), and the recipe's compensating
unit_conversion: 0.001 was a bare number, hence dimensionless. So the
1e-3 survived into the composed units while the arithmetic already produced
kg/s:

stamped units : 0.001 kg.s-1
value         : 35.60          <- correct kg/s
read with its own units -> 0.0356 kg/s     # off by 1000

Eight ECCO variables shipped that label. The budgets still closed (both sides
carried the same error), so nothing looked wrong — but the attribute is written
to disk, and it is the thing this feature exists to get right.

The conversion constants now declare the reciprocal scale, so value × units == 1
and the scale cancels:

constants:
  salinity_scale:      {value: 0.001, units: "1e3"}     # cancels the input's 1e-3
  grams_to_kilograms:  {value: 0.001, units: "kg g-1"}  # SFLUX/oceSPtnd are g m-2 s-1

ECCO salt now stamps kg.s-1. Values are byte-identical — closure residuals
unchanged (mass 2.590e-04, heat 5.111e-06, salt 1.206e-04, exactly as before).

assert_unit_consistency now checks magnitude, not just dimension

is_convertible is blind to a numeric scale, so it passed the above. It now
requires the inferred units to equal the declared ones, and reports a
scale-mismatch naming the factor and the fix. Both shipped models reconcile
exactly under the stricter check.

This reverses a decision documented at length in #32 ("why strict checks
dimension, not exact units"). The motivating case there was salinity's 1e-3
which is better handled by making the recipe cancel the scale than by declining
to look at it. Worth your call; it is one predicate (same_scale) if you'd
rather keep the looser check.

lateral_divergence without a grid raised AttributeError

A regression from #33: the rechunking reads self.grid.axes before the guarded
helper is reached.

before: AttributeError: 'NoneType' object has no attribute 'axes'
after:  ValueError: Input `data` must be an `xgcm.Grid` instance when using
        `lateral_divergence` operations.

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 above any missing diagnostic

Static inference had no notion of "the evaluator will drop this operand", so one
absent diagnostic poisoned every ancestor — on the raw ECCO dataset, all three
rhs roots
, the most useful nodes in the display:

raw ECCO dataset       before    after
  mass/rhs             None      kg.s-1
  heat/rhs             None      W
  salt/rhs             None      kg.s-1
  mass/lhs             None      None     <- correct: genuinely won't be built

infer_units(node, lookup, available=) now mirrors the run when told which
diagnostics exist: a sum drops what the evaluator drops, a product missing a
factor is dropped whole, an optional subtree stops blanking its parent. A term
that genuinely will not be built stays blank — leniency, not optimism.

Smaller fixes

constants entry with no value → bare KeyError BudgetParseError naming the path
Budget named constants → same KeyError says the key is reserved
reciprocal operand unannotated in the HTML tree annotated (it was correct in the ASCII one)
UnitError preempted MissingDiagnosticError missing diagnostics reported first — the absent input causes the unknown units
units: on a term → "you may be missing an enclosing product" its own message; that hint pointed at the wrong fix
Rechunking dask-ified unchunked data no-op on unchunked input, which is already one chunk

Docs

The reviewer got through recipe-writing on the docs alone and was stopped by one
thing: recipes.md never showed how to build an xgcm.Grid. Quickstart says
a grid is needed and to "pass it in exactly the same way", but nothing shows
constructing one — and xgcm ≥ 0.10 renamed boundary= to padding= and removed
periodic=, so every older tutorial they'd find hands them a ValueError. They
had to read tests/conftest.py. There is now a ### The grid section with a
worked snippet and a note about the rename.

Also:

  • The old guidance — "salinity-to-mass conversion factors, and the like are
    dimensionless and stay bare"
    — is what led them into the 1000× bug. It now
    says the opposite, with the worked pattern.
  • Documented what the assertion does not check (unmaterialized side roots).
  • Corrected the constants-substitution scope: sum/product operands only, not
    "a string operand" (a difference/reciprocal operand is always a diagnostic).
  • aggregate() returns metadata beside lhs/rhs, and now that every recipe
    declares units that trips anyone iterating the values. Noted in quickstart.

Verified

  • 178 tests pass (was 157), with both example datasets present so the MOM6
    characterization golden runs rather than skips — it still matches.
  • assert_unit_consistency=True passes on real MOM6 and real ECCO V4r4 LLC90
    under the stricter magnitude check.
  • ECCO closure residuals unchanged to every printed digit.
  • Three notebooks re-executed; docs build clean under -W --keep-going.

Reviewer findings deliberately not acted on

  • _ALIASES is a whole-string map, so psu m3 s-1 parses as unknown while
    psu aliases to 1. No shipped recipe or example dataset hits it, and
    token-level rewriting of a UDUNITS string is its own small risk. Worth an issue.
  • Stamped units are cf-units canonical form (kg.s-1, not kg s-1). Legal
    UDUNITS and consistent, but it does differ from what input datasets write.
    A deliberate choice worth confirming rather than a bug.
  • A typo'd constant reference degrades into a missing diagnostic with no hint
    that a similarly-named constant was declared. The evaluator has no access to
    the table by then (references are substituted at parse time), so a good hint
    needs plumbing that did not seem worth it here.

🤖 Generated with Claude Code

…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>
@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

@hdrake
hdrake merged commit 362991c into units-handling Jul 29, 2026
1 check passed
@hdrake
hdrake deleted the units-review-followups branch July 29, 2026 03:15
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.

1 participant