Source code for xbudget.collect
"""The public entry point: parse a recipe and evaluate it into a dataset.
``collect_budgets`` is a thin wrapper over the typed engine
(:func:`xbudget.parse.parse_budgets` then
:func:`xbudget.evaluate.evaluate_budgets`). It never mutates the recipe; it only
adds derived variables to the dataset. Query the result with
:class:`xbudget.query.BudgetQuery`.
"""
import warnings
import xarray as xr
__all__ = [
"collect_budgets",
"lateral_divergence",
]
def _warn_if_summands_broadcast(op_list, name):
"""Warn when a ``sum`` mixes operands of differing dimensionality (issue #11).
In a finite-volume budget every term of a ``sum`` should live on the same
grid and therefore carry identical dimensions. When they do not, ``xarray``
silently broadcasts the lower-rank operand across the dimensions it lacks —
so a 2D surface flux summed with a 3D flux convergence is spread *uniformly*
over the vertical rather than deposited at the single level that outcrops.
That is almost never the intended finite-volume broadcast, and the recipe
does not carry the outcropping level needed to do it correctly, so for now
we surface the situation loudly instead of returning a wrong budget.
Only ``xr.DataArray`` operands are compared; scalar constants (``sign``,
``density``, …) are ignored.
"""
dim_sets = [frozenset(o.dims) for o in op_list if isinstance(o, xr.DataArray)]
if len(dim_sets) < 2:
return
common = frozenset.intersection(*dim_sets)
broadcast_dims = frozenset.union(*dim_sets) - common
if broadcast_dims:
warnings.warn(
f"Summing terms with mismatched dimensions while building "
f"'{name}': the operands carry dimension sets "
f"{[tuple(sorted(s)) for s in dim_sets]}. xarray will broadcast the "
f"lower-dimensional term(s) across {tuple(sorted(broadcast_dims))}, "
f"e.g. spreading a 2D surface flux uniformly over the vertical of a "
f"3D flux convergence instead of depositing it at the outcropping "
f"level. Verify this broadcast is intended; see "
f"https://github.com/hdrake/xbudget/issues/11.",
UserWarning,
)
[docs]
def lateral_divergence(grid, Fx, Fy):
"""Horizontal flux divergence ``div(Fx, Fy)`` on cell centers, via xgcm.
Uses ``grid.diff`` with a vector ``other_component`` so face-connected
topologies (e.g. the LLC tiles of ECCO) are stitched correctly, for any grid
topology xgcm supports.
"""
if grid is None:
raise ValueError(
"Input `data` must be an `xgcm.Grid` instance when using "
"`lateral_divergence` operations."
)
dFx = grid.diff({"X": Fx}, "X", other_component={"Y": Fy})
dFy = grid.diff({"Y": Fy}, "Y", other_component={"X": Fx})
return dFx + dFy
[docs]
def collect_budgets(data, recipe, allow_rechunk=True, on_missing="warn"):
"""Materialize every budget term described by ``recipe`` into ``data``.
The recipe dict is parsed into a typed expression tree
(:mod:`xbudget.nodes`) and evaluated
(:func:`xbudget.evaluate.evaluate_budgets`). It does **not** mutate
``recipe``; it only adds derived variables to the dataset. Each derived
variable is named by its term path with operator infixes dropped (e.g.
``heat_rhs_diffusion_lateral``) and carries ``xbudget_path``, ``xbudget_op``,
and ``provenance`` attributes. Query the result with
:class:`xbudget.query.BudgetQuery`.
Parameters
----------
data : xgcm.Grid or xr.Dataset
Budget diagnostics to read from and write derived variables into,
modified in place. A grid is required if the recipe uses
``difference`` or ``lateral_divergence`` operations.
recipe : dict
A recipe in xbudget format (e.g. from ``load_preset_budget``).
allow_rechunk : bool (default: True)
Whether to temporarily rechunk when taking differences along a dimension,
e.g. to compute flux divergences on `center` from fluxes on `outer` or
tendencies on `center` from snapshots on `outer`.
on_missing : {"warn", "raise", "ignore"} (default: "warn")
How to handle a *required* diagnostic that a term references but that is
absent from ``data``. ``"warn"`` (default) skips the term and emits one
end-of-run summary ``UserWarning`` naming the missing diagnostics and the
now-incomplete terms; ``"raise"`` raises
:class:`~xbudget.evaluate.MissingDiagnosticError` so a recipe/dataset
mismatch fails loudly; ``"ignore"`` skips silently. In every case the
affected variables are stamped with ``xbudget_incomplete`` /
``xbudget_missing`` attributes (query them with
:meth:`xbudget.BudgetQuery.missing`), and a term declared ``optional`` in
the recipe is exempt — its absence is expected and never alarms.
Returns
-------
data : xgcm.Grid or xr.Dataset
The same object passed in, for convenience.
Examples
--------
Load a preset recipe, collect its budgets into a grid, and query the result:
>>> recipe = xbudget.load_preset_budget(model="MOM6")
>>> xbudget.collect_budgets(grid, recipe) # adds derived vars in place
>>> q = xbudget.BudgetQuery(grid, recipe)
>>> q.var("heat_rhs") # the closed RHS heat tendency
'heat_rhs'
>>> grid._ds["heat_rhs"].attrs["xbudget_path"]
['heat', 'rhs']
"""
from .parse import parse_budgets
from .evaluate import evaluate_budgets
budgets = parse_budgets(recipe)
evaluate_budgets(
data, budgets, allow_rechunk=allow_rechunk, on_missing=on_missing
)
return data