From 6b1e40605e29540ed7aed9e48c5d3aaf614b6e76 Mon Sep 17 00:00:00 2001 From: Henri Drake Date: Tue, 28 Jul 2026 12:38:03 -0700 Subject: [PATCH 1/2] Address review of #32: shared constants, clearer flag name, units in the repr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 32 +- CLAUDE.md | 12 +- docs/recipes.md | 51 ++- .../MOM6_budget_examples_mass_heat_salt.ipynb | 356 +++++++++--------- ...ov4r4_budget_examples_mass_heat_salt.ipynb | 184 ++++----- .../eccov4r4_heat_budget_decomposition.ipynb | 154 ++++---- xbudget/collect.py | 21 +- xbudget/display.py | 173 +++++++-- xbudget/evaluate.py | 144 +++++-- xbudget/parse.py | 86 ++++- xbudget/query.py | 31 +- xbudget/recipes/ECCOV4r4_native.yaml | 111 ++---- xbudget/recipes/MOM6.yaml | 54 ++- xbudget/recipes/MOM6_3Donly.yaml | 46 +-- xbudget/recipes/MOM6_surface.yaml | 15 +- xbudget/tests/test_collect.py | 76 ++++ xbudget/tests/test_display.py | 55 +++ xbudget/tests/test_units.py | 173 ++++++++- xbudget/units.py | 77 +++- 19 files changed, 1205 insertions(+), 646 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85c24f1..90de451 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,17 +17,41 @@ `specific_heat_capacity` needed it; salinity/sign/conversion factors are dimensionless) and declare a top-level budget `units` (`mass`/`salt` → `kg s-1`, `heat` → `W`). -- `collect_budgets(..., strict=True)` enforces units: each budget that declares - a top-level `units` must have its side roots infer units convertible to the - declared ones, raising `UnitError` (exported) otherwise. Without `strict`, a - reconciliation failure only warns; inference and stamping happen either way. +- Constants shared across a recipe can be declared once under a new top-level + `constants` key and referred to by name (`density: "seawater_density"`), + instead of repeating a `{value:, units:}` mapping at every use. `constants` is + the only top-level key that is not a budget; a string operand resolves against + it first and otherwise stays a diagnostic name. The shipped recipes use it for + their reference density and heat capacity (36 repeated blocks in + `ECCOV4r4_native.yaml` alone). +- `collect_budgets(..., assert_unit_consistency=True)` enforces units: each + budget that declares a top-level `units` must have its side roots infer units + convertible to the declared ones, raising `UnitError` (exported) otherwise. + Left at its default of `False`, a reconciliation failure only warns; inference + and stamping happen either way. - `BudgetQuery` gained `.units(term)` (the stamped units of a materialized term) and `.budget_units(budget)` (the declared target). +- The recipe display (`show_recipe`, and a `BudgetQuery` shown in a notebook) + annotates every node with its units in square brackets — `density: 1035. + [kg m-3]`, `lateral [×] [W]`. `show_recipe` takes an optional third argument, + the grid/dataset to read raw diagnostics' units from, so a recipe can be read + with its full unit arithmetic before anything is collected. - Practical salinity (`psu`), which UDUNITS does not define, is treated as dimensionless (PSS-78); offset temperature (`degC`) is handled by cf-units' in-product dimensionalization, so heat-content terms reconcile to `W`. - `cf-units` is now a dependency. +### Engine + +- `lateral_divergence` now gets the same `allow_rechunk` treatment `difference` + has always had: its flux operands are put into a single chunk along the + differenced dimensions while xgcm works, and the caller's chunking is restored + afterwards. Previously only `difference` rechunked, so a horizontally chunked + flux made xgcm shuffle across chunk (and face) boundaries. Verified identical + to the byte on the ECCO V4r4 LLC90 budgets, roughly halving their wall clock. + `lateral_divergence` now also has a CI test, on a two-tile face-connected + synthetic grid. + ### Packaging - `pyyaml` is now declared as a dependency. It is imported at package import diff --git a/CLAUDE.md b/CLAUDE.md index deb7fc5..18ca834 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,7 @@ The central abstraction is the **`recipe`** — a nested provenance tree (loaded ### The recipe tree (input format — unchanged) -Top-level keys are budgets (`mass`, `heat`, `salt`). Each budget has `lhs` and/or `rhs` sub-trees plus metadata keys (`lambda`, `thickness`, `surface_lambda`) that the engine does not interpret. Within a side, terms nest recursively. A node names a diagnostic with a `var` key (`var: "thetao"`); a derived term omits `var` entirely (the `var: null` placeholders are optional as of 0.7.0) and carries one or more **operation** keys instead: +Top-level keys are budgets (`mass`, `heat`, `salt`) — except `constants`, the one reserved key, which declares scalars shared across the recipe (`seawater_density: {value: 1035., units: "kg m-3"}`) that terms then refer to 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. Each budget has `lhs` and/or `rhs` sub-trees plus metadata keys (`units`, `lambda`, `thickness`, `surface_lambda`) that the engine does not interpret as terms. Within a side, terms nest recursively. A node names a diagnostic with a `var` key (`var: "thetao"`); a derived term omits `var` entirely (the `var: null` placeholders are optional as of 0.7.0) and carries one or more **operation** keys instead: - `sum` — add the child terms together - `product` — multiply child terms (scalar numbers allowed as factors, e.g. `density: 1035.`, `sign: -1.`) @@ -63,7 +63,8 @@ recipe ──parse_budgets──▶ typed tree (nodes.py) ──evaluate_budgets - **Naming.** One variable per node/operation with operator infixes dropped; no duplicate "copy" variables (108 → 57 on the MOM6 example; 73 on the ECCOv4r4 native-grid recipe, whose volume budget is Eulerian-only — the GM bolus is exposed as `bolus` metadata, not a budget term). `CHANGELOG.md` has the old→new table for anyone migrating from 0.6.x. - **Missing diagnostics are skipped, not fatal** — a `sum` drops only the missing operand and builds from the rest (flagged `xbudget_incomplete`); a `product` with a missing *required* factor is dropped entirely (an unknown factor is not a zero one). `collect_budgets(on_missing="warn"|"raise"|"ignore")` sets the policy; `optional: true` on a term exempts its subtree. Query it back with `BudgetQuery.missing()`. -- **`difference` rechunking:** `allow_rechunk=True` (default) temporarily rechunks the difference dimension into a single chunk (required by `grid.diff`) then restores chunking. +- **Rechunking:** `allow_rechunk=True` (default) temporarily rechunks the differenced dimension(s) into a single chunk (required by `grid.diff`) then restores the caller's chunking. This applies to **both** `difference` and `lateral_divergence` — they share `_dataset_chunks`/`_single_chunk_along`/`_restore_chunks` in `evaluate.py`. On the ECCO LLC90 budgets it is numerically identical and roughly halves wall clock. +- **Units.** Every emitted variable is stamped with a `units` attr inferred through the tree (`units.py`, UDUNITS via `cf-units`); `collect_budgets(..., assert_unit_consistency=True)` additionally requires each budget's side roots to reconcile to its declared top-level `units`, raising `UnitError` (it only warns by default). `units.infer_units(node, lookup)` answers the same question statically, off the typed tree, which is what lets `show_recipe(recipe, budget, data)` and a notebook-displayed `BudgetQuery` annotate every node with `[units]`. - **Lenient parser.** `parse.py` **warns and skips** unavailable-diagnostic placeholders (e.g. a `null`-source `difference`) and stray non-operation keys instead of failing, so real recipes with such terms still load. A stray key usually means an enclosing `product:` was omitted (its factors are then silently dropped rather than multiplied), so those warnings are worth reading rather than filtering. - **xgcm version:** requires **xgcm >= 0.10.0** — `lateral_divergence` needs native face-connected differencing, first shipped there. 0.10.0 also removed `xgcm.Grid`'s `periodic` argument and renamed `boundary` to `padding` (both now raise `ValueError`), so every grid construction in `examples/` and `tests/` uses `padding=`; `periodic=False` translates to `padding="fill"`. @@ -71,12 +72,13 @@ recipe ──parse_budgets──▶ typed tree (nodes.py) ──evaluate_budgets - `test_parse.py` — parser units + validation; asserts all shipped recipes parse; covers the tolerated-malformation path. - `test_characterization.py` (+ `characterization_MOM6.json`) — golden snapshot of the engine's absolute MOM6 output (data-gated; exercises reciprocal/difference-of-sub-term only via the real recipe locally). -- `test_collect.py` — `collect_budgets` behavior (no recipe mutation, lhs/rhs, the grid-guard on `difference`). +- `test_collect.py` — `collect_budgets` behavior (no recipe mutation, lhs/rhs, the grid-guard on `difference`, and the `lateral_divergence` rechunking on a two-tile face-connected synthetic grid — the only CI coverage of face-connected differencing). - `test_query.py` — the query layer, all on the synthetic grid so it actually runs in CI. -- `test_missing_handling.py` / `test_optional_var.py` / `test_broadcast_warning.py` / `test_display.py` — missing-diagnostic handling, `var: null` optionality, the issue-#11 broadcast guard, and the tree display; all synthetic-grid, CI-safe. +- `test_units.py` — the unit algebra, the `constants` table, static inference, stamping, and `assert_unit_consistency`; all pure or synthetic-grid. +- `test_missing_handling.py` / `test_optional_var.py` / `test_broadcast_warning.py` / `test_display.py` — missing-diagnostic handling, `var: null` optionality, the issue-#11 broadcast guard, and the tree display (including its `[units]` annotation); all synthetic-grid, CI-safe. - `conftest.py` — `synthetic_grid` / `synthetic_preset` fixtures, plus `SYNTHETIC_PRESET_SKIPS` (a preset whose structurally-primary op is skipped at run time — the regression test for name resolution being a runtime fact). -**CI has neither example dataset**, so every data-gated test skips there. Only the synthetic-grid tests actually protect the engine in CI; `lateral_divergence` and `reciprocal` on a real face-connected grid are exercised *only* by the data-gated characterization / ECCO notebooks locally. +**CI has neither example dataset**, so every data-gated test skips there. Only the synthetic-grid tests actually protect the engine in CI; `reciprocal` on a real face-connected grid is exercised *only* by the data-gated characterization / ECCO notebooks locally (`lateral_divergence` now has the miniature face-connected test in `test_collect.py`, but the real LLC90 topology still only runs locally). ## Data & examples diff --git a/docs/recipes.md b/docs/recipes.md index 6524e15..e8f26a3 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -90,6 +90,35 @@ Only genuinely dimensional constants need this (`density`, `specific_heat_capaci `sign`, salinity-to-mass conversion factors, and the like are dimensionless and stay bare. See the *Units* section below. +**Shared constants live at the root.** A reference density recurs at nearly every +term of a budget, and repeating the mapping at each use is both tedious and a +place for the copies to drift apart. Declare it once under the top-level +`constants` key and refer to it by name: + +```yaml +constants: + seawater_density: + value: 1035. + units: "kg m-3" + +mass: + units: "kg s-1" + lhs: + sum: + Eulerian_tendency: + product: + thickness_tendency: "dhdt" + density: "seawater_density" # -> 1035. kg m-3 + area: "areacello" +``` + +Entries take the same two forms an inline constant does (a bare number, or a +`{value:, units:}` mapping). `constants` is the one top-level key that is not a +budget. A string operand is looked up there first and otherwise treated as a +diagnostic name, so a constant sharing a name with a dataset variable shadows it +— name them distinctly. All the shipped recipes use this for their reference +density and heat capacity. + [udunits]: https://www.unidata.ucar.edu/software/udunits/ Budget-level keys that are not `lhs`/`rhs` are metadata, carried through @@ -143,13 +172,13 @@ mass: ... ``` -With `strict=True`, this is enforced: each budget that declares `units` must have -its `lhs`/`rhs` roots infer units convertible to the declared ones, or -`collect_budgets` raises `xbudget.UnitError`. Without `strict` (the default) a -mismatch only warns — inference and stamping happen regardless. +With `assert_unit_consistency=True`, this is enforced: each budget that declares +`units` must have its `lhs`/`rhs` roots infer units convertible to the declared +ones, or `collect_budgets` raises `xbudget.UnitError`. Left at its default of +`False`, a mismatch only warns — inference and stamping happen regardless. ```python -xbudget.collect_budgets(grid, recipe, strict=True) # raises UnitError on a clash +xbudget.collect_budgets(grid, recipe, assert_unit_consistency=True) # UnitError on a clash q = xbudget.BudgetQuery(grid, recipe) q.budget_units("mass") # -> "kg s-1" (the declared target) q.units(("mass", "rhs")) # -> "kg.s-1" (what the run inferred) @@ -169,8 +198,9 @@ A real recipe nests deeply, and printed as raw JSON it is hard to read. Instead, arrow, the way an `xarray.Dataset` repr expands: ```python -xbudget.show_recipe(recipe) # every budget -xbudget.show_recipe(recipe, "heat") # just one +xbudget.show_recipe(recipe) # every budget +xbudget.show_recipe(recipe, "heat") # just one +xbudget.show_recipe(recipe, "heat", grid) # ...annotated with units ``` In a Jupyter/VSCode notebook it displays as an interactive HTML tree (operator @@ -178,6 +208,13 @@ badges, diagnostics, and constants distinguished); printed at a terminal it falls back to an indented ASCII tree. It reads a recipe directly, so you can inspect one before ever running it. +Each node also carries its units in square brackets — `density: 1035. [kg m-3]`, +`lateral [×] [W]` — composed up the tree by the same arithmetic +`assert_unit_consistency` checks. Units enter at the leaves, so pass the data you +intend to collect against (as the third argument above) and the whole tree +resolves; reading it top-down then shows exactly *where* a unit stops being +inferable. Without it, only the recipe's declared constants are annotated. + Once you *have* run a budget, displaying the query does the same but annotated with each term's resolved variable name — with terms whose diagnostics were missing greyed out, so the tree doubles as a map of what the run materialized: diff --git a/examples/MOM6_budget_examples_mass_heat_salt.ipynb b/examples/MOM6_budget_examples_mass_heat_salt.ipynb index 7f8e1cb..cf37f27 100644 --- a/examples/MOM6_budget_examples_mass_heat_salt.ipynb +++ b/examples/MOM6_budget_examples_mass_heat_salt.ipynb @@ -6,10 +6,10 @@ "id": "182a8d3a-09f8-448e-a029-d03db69bbe44", "metadata": { "execution": { - "iopub.execute_input": "2026-07-28T15:33:01.766664Z", - "iopub.status.busy": "2026-07-28T15:33:01.766388Z", - "iopub.status.idle": "2026-07-28T15:33:01.879345Z", - "shell.execute_reply": "2026-07-28T15:33:01.878869Z" + "iopub.execute_input": "2026-07-28T19:27:33.362422Z", + "iopub.status.busy": "2026-07-28T19:27:33.362027Z", + "iopub.status.idle": "2026-07-28T19:27:33.523729Z", + "shell.execute_reply": "2026-07-28T19:27:33.523413Z" } }, "outputs": [], @@ -24,10 +24,10 @@ "id": "a5f0eef4-7368-4f12-bc35-e09387046539", "metadata": { "execution": { - "iopub.execute_input": "2026-07-28T15:33:01.880733Z", - "iopub.status.busy": "2026-07-28T15:33:01.880650Z", - "iopub.status.idle": "2026-07-28T15:33:04.997027Z", - "shell.execute_reply": "2026-07-28T15:33:04.996451Z" + "iopub.execute_input": "2026-07-28T19:27:33.525645Z", + "iopub.status.busy": "2026-07-28T19:27:33.525528Z", + "iopub.status.idle": "2026-07-28T19:27:35.076743Z", + "shell.execute_reply": "2026-07-28T19:27:35.076428Z" } }, "outputs": [], @@ -61,10 +61,10 @@ "id": "5aa1b5c9-60d4-4b5e-9c7d-d0c25efb5093", "metadata": { "execution": { - "iopub.execute_input": "2026-07-28T15:33:04.998864Z", - "iopub.status.busy": "2026-07-28T15:33:04.998668Z", - "iopub.status.idle": "2026-07-28T15:33:06.360523Z", - "shell.execute_reply": "2026-07-28T15:33:06.359568Z" + "iopub.execute_input": "2026-07-28T19:27:35.078522Z", + "iopub.status.busy": "2026-07-28T19:27:35.078366Z", + "iopub.status.idle": "2026-07-28T19:27:36.164349Z", + "shell.execute_reply": "2026-07-28T19:27:36.163987Z" } }, "outputs": [ @@ -95,10 +95,10 @@ "id": "449ee6d3-57ca-4e3d-94dc-076887309454", "metadata": { "execution": { - "iopub.execute_input": "2026-07-28T15:33:06.364794Z", - "iopub.status.busy": "2026-07-28T15:33:06.364533Z", - "iopub.status.idle": "2026-07-28T15:33:06.436880Z", - "shell.execute_reply": "2026-07-28T15:33:06.436226Z" + "iopub.execute_input": "2026-07-28T19:27:36.166023Z", + "iopub.status.busy": "2026-07-28T19:27:36.165928Z", + "iopub.status.idle": "2026-07-28T19:27:36.212256Z", + "shell.execute_reply": "2026-07-28T19:27:36.211968Z" } }, "outputs": [], @@ -123,10 +123,10 @@ "id": "2d158238-b8a5-4fe5-8a59-7198d9e759ce", "metadata": { "execution": { - "iopub.execute_input": "2026-07-28T15:33:06.438563Z", - "iopub.status.busy": "2026-07-28T15:33:06.438455Z", - "iopub.status.idle": "2026-07-28T15:33:06.459336Z", - "shell.execute_reply": "2026-07-28T15:33:06.458379Z" + "iopub.execute_input": "2026-07-28T19:27:36.213742Z", + "iopub.status.busy": "2026-07-28T19:27:36.213659Z", + "iopub.status.idle": "2026-07-28T19:27:36.234348Z", + "shell.execute_reply": "2026-07-28T19:27:36.234132Z" } }, "outputs": [ @@ -196,6 +196,12 @@ " font-style: italic;\n", " margin-left: 0.5em;\n", "}\n", + ".xbdg-wrap .xbdg-units {\n", + " font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n", + " font-size: 0.9em;\n", + " color: var(--xbdg-muted);\n", + " margin-left: 0.4em;\n", + "}\n", ".xbdg-wrap .xbdg-leaf { padding: 1px 0; }\n", ".xbdg-wrap .xbdg-label { color: var(--xbdg-muted); }\n", ".xbdg-wrap .xbdg-diag {\n", @@ -207,71 +213,71 @@ " color: var(--xbdg-const);\n", "}\n", ".xbdg-wrap .xbdg-missing { opacity: 0.45; }\n", - "
xbudget recipe · heat
heatunits=Wlambda=thetaosurface_lambda=tos
lhsΣ
Eulerian_tendency×
tracer_content_tendency_per_unit_area: opottemptend
area: areacello
advectionΣ
lateral×
sign: -1.
tracer_content_tendency_per_unit_area: T_advection_xy
area: areacello
interfacial×
sign: -1.
tracer_content_tendency_per_unit_area: Th_tendency_vert_remap
area: areacello
surface_ocean_flux_advective_negative_lhs×
sign: -1.
specific_heat_capacity: 3992.
lambda_mass: tos
thickness_tendency: boundary_forcing_h_tendency
density: 1035.
area: areacello
rhsΣ
diffusionΣ
lateral×
tracer_content_tendency_per_unit_area: opottemppmdiff
area: areacello
interfacial×
tracer_content_tendency_per_unit_area: opottempdiff
area: areacello
surface_exchange_flux×Σ
×product
tracer_content_tendency_per_unit_area: boundary_forcing_heat_tendency
area: areacello
Σsum
nonadvectiveΣ
latent×
tracer_content_tendency_per_unit_area: hflso
area: areacello
sensible×
tracer_content_tendency_per_unit_area: hfsso
area: areacello
longwave×
tracer_content_tendency_per_unit_area: rlntds
area: areacello
shortwave×
tracer_content_tendency_per_unit_area: rsdoabsorb
area: areacello
advective×
tracer_content_tendency_per_unit_area: heat_content_surfwater
area: areacello
surface_ocean_flux_advective_negative_rhs×
sign: -1.
specific_heat_capacity: 3992.
lambda_mass: tos
thickness_tendency: boundary_forcing_h_tendency
density: 1035.
area: areacello
bottom_flux×
tracer_content_tendency_per_unit_area: internal_heat_heat_tendency
area: areacello
frazil_ice×
tracer_content_tendency_per_unit_area: frazil_heat_tendency
area: areacello
" + "
xbudget recipe · heat
heatunits=Wlambda=thetaosurface_lambda=tos
lhsΣ[W]
Eulerian_tendency×[W]
tracer_content_tendency_per_unit_area: opottemptend[W m-2]
area: areacello[m2]
advectionΣ[W]
lateral×[W]
sign: -1.
tracer_content_tendency_per_unit_area: T_advection_xy[W m-2]
area: areacello[m2]
interfacial×[W]
sign: -1.
tracer_content_tendency_per_unit_area: Th_tendency_vert_remap[W m-2]
area: areacello[m2]
surface_ocean_flux_advective_negative_lhs×[W]
sign: -1.
specific_heat_capacity: 3992.[J kg-1 K-1]
lambda_mass: tos[degC]
thickness_tendency: boundary_forcing_h_tendency[m s-1]
density: 1035.[kg m-3]
area: areacello[m2]
rhsΣ[W]
diffusionΣ[W]
lateral×[W]
tracer_content_tendency_per_unit_area: opottemppmdiff[W m-2]
area: areacello[m2]
interfacial×[W]
tracer_content_tendency_per_unit_area: opottempdiff[W m-2]
area: areacello[m2]
surface_exchange_flux×Σ[W]
×product
tracer_content_tendency_per_unit_area: boundary_forcing_heat_tendency[W m-2]
area: areacello[m2]
Σsum
nonadvectiveΣ[W]
latent×[W]
tracer_content_tendency_per_unit_area: hflso[W m-2]
area: areacello[m2]
sensible×[W]
tracer_content_tendency_per_unit_area: hfsso[W m-2]
area: areacello[m2]
longwave×[W]
tracer_content_tendency_per_unit_area: rlntds[W m-2]
area: areacello[m2]
shortwave×[W]
tracer_content_tendency_per_unit_area: rsdoabsorb[W m-2]
area: areacello[m2]
advective×[W]
tracer_content_tendency_per_unit_area: heat_content_surfwater[W m-2]
area: areacello[m2]
surface_ocean_flux_advective_negative_rhs×[W]
sign: -1.
specific_heat_capacity: 3992.[J kg-1 K-1]
lambda_mass: tos[degC]
thickness_tendency: boundary_forcing_h_tendency[m s-1]
density: 1035.[kg m-3]
area: areacello[m2]
bottom_flux×[W]
tracer_content_tendency_per_unit_area: internal_heat_heat_tendency[W m-2]
area: areacello[m2]
frazil_ice×[W]
tracer_content_tendency_per_unit_area: frazil_heat_tendency[W m-2]
area: areacello[m2]
" ], "text/plain": [ "xbudget recipe · heat\n", "heat units=W, lambda=thetao, surface_lambda=tos\n", - "├─ lhs [Σ]\n", - "│ ├─ Eulerian_tendency [×]\n", - "│ │ ├─ tracer_content_tendency_per_unit_area: opottemptend\n", - "│ │ └─ area: areacello\n", - "│ ├─ advection [Σ]\n", - "│ │ ├─ lateral [×]\n", + "├─ lhs [Σ] [W]\n", + "│ ├─ Eulerian_tendency [×] [W]\n", + "│ │ ├─ tracer_content_tendency_per_unit_area: opottemptend [W m-2]\n", + "│ │ └─ area: areacello [m2]\n", + "│ ├─ advection [Σ] [W]\n", + "│ │ ├─ lateral [×] [W]\n", "│ │ │ ├─ sign: -1.\n", - "│ │ │ ├─ tracer_content_tendency_per_unit_area: T_advection_xy\n", - "│ │ │ └─ area: areacello\n", - "│ │ └─ interfacial [×]\n", + "│ │ │ ├─ tracer_content_tendency_per_unit_area: T_advection_xy [W m-2]\n", + "│ │ │ └─ area: areacello [m2]\n", + "│ │ └─ interfacial [×] [W]\n", "│ │ ├─ sign: -1.\n", - "│ │ ├─ tracer_content_tendency_per_unit_area: Th_tendency_vert_remap\n", - "│ │ └─ area: areacello\n", - "│ └─ surface_ocean_flux_advective_negative_lhs [×]\n", + "│ │ ├─ tracer_content_tendency_per_unit_area: Th_tendency_vert_remap [W m-2]\n", + "│ │ └─ area: areacello [m2]\n", + "│ └─ surface_ocean_flux_advective_negative_lhs [×] [W]\n", "│ ├─ sign: -1.\n", - "│ ├─ specific_heat_capacity: 3992.\n", - "│ ├─ lambda_mass: tos\n", - "│ ├─ thickness_tendency: boundary_forcing_h_tendency\n", - "│ ├─ density: 1035.\n", - "│ └─ area: areacello\n", - "└─ rhs [Σ]\n", - " ├─ diffusion [Σ]\n", - " │ ├─ lateral [×]\n", - " │ │ ├─ tracer_content_tendency_per_unit_area: opottemppmdiff\n", - " │ │ └─ area: areacello\n", - " │ └─ interfacial [×]\n", - " │ ├─ tracer_content_tendency_per_unit_area: opottempdiff\n", - " │ └─ area: areacello\n", - " ├─ surface_exchange_flux [× Σ]\n", - " │ ├─ tracer_content_tendency_per_unit_area: boundary_forcing_heat_tendency\n", - " │ ├─ area: areacello\n", - " │ ├─ nonadvective [Σ]\n", - " │ │ ├─ latent [×]\n", - " │ │ │ ├─ tracer_content_tendency_per_unit_area: hflso\n", - " │ │ │ └─ area: areacello\n", - " │ │ ├─ sensible [×]\n", - " │ │ │ ├─ tracer_content_tendency_per_unit_area: hfsso\n", - " │ │ │ └─ area: areacello\n", - " │ │ ├─ longwave [×]\n", - " │ │ │ ├─ tracer_content_tendency_per_unit_area: rlntds\n", - " │ │ │ └─ area: areacello\n", - " │ │ └─ shortwave [×]\n", - " │ │ ├─ tracer_content_tendency_per_unit_area: rsdoabsorb\n", - " │ │ └─ area: areacello\n", - " │ └─ advective [×]\n", - " │ ├─ tracer_content_tendency_per_unit_area: heat_content_surfwater\n", - " │ └─ area: areacello\n", - " ├─ surface_ocean_flux_advective_negative_rhs [×]\n", + "│ ├─ specific_heat_capacity: 3992. [J kg-1 K-1]\n", + "│ ├─ lambda_mass: tos [degC]\n", + "│ ├─ thickness_tendency: boundary_forcing_h_tendency [m s-1]\n", + "│ ├─ density: 1035. [kg m-3]\n", + "│ └─ area: areacello [m2]\n", + "└─ rhs [Σ] [W]\n", + " ├─ diffusion [Σ] [W]\n", + " │ ├─ lateral [×] [W]\n", + " │ │ ├─ tracer_content_tendency_per_unit_area: opottemppmdiff [W m-2]\n", + " │ │ └─ area: areacello [m2]\n", + " │ └─ interfacial [×] [W]\n", + " │ ├─ tracer_content_tendency_per_unit_area: opottempdiff [W m-2]\n", + " │ └─ area: areacello [m2]\n", + " ├─ surface_exchange_flux [× Σ] [W]\n", + " │ ├─ tracer_content_tendency_per_unit_area: boundary_forcing_heat_tendency [W m-2]\n", + " │ ├─ area: areacello [m2]\n", + " │ ├─ nonadvective [Σ] [W]\n", + " │ │ ├─ latent [×] [W]\n", + " │ │ │ ├─ tracer_content_tendency_per_unit_area: hflso [W m-2]\n", + " │ │ │ └─ area: areacello [m2]\n", + " │ │ ├─ sensible [×] [W]\n", + " │ │ │ ├─ tracer_content_tendency_per_unit_area: hfsso [W m-2]\n", + " │ │ │ └─ area: areacello [m2]\n", + " │ │ ├─ longwave [×] [W]\n", + " │ │ │ ├─ tracer_content_tendency_per_unit_area: rlntds [W m-2]\n", + " │ │ │ └─ area: areacello [m2]\n", + " │ │ └─ shortwave [×] [W]\n", + " │ │ ├─ tracer_content_tendency_per_unit_area: rsdoabsorb [W m-2]\n", + " │ │ └─ area: areacello [m2]\n", + " │ └─ advective [×] [W]\n", + " │ ├─ tracer_content_tendency_per_unit_area: heat_content_surfwater [W m-2]\n", + " │ └─ area: areacello [m2]\n", + " ├─ surface_ocean_flux_advective_negative_rhs [×] [W]\n", " │ ├─ sign: -1.\n", - " │ ├─ specific_heat_capacity: 3992.\n", - " │ ├─ lambda_mass: tos\n", - " │ ├─ thickness_tendency: boundary_forcing_h_tendency\n", - " │ ├─ density: 1035.\n", - " │ └─ area: areacello\n", - " ├─ bottom_flux [×]\n", - " │ ├─ tracer_content_tendency_per_unit_area: internal_heat_heat_tendency\n", - " │ └─ area: areacello\n", - " └─ frazil_ice [×]\n", - " ├─ tracer_content_tendency_per_unit_area: frazil_heat_tendency\n", - " └─ area: areacello" + " │ ├─ specific_heat_capacity: 3992. [J kg-1 K-1]\n", + " │ ├─ lambda_mass: tos [degC]\n", + " │ ├─ thickness_tendency: boundary_forcing_h_tendency [m s-1]\n", + " │ ├─ density: 1035. [kg m-3]\n", + " │ └─ area: areacello [m2]\n", + " ├─ bottom_flux [×] [W]\n", + " │ ├─ tracer_content_tendency_per_unit_area: internal_heat_heat_tendency [W m-2]\n", + " │ └─ area: areacello [m2]\n", + " └─ frazil_ice [×] [W]\n", + " ├─ tracer_content_tendency_per_unit_area: frazil_heat_tendency [W m-2]\n", + " └─ area: areacello [m2]" ] }, "execution_count": 5, @@ -280,7 +286,7 @@ } ], "source": [ - "xbudget.show_recipe(recipe, \"heat\")" + "xbudget.show_recipe(recipe, \"heat\", grid)" ] }, { @@ -288,9 +294,9 @@ "id": "fbbcd656-22be-44b3-9a79-412f4bd03d86", "metadata": {}, "source": [ - "The `var: null` entries in the dictionary denote where diagnostics are not directly available. In many cases, however, they can be reconstructed by taking the sum or product of available diagnostics.\n", + "Each term is annotated with the operation that builds it (`Σ` sum, `×` product, `Δ` difference) and, in square brackets, the units `xbudget` infers for it — read from the diagnostics in `grid` at the leaves and composed upwards, so both sides of the heat budget come out in `W`. Passing `grid` is what makes that possible; without it only the recipe's declared constants carry units.\n", "\n", - "We can use the `xbudget.collect_budgets` function to leverage the information in this metadata dictionary to fill in all of these gaps in the budget." + "A term with no `var` of its own is one no single diagnostic provides: it is *reconstructed* from the sum or product of the diagnostics beneath it. We can use the `xbudget.collect_budgets` function to leverage the information in this metadata dictionary to fill in all of these gaps in the budget." ] }, { @@ -299,16 +305,16 @@ "id": "527f1b10", "metadata": { "execution": { - "iopub.execute_input": "2026-07-28T15:33:06.460982Z", - "iopub.status.busy": "2026-07-28T15:33:06.460867Z", - "iopub.status.idle": "2026-07-28T15:33:06.883460Z", - "shell.execute_reply": "2026-07-28T15:33:06.882933Z" + "iopub.execute_input": "2026-07-28T19:27:36.235573Z", + "iopub.status.busy": "2026-07-28T19:27:36.235494Z", + "iopub.status.idle": "2026-07-28T19:27:36.380448Z", + "shell.execute_reply": "2026-07-28T19:27:36.380186Z" } }, "outputs": [], "source": [ - "# strict=True asserts each budget reconciles to its declared units (mass/salt -> kg s-1, heat -> W).\n", - "xbudget.collect_budgets(grid, recipe, strict=True)\n", + "# assert_unit_consistency=True asserts each budget reconciles to its declared units (mass/salt -> kg s-1, heat -> W).\n", + "xbudget.collect_budgets(grid, recipe, assert_unit_consistency=True)\n", "q = xbudget.BudgetQuery(grid, recipe)" ] }, @@ -328,10 +334,10 @@ "id": "aa8797da-0f49-40aa-be98-b03ba49b380a", "metadata": { "execution": { - "iopub.execute_input": "2026-07-28T15:33:06.884997Z", - "iopub.status.busy": "2026-07-28T15:33:06.884889Z", - "iopub.status.idle": "2026-07-28T15:33:06.905854Z", - "shell.execute_reply": "2026-07-28T15:33:06.905432Z" + "iopub.execute_input": "2026-07-28T19:27:36.382102Z", + "iopub.status.busy": "2026-07-28T19:27:36.382019Z", + "iopub.status.idle": "2026-07-28T19:27:36.392036Z", + "shell.execute_reply": "2026-07-28T19:27:36.391784Z" } }, "outputs": [ @@ -374,10 +380,10 @@ "id": "7c9bfd4c-aaff-44ca-b360-d3b43f3addf1", "metadata": { "execution": { - "iopub.execute_input": "2026-07-28T15:33:06.907231Z", - "iopub.status.busy": "2026-07-28T15:33:06.907135Z", - "iopub.status.idle": "2026-07-28T15:33:06.960491Z", - "shell.execute_reply": "2026-07-28T15:33:06.960013Z" + "iopub.execute_input": "2026-07-28T19:27:36.393349Z", + "iopub.status.busy": "2026-07-28T19:27:36.393269Z", + "iopub.status.idle": "2026-07-28T19:27:36.411833Z", + "shell.execute_reply": "2026-07-28T19:27:36.411594Z" } }, "outputs": [ @@ -845,7 +851,7 @@ " provenance: ['heat_lhs_advection_lateral', 'heat_lhs_advection_interfa...\n", " xbudget_path: ['heat', 'lhs', 'advection']\n", " xbudget_op: sum\n", - " units: W