Adding a GCM#
In Fortran TRACMASS, supporting a new model means writing a project folder
with setup_grid.F90 (grid metrics, kmt) and read_field.F90 (reading
velocities and turning them into fluxes), then recompiling. In
TRACMASS-JAX it means writing one Python function, fluxes(dtm), which
returns one snapshot as an xarray.Dataset. This tutorial writes a
complete adapter for an analytical eddy, then shows what changes for real
model output.
1. A minimal adapter#
Save this as eddy.py. It describes solid-body rotation on a closed
60 × 60 km box with 1 km cells, one turn every five days, and 6-hourly
“snapshots”:
"""A solid-body-rotation eddy on a closed 60 x 60 x 1 box."""
import numpy as np
import pandas as pd
import xarray as xr
IMT, JMT, KM = 60, 60, 1
DX = DY = 1000.0 # m
DZ = 10.0 # m
OMEGA = 2 * np.pi / (5 * 86400.0) # one turn every five days
TIMESTEP = pd.Timedelta(6, "h")
DTM0 = pd.Timestamp("2020-01-01")
def fluxes(dtm=None) -> xr.Dataset:
"""Return one snapshot of volume fluxes at time *dtm*."""
dtm = DTM0 if dtm is None else pd.Timestamp(dtm)
# Cell-centre coordinates in metres from the eddy centre.
xc = (np.arange(IMT) + 0.5 - IMT / 2) * DX
yc = (np.arange(JMT) + 0.5 - JMT / 2) * DY
# Solid-body rotation, u = -omega*y on east faces, v = omega*x on north faces
u = -OMEGA * yc[None, :] * np.ones((IMT, 1))
v = OMEGA * xc[:, None] * np.ones((1, JMT))
# Volume fluxes (m3/s), with a trailing size-1 time axis: (imt, jmt, km, 1)
uflux = (u * DY * DZ)[:, :, None, None]
vflux = (v * DX * DZ)[:, :, None, None]
uflux[-1] = 0.0 # closed east wall
vflux[:, -1] = 0.0 # closed north wall
return xr.Dataset(
{
"uflux": (("x", "y", "z", "t"), uflux),
"vflux": (("x", "y", "z", "t"), vflux),
"dzt": (("x", "y", "z", "t"), np.full((IMT, JMT, KM, 1), DZ)),
"zstot": (("x", "y", "t"), np.ones((IMT, JMT, 1))),
"kmt": (("x", "y"), np.full((IMT, JMT), KM, dtype=np.int32)),
"dxdy": (("x", "y"), np.full((IMT, JMT), DX * DY)),
"imt": IMT, "jmt": JMT, "km": KM,
"iperio": 0, "jperio": 0,
"timestep": TIMESTEP,
"dtm": dtm,
"reverse": False,
}
)
Run one particle 10 km east of the centre for one turn:
import numpy as np
import tracmass
from tracmass import seed
import eddy
grid, lazy = tracmass.new_flux(eddy.fluxes, dtm_start="2020-01-01",
dtm_end="2020-01-06") # 5 days = 20 windows
state = seed.ijk(40, 40, 30, 30, 1, 1, grid=grid)
result = tracmass.time_dependent(state, grid, lazy, iter_sub=4,
recorder="eddy.parquet")
df = tracmass.load("eddy.parquet")
r = np.hypot(df.x - 30, df.y - 30)
print(r.min(), r.max()) # 9.513... 9.529...: the radius is conserved
The particle stays on its circle and comes back to where it started after five days.
2. The dataset contract#
new_flux reads these variables from the dataset:
Variable |
Dims |
Notes |
|---|---|---|
|
|
Flux through the east face of each cell, m³ s⁻¹, positive eastward |
|
|
Flux through the north face, positive northward |
|
|
Optional. Flux through the top face of each level (level 0 at the surface), positive upward, as NEMO and MITgcm store it. |
|
|
Layer thickness, m |
|
|
Free-surface stretching factor; 1 for fixed levels |
|
|
Ocean levels per column, 0 on land |
|
|
Cell area, m² |
|
scalars |
Grid size and periodicity |
|
scalar |
|
|
scalar |
|
|
scalar |
Always |
Rules to follow:
Axis order
x, y, z(longitude first). Transpose withds.transpose("x", "y", ...)if your source is(z, y, x).Add a trailing size-1
taxis to the time-varying fields, as above.new_fluxremoves a trailing size-1 dimension, so without ataxis a one-level grid (km = 1) would lose itszaxis.Fluxes, not velocities: multiply by the face area.
No NaN on purpose: NaN and fill values in the fluxes are replaced by 0 (no flow) when the data is read, but
kmtmust be 0 on land.Forward fluxes only: backward runs are handled by
new_flux.dtm=Noneshould give a sensible default date, which is used whendtm_startis not given.The
xandydimensions must have the same name in every variable, or xarray will try to align them. Use.dataon geometry arrays from another file, as the GLORYS adapter does.
3. Real model output#
For a real model, the adapter opens the model files lazily and builds the
fluxes from the velocities and grid metrics. The structure of
tracmass.gcm.glorys.fluxes is a good template:
def fluxes(dtm=None, include_wflux=True):
vels = seagrid.mymodel.open_dataset(dtm) # lazy, Dask-backed
sub = _subset_isel() # settings["subset"]
vels = vels.isel(**sub)
grd = seagrid.mymodel.grid_definition().isel(**sub) # dx, dy, dz, kmt
ds = xr.Dataset({
"uflux": vels.u * grd.dzt.data * grd.dyu.data,
"vflux": vels.v * grd.dzt.data * grd.dxv.data,
"kmt": grd.kmt, "dxdy": grd.dxdy,
"dzt": grd.dzt.expand_dims("t", axis=-1),
"timestep": pd.Timedelta(1, "D"), "dtm": pd.Timestamp(dtm),
"reverse": False,
})
if include_wflux:
ds["wflux"] = vels.w * grd.dxdy.data
ds["km"], ds["jmt"], ds["imt"] = ds.uflux.shape[:3]
ds["iperio"], ds["jperio"] = 1, 0
ds["zstot"] = grd.dxdy * 0 + 1
ds.attrs.update(subset_offset_attrs(settings["subset"]))
return ds.transpose("x", "y", ...)
Things to get right:
Staggering. TRACMASS expects uflux[i] on the east face and
vflux[j] on the north face of cell (i, j). NEMO already stores them
there. MITgcm stores U on the west face and V on the south face, so the
scrippsgog adapter shifts them by one face. A one-cell shift is easy to
miss and makes coastal particles freeze. tests/test_glorys_alignment.py
shows how to test for it: the face flux must be zero exactly where a
neighbouring cell is land.
Halos. Remove halo or ghost columns from velocities and grid metrics consistently. In GLORYS the velocity Zarrs and the mesh files have different halos.
Other Arakawa grids. Interpolate A- or B-grid velocities onto the C-grid faces first. The Fortran grid setup tutorial gives the averaging formulas.
Laziness. Keep the fields as Dask arrays and avoid .values. Mask fill
values after cutting the subset, so only the chunks you need are touched.
Subsets. Keep a module-level settings = {"subset": {...}} dict with
the keys i1, i2, istep, j1, j2, jstep, k1, k2, kstep. new_flux fills it
from [env.gcm] subset before the first call. Record the subset start with
ds.attrs.update(subset_offset_attrs(...)) so saved positions are
absolute. Set iperio = 0 when the subset no longer wraps around.
Chunk hints. If the data is in Zarr, set
ds.attrs["_zarr_chunk_x"] and ["_zarr_chunk_y"] to the storage chunk
sizes. time_dependent_patched then lines its patches up with them.
4. Optional extras#
Add these functions to the module to enable more features:
def lonlat_grid(grid=None):
"""Cell-centre (lon, lat), each (imt, jmt), for the grid's subset."""
def lonlat_to_xy(lon, lat):
"""Geographic -> 0-based fractional model positions (full grid)."""
def xy_to_lonlat(x, y):
"""Model positions -> geographic (full grid)."""
tracmass.trajs.lonlat_to_xy and xy_to_lonlat do the work for any grid
with 2-D lon/lat arrays (see Coordinates).
With these, seed.latlon_box and the other lat/lon seeders and
tracmass.load(lonlat=True) work for the new model.
5. Making it available by name#
To use [env.gcm] name = "mymodel" and new_flux("mymodel"), put the
module in src/tracmass/gcm/mymodel.py (with the data-access code in
src/tracmass/seagrid/mymodel.py) and import it in
src/tracmass/gcm/__init__.py. Outside the package you can always pass
the function itself: new_flux(mymodel.fluxes, ...).
Add a test that builds the grid, checks the face alignment against kmt,
and runs a few windows. tests/test_scrippsgog.py is a compact example.