%load_ext autoreload
%autoreload 2
import xarray as xr
import xgcm
import matplotlib.pyplot as plt

Tutorial: analyzing finite-volume ocean model budgets with xbudget

This tutorial shows how to use xbudget to close and manipulate mass and tracer budgets in finite-volume.

Load example MOM6 dataset from Zenodo

from load_example_model_grid import load_MOM6_coarsened_diagnostics
grid = load_MOM6_coarsened_diagnostics()
File 'MOM6_global_example_diagnostics_zlevels_v0_0_6.nc' already exists at ../data/MOM6_global_example_diagnostics_zlevels_v0_0_6.nc. Skipping download.

Load budget metadata from preset MOM6 dictionary

import xbudget
recipe = xbudget.load_preset_budget(model="MOM6").copy()

We load in the file xbudget/recipes/MOM6.yaml, which comprehensively details the mass, heat, and salt budgets in MOM6, and how they can be evaluated using various combinations of diagnostics. In MOM6, most mass and tracer diagnostics are output as thickness tendencies and thickness-weighted tracer tendencies, respectively, and must be converted into density-weighted integrals over a finite-volume cell by multiplying by a seawater density and grid cell area.

For example, consider the structure of the heat budget:

xbudget.show_recipe(recipe, "heat")
xbudget recipe · heat
heatlambda=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

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.

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.

xbudget.collect_budgets(grid, recipe)
q = xbudget.BudgetQuery(grid, recipe)

Every reconstructed term is added to the original dataset (lazily), named after its path through the budget tree: the advection term under heat/lhs becomes heat_lhs_advection. Your metadata dictionary is left exactly as you wrote it.

BudgetQuery is how you ask what was built, without having to know the naming rule or search the dataset by eye.

q.aggregate()['heat']
{'lambda': 'thetao',
 'surface_lambda': 'tos',
 'lhs': {'Eulerian_tendency': 'heat_lhs_Eulerian_tendency',
  'advection': 'heat_lhs_advection',
  'surface_ocean_flux_advective_negative_lhs': 'heat_lhs_surface_ocean_flux_advective_negative_lhs'},
 'rhs': {'diffusion': 'heat_rhs_diffusion',
  'surface_exchange_flux': 'heat_rhs_surface_exchange_flux',
  'surface_ocean_flux_advective_negative_rhs': 'heat_rhs_surface_ocean_flux_advective_negative_rhs',
  'bottom_flux': 'heat_rhs_bottom_flux',
  'frazil_ice': 'heat_rhs_frazil_ice'}}

aggregate gives the high-level terms of each budget. To go the other way – from a term to the variable holding it – use q.var:

grid._ds[q.var("heat_lhs_advection")]
<xarray.DataArray 'heat_lhs_advection' (time: 1, z_l: 35, yh: 180, xh: 240)> Size: 12MB
dask.array<add, shape=(1, 35, 180, 240), dtype=float64, chunksize=(1, 35, 100, 100), chunktype=numpy.ndarray>
Coordinates:
    geolon     (yh, xh) float64 346kB dask.array<chunksize=(100, 100), meta=np.ndarray>
    lon        (yh, xh) float64 346kB dask.array<chunksize=(100, 100), meta=np.ndarray>
    geolat     (yh, xh) float64 346kB dask.array<chunksize=(100, 100), meta=np.ndarray>
    lat        (yh, xh) float64 346kB dask.array<chunksize=(100, 100), meta=np.ndarray>
    deptho     (yh, xh) float32 173kB dask.array<chunksize=(100, 100), meta=np.ndarray>
    wet        (yh, xh) float32 173kB dask.array<chunksize=(100, 100), meta=np.ndarray>
  * z_l        (z_l) float64 280B 2.5 10.0 20.0 32.5 ... 5.5e+03 6e+03 6.5e+03
  * time       (time) object 8B 2000-07-01 00:00:00
    areacello  (yh, xh) float64 346kB dask.array<chunksize=(100, 100), meta=np.ndarray>
  * xh         (xh) int64 2kB 0 1 2 3 4 5 6 7 ... 233 234 235 236 237 238 239
  * yh         (yh) int64 1kB 0 1 2 3 4 5 6 7 ... 173 174 175 176 177 178 179
Attributes:
    provenance:    ['heat_lhs_advection_lateral', 'heat_lhs_advection_interfa...
    xbudget_path:  ['heat', 'lhs', 'advection']
    xbudget_op:    sum

If the variable is not immediately available as a diagnostic–but instead derived from other diagnostics–the provenance attribute points to the origin of the variable.

Note that q.var above and q.get_vars below are asking about the same variable: get_vars additionally reports the terms that feed into it. For variables that are the sum or product of other terms, that list is the decomposition:

q.get_vars("heat_lhs_advection")
{'var': 'heat_lhs_advection',
 'sum': ['heat_lhs_advection_lateral', 'heat_lhs_advection_interfacial']}

The terms from which the variable is derived are not necessarily other xr.DataArray objects–they can also be constant numbers.

q.get_vars("heat_lhs_surface_ocean_flux_advective_negative_lhs")
{'var': 'heat_lhs_surface_ocean_flux_advective_negative_lhs',
 'product': [-1.0,
  3992.0,
  'tos',
  'boundary_forcing_h_tendency',
  1035.0,
  'areacello']}

get_vars takes either a single address or a list of them, and a raw diagnostic name works too (it reports which terms use it):

q.get_vars(["heat_lhs", "heat_rhs_diffusion", "boundary_forcing_heat_tendency"])
[{'var': 'heat_lhs',
  'sum': ['heat_lhs_Eulerian_tendency',
   'heat_lhs_advection',
   'heat_lhs_surface_ocean_flux_advective_negative_lhs']},
 {'var': 'heat_rhs_diffusion',
  'sum': ['heat_rhs_diffusion_lateral', 'heat_rhs_diffusion_interfacial']},
 {'var': 'boundary_forcing_heat_tendency',
  'referenced_by': [('heat', 'rhs', 'surface_exchange_flux')]}]

Verification that the budgets close

We compare the left-hand-side (LHS) tendency terms against the sum of all of the right-hand-side (RHS) terms.

for eq, vmax in zip(["mass", "heat", "salt"], [1.e4, 1e12, 1.e5]):
    plt.figure(figsize=(10, 4))

    plt.subplot(1,2,1)
    pc = grid._ds[q.var(f"{eq}_lhs")].isel(z_l = 0).isel(time=0).plot(vmin=-vmax, vmax=vmax, cmap="RdBu_r")
    plt.title(f"LHS {eq} tendency")

    plt.subplot(1,2,2)
    pc = grid._ds[q.var(f"{eq}_rhs")].isel(z_l = 0).isel(time=0).plot(vmin=-vmax, vmax=vmax, cmap="RdBu_r")
    plt.title(f"RHS {eq} tendency")

    plt.tight_layout()
../_images/c167f44bb763e4560c1606cb1d08daeefc91b589b180ce8196d054b7fe012ac8.png ../_images/0fd144fc5e3d2349fd42f7809e009a13f1c79382f4d368b3fe0098e328a22495.png ../_images/b994a58b55d5fec62ff09374560acaa6f31eb3130bcd348a3bbdf12a0249a904.png

Decompose RHS budget terms with xbudget

Since the full budget is relatively clunky and can be decomposed in a number of different ways, we include helper functions for collecting all of the high-level terms and optionally dissaggreating terms of interest:

# Default high-level aggregate budget
simple_budgets = q.aggregate()
simple_budgets
{'mass': {'lambda': 'density',
  'thickness': 'thkcello',
  'lhs': {'Eulerian_tendency': 'mass_lhs_Eulerian_tendency'},
  'rhs': {'advection': 'mass_rhs_advection',
   'surface_exchange_flux': 'mass_rhs_surface_exchange_flux'}},
 'heat': {'lambda': 'thetao',
  'surface_lambda': 'tos',
  'lhs': {'Eulerian_tendency': 'heat_lhs_Eulerian_tendency',
   'advection': 'heat_lhs_advection',
   'surface_ocean_flux_advective_negative_lhs': 'heat_lhs_surface_ocean_flux_advective_negative_lhs'},
  'rhs': {'diffusion': 'heat_rhs_diffusion',
   'surface_exchange_flux': 'heat_rhs_surface_exchange_flux',
   'surface_ocean_flux_advective_negative_rhs': 'heat_rhs_surface_ocean_flux_advective_negative_rhs',
   'bottom_flux': 'heat_rhs_bottom_flux',
   'frazil_ice': 'heat_rhs_frazil_ice'}},
 'salt': {'lambda': 'so',
  'surface_lambda': 'sos',
  'lhs': {'Eulerian_tendency': 'salt_lhs_Eulerian_tendency',
   'advection': 'salt_lhs_advection',
   'surface_ocean_flux_advective_negative_lhs': 'salt_lhs_surface_ocean_flux_advective_negative_lhs'},
  'rhs': {'diffusion': 'salt_rhs_diffusion',
   'surface_exchange_flux': 'salt_rhs_surface_exchange_flux',
   'surface_ocean_flux_advective_negative_rhs': 'salt_rhs_surface_ocean_flux_advective_negative_rhs'}}}
vmax = 1.e-4

for eq, vmax in zip(["mass", "heat", "salt"], [1.e6, 1e12, 1e4]):
    N = len(simple_budgets[eq]['rhs'])
    plt.figure(figsize=(5*N, 4))
    for i, (k,v) in enumerate(simple_budgets[eq]['rhs'].items(), start=1):
        plt.subplot(1,N, i)
        if "z_l" in grid._ds[v].dims:
            grid._ds[v].isel(z_l = 0).isel(time=0).plot(vmin=-vmax, vmax=vmax, cmap="RdBu_r")
        else:
            grid._ds[v].isel(time=0).plot(vmin=-vmax, vmax=vmax, cmap="RdBu_r")
        plt.title(f"{eq} {k}")

    plt.tight_layout()
../_images/294c8fc931944a09ebec9a03a6bc4ad72e84016dbfba319d5d756e54548bb6bf.png ../_images/6c2835c824421c6e8229aaaced51c6fa3161b13a052eccc8189dfa9fbfff2f5b.png ../_images/d42d1d77ee4a92e0bcfacfe4623a971ff71e501b23a5ea9a31f36521a37a3db2.png

Custom budget decompositions

Suppose we want to have a better understanding of the processes driving both the surface_flux terms and interior diffusion terms. We can simply pass the list of these process names to the decompose keyword argument. We can also further breakdown the nonadvective part of the surface_flux term.

decomposed_budgets = q.aggregate(decompose=["surface_exchange_flux", "nonadvective", "diffusion"])
decomposed_budgets
{'mass': {'lambda': 'density',
  'thickness': 'thkcello',
  'lhs': {'Eulerian_tendency': 'mass_lhs_Eulerian_tendency'},
  'rhs': {'advection': 'mass_rhs_advection',
   'surface_exchange_flux_rain_and_ice': 'mass_rhs_surface_exchange_flux_rain_and_ice',
   'surface_exchange_flux_snow': 'mass_rhs_surface_exchange_flux_snow',
   'surface_exchange_flux_evaporation': 'mass_rhs_surface_exchange_flux_evaporation',
   'surface_exchange_flux_rivers': 'mass_rhs_surface_exchange_flux_rivers',
   'surface_exchange_flux_icebergs': 'mass_rhs_surface_exchange_flux_icebergs',
   'surface_exchange_flux_sea_ice_melt': 'mass_rhs_surface_exchange_flux_sea_ice_melt',
   'surface_exchange_flux_virtual_precip_restoring': 'mass_rhs_surface_exchange_flux_virtual_precip_restoring'}},
 'heat': {'lambda': 'thetao',
  'surface_lambda': 'tos',
  'lhs': {'Eulerian_tendency': 'heat_lhs_Eulerian_tendency',
   'advection': 'heat_lhs_advection',
   'surface_ocean_flux_advective_negative_lhs': 'heat_lhs_surface_ocean_flux_advective_negative_lhs'},
  'rhs': {'diffusion_lateral': 'heat_rhs_diffusion_lateral',
   'diffusion_interfacial': 'heat_rhs_diffusion_interfacial',
   'surface_exchange_flux_nonadvective_latent': 'heat_rhs_surface_exchange_flux_nonadvective_latent',
   'surface_exchange_flux_nonadvective_sensible': 'heat_rhs_surface_exchange_flux_nonadvective_sensible',
   'surface_exchange_flux_nonadvective_longwave': 'heat_rhs_surface_exchange_flux_nonadvective_longwave',
   'surface_exchange_flux_nonadvective_shortwave': 'heat_rhs_surface_exchange_flux_nonadvective_shortwave',
   'surface_exchange_flux_advective': 'heat_rhs_surface_exchange_flux_advective',
   'surface_ocean_flux_advective_negative_rhs': 'heat_rhs_surface_ocean_flux_advective_negative_rhs',
   'bottom_flux': 'heat_rhs_bottom_flux',
   'frazil_ice': 'heat_rhs_frazil_ice'}},
 'salt': {'lambda': 'so',
  'surface_lambda': 'sos',
  'lhs': {'Eulerian_tendency': 'salt_lhs_Eulerian_tendency',
   'advection': 'salt_lhs_advection',
   'surface_ocean_flux_advective_negative_lhs': 'salt_lhs_surface_ocean_flux_advective_negative_lhs'},
  'rhs': {'diffusion_lateral': 'salt_rhs_diffusion_lateral',
   'diffusion_interfacial': 'salt_rhs_diffusion_interfacial',
   'surface_exchange_flux_nonadvective_basal': 'salt_rhs_surface_exchange_flux_nonadvective_basal',
   'surface_exchange_flux_advective': 'salt_rhs_surface_exchange_flux_advective',
   'surface_ocean_flux_advective_negative_rhs': 'salt_rhs_surface_ocean_flux_advective_negative_rhs'}}}
decomposed_budgets[eq]['rhs']
{'diffusion_lateral': 'salt_rhs_diffusion_lateral',
 'diffusion_interfacial': 'salt_rhs_diffusion_interfacial',
 'surface_exchange_flux_nonadvective_basal': 'salt_rhs_surface_exchange_flux_nonadvective_basal',
 'surface_exchange_flux_advective': 'salt_rhs_surface_exchange_flux_advective',
 'surface_ocean_flux_advective_negative_rhs': 'salt_rhs_surface_ocean_flux_advective_negative_rhs'}
vmax = 1.e-4
import numpy as np

for eq, vmax in zip(["mass", "heat", "salt"], [1e6, 1e12, 1e4]):
    nrow = np.int64(np.ceil(len(decomposed_budgets[eq]['rhs'])/2))
    plt.figure(figsize=(12, nrow*4))
    for i, (k,v) in enumerate(decomposed_budgets[eq]['rhs'].items(), start=1):
        plt.subplot(nrow, 2, i)
        if "z_l" in grid._ds[v].dims:
            grid._ds[v].isel(z_l = 0).isel(time=0).plot(vmin=-vmax, vmax=vmax, cmap="RdBu_r")
        else:
            grid._ds[v].isel(time=0).plot(vmin=-vmax, vmax=vmax, cmap="RdBu_r")
        plt.title(f"{eq} {k}")

    plt.tight_layout()
    plt.show()
../_images/af9481284de9deac7afc296921b51befe004a7979dd15782385412c0a6661d22.png ../_images/cb4a8e5ee3d9f12e4951a8953f18d1c67d0a935dbc46d6381eda5aa213ca15e5.png ../_images/64c1c5d13ec22a548f9c633a43441a2e1cc98438429c53fd7fbadf437f79a5a3.png