Quickstart¶
This page closes a budget end to end in about thirty lines, with data you can
make up on the spot — no model output to download, and no xgcm.Grid needed.
The idea is simple. A budget says this tendency equals the sum of these processes. Your model writes out the pieces; xbudget assembles them, gives each one a predictable name, and lets you check that the two sides agree.
Some data¶
Pretend this came out of an ocean model. Heat changes because of advection, diffusion, and a surface flux — and because we are making the numbers up, we can make the budget balance exactly:
import numpy as np
import xarray as xr
import xbudget
rng = np.random.default_rng(0)
dims, coords = ("x", "y"), {"x": [0, 1, 2], "y": [0, 1, 2]}
advection = rng.random((3, 3)) # W (flux convergence, already area-integrated)
diffusion = rng.random((3, 3)) # W (flux convergence, already area-integrated)
surface_flux = rng.random((3, 3)) # W m-2
cell_area = np.full((3, 3), 1.0e6) # m2
ds = xr.Dataset(
{
# the tendency the model reports, in W
"heat_tendency": (dims, advection + diffusion + surface_flux * cell_area),
"advective_flux_convergence": (dims, advection),
"diffusive_flux_convergence": (dims, diffusion),
"surface_heat_flux": (dims, surface_flux),
"cell_area": (dims, cell_area),
},
coords=coords,
)
Note the surface term: the model reports a flux per unit area, so it has to be multiplied by the cell area before it can join a budget written in watts. That kind of bookkeeping is most of what xbudget does for you.
The recipe¶
You describe the budget once, as a nested dict — the recipe. Each budget has a
lhs and an rhs; each side is built from operations (sum, product, and a
few others). You write var only to name a diagnostic; anything without one is
something xbudget derives for you:
recipe = {
"heat": {
"lhs": {"sum": {"tendency": {"var": "heat_tendency"}}},
"rhs": {
"sum": {
"advection": {"var": "advective_flux_convergence"},
"diffusion": {"var": "diffusive_flux_convergence"},
"surface_forcing": {
"product": {
"flux": "surface_heat_flux",
"area": "cell_area",
},
},
},
},
}
}
{"var": "advective_flux_convergence"} points straight at a diagnostic.
surface_forcing is the interesting one: it is a product of a diagnostic and a
grid metric, which xbudget multiplies out for you.
Collect, then query¶
xbudget.collect_budgets(ds, recipe)
q = xbudget.BudgetQuery(ds, recipe)
collect_budgets computes every term and writes it into ds. BudgetQuery is
how you find them afterwards:
>>> q.aggregate()
{'heat': {'lhs': {'tendency': 'heat_lhs_tendency'},
'rhs': {'advection': 'heat_rhs_advection',
'diffusion': 'heat_rhs_diffusion',
'surface_forcing': 'heat_rhs_surface_forcing'}}}
Each term is named after its path through the recipe, joined with underscores —
heat / rhs / advection becomes heat_rhs_advection. Nothing to memorize,
and nothing to parse back out.
Does it close?¶
This is the question xbudget exists to answer:
>>> residual = ds[q.var("heat_lhs")] - ds[q.var("heat_rhs")]
>>> float(abs(residual).max())
0.0
Zero, because we built the data to balance. With real model output you would expect something small but non-zero, and a residual that is not small usually means a term is missing from your recipe or is on the wrong side.
When a term is missing¶
The other common reason a budget doesn’t close: your dataset didn’t have a
diagnostic the recipe asked for. xbudget skips that term rather than erroring —
one recipe can serve datasets with different diagnostics — but it never does so
silently. Drop surface_heat_flux from the data and collect again:
>>> xbudget.collect_budgets(ds, recipe)
UserWarning: xbudget: missing diagnostic(s) ['surface_heat_flux']; 1 budget
term(s) are now incomplete: ['heat_rhs']. ...
The term that couldn’t be built is dropped, and everything that depended on it is flagged — durably, on the variable itself, so it survives being saved and reopened:
>>> q = xbudget.BudgetQuery(ds, recipe)
>>> q.is_complete("heat_rhs")
False
>>> q.missing()
{('heat', 'rhs'): ['surface_forcing'],
('heat', 'rhs', 'surface_forcing'): ['surface_heat_flux']}
So a budget is never quietly incomplete: is_complete() and missing() tell you
exactly what was left out. If a missing diagnostic should be an error instead, pass
on_missing="raise"; if its absence is expected, mark that term optional in
the recipe. See Writing a recipe for the full story, and
Handling missing diagnostics for a
worked, runnable example.
Where did a term come from?¶
Every variable xbudget derives carries its own provenance:
>>> ds[q.var("heat_rhs_surface_forcing")].attrs
{'provenance': ['surface_heat_flux', 'cell_area'],
'xbudget_path': ['heat', 'rhs', 'surface_forcing'],
'xbudget_op': 'product'}
Or ask the query directly, which also works before you go digging in the dataset:
>>> q.get_vars("heat_rhs")
{'var': 'heat_rhs', 'sum': ['heat_rhs_advection', 'heat_rhs_diffusion', 'heat_rhs_surface_forcing']}
>>> q.get_vars("heat_rhs_surface_forcing")
{'var': 'heat_rhs_surface_forcing', 'product': ['surface_heat_flux', 'cell_area']}
Keeping the recipe¶
A recipe is just a dict, so you can save it and reuse it:
xbudget.save_yaml(recipe, "my_model.yaml")
recipe = xbudget.load_yaml("my_model.yaml")
save_yaml validates before writing, so a malformed recipe fails while you are
writing it rather than the next time someone loads it:
>>> broken = {"heat": {"rhs": {"sum": "not a dict"}}}
>>> xbudget.save_yaml(broken, "my_model.yaml")
BudgetParseError: 'sum' at heat/rhs must be a dict, got str.
Nothing lands on disk — the error is raised before the file is opened.
Your own model¶
Real recipes are bigger than this one — a full ocean heat budget can have dozens of terms — but they are the same shape, and you only write one once. xbudget ships several:
recipe = xbudget.load_preset_budget("MOM6")
xbudget.collect_budgets(grid, recipe)
Currently shipped: MOM6, MOM6_3Donly, MOM6_drift, MOM6_surface, and
ECCOV4r4_native. If your model is not here, writing a recipe for it is a
one-time job — see Writing a recipe for the full schema,
the available operations, and how a recipe becomes variables.
Two things this page skipped, both of which real budgets need:
Grids. Terms built by differencing a staggered flux (or by a lateral divergence) need an
xgcm.Gridinstead of a plainDataset, so xbudget knows the discretization. Pass the grid tocollect_budgetsin exactly the same way.Scale. The examples below run against real model output, with dask.
For a full worked budget, see the MOM6 example.