Non-dimensional models and standardised states
esmax · ocean · 10 · Build a model from dimensionless numbers, and scale its state with the same affine map that the assimilation and the emulators use
Abstract¶
Hand-tuning viscosity, drag and wind amplitude until a run is stable and resolved is the wrong interface to an ocean model. In this page, we build somax models from dimensionless numbers instead, with resolution guards, and we show that non-dimensionalisation and standardisation are one per-field affine map which the model, the assimilation and the emulators share. We use the same Gulf Stream QG example as the other pages.
Where it sits. Infrastructure under every other page: the QG model of pages 2 to 6 and 8 is built this way, the 4D-Var control vector of pages 3, 4 and 9 is flattened through this map, and the learned components of pages 5, 6 and 8 see its O(1) output.
Status. We designed this on 2026-09-09 and it shipped in somax on 2026-09-12 as ten merged PRs (#180 to #189); see Status. Every today block on this page runs against somax main (9cb19b3).
TL;DR: one affine map, three users¶
The right knobs are dimensionless. A double gyre is set by its Rossby number, its planetary-vorticity gradient and the widths of its boundary layers, not by , and in SI units.
from_nondimensionaltakes the first and derives the second.Resolution becomes a check, not a trial. The boundary-layer widths are given in units of , so the factory compares them to before the run and refuses an unresolved Munk layer.
Non-dimensionalisation and standardisation are the same object, which is a per-field affine map on the state pytree. Physics picks from scales, whereas data picks them from sample moments. So one class,
StateAffine, serves both.The same map fixes the assimilation. Flattening m next to m/s into one control vector gives a background covariance which is four orders of magnitude out of balance, whereas flattening through the map gives O(1) entries.
Scale sets are canonical per equation family, not universal: advective for QG, inertial for shallow water, planetary for the sphere, and diffusive for pure diffusion.
paramax keeps parameters physical (positive, bounded) while the optimisers work unconstrained, and flowjax stays optional, for learned priors only.
1. The problem¶
Running example. We take barotropic QG over a Gulf Stream window: a basin of side m at about 40° N, so s⁻¹ and m⁻¹ s⁻¹, with eddy velocities m s⁻¹, on a grid ( km).
Built from SI coefficients, this model asks for a lateral viscosity (m² s⁻¹), a bottom drag (s⁻¹) and a wind amplitude . However, none of the three says what the user cares about: is the western boundary current resolved, is the run inertial or viscous, and will it blow up? The usual loop is to guess , run, watch the boundary current ring or smear, and guess again, and each new grid, basin or latitude restarts the loop.
The state has the same problem from the other side. A shallow-water state stacks (order 103 m) with and (order 10-1 m/s). Flattened as-is for an ensemble filter or a 4D-Var control vector, a single perturbation size moves thickness and velocity by the same absolute amount, and a network trained on it spends its first layer learning the units.
2. Notation¶
Every symbol below is a scalar unless the line says otherwise.
, , and are the length, velocity, depth and time scales. is the reference Coriolis parameter, its meridional gradient, gravity, reduced gravity (often written ), a diffusivity, and and the planet radius and rotation rate.
are the zonal and meridional coordinates (m) and the grid spacing.
A prime marks a dimensionless quantity: , , , and .
is the PV anomaly on T-points, the streamfunction with , and the Arakawa Jacobian (page 7).
is the harmonic lateral viscosity, the linear bottom drag and the wind-stress-curl amplitude.
is the Rossby number, the dimensionless planetary gradient, the Burger number, the Froude number and the Lamb parameter.
, and are the Munk, Stommel and inertial boundary-layer widths in units of .
is a state pytree, and and are pytrees of the same structure (the location and the scale). The affine map is , leaf by leaf.
3. Non-dimensionalisation is a factory problem¶
Reading somax’s right-hand sides showed that none of them knows about units: vector_field only reads grid spacings, constants, parameters and precomputed fields such as and the wind curl. So a dimensionless model needs no change to any dynamics, only a second constructor which chooses and fills the existing create() arguments from dimensionless numbers.
In the advective scale set, and , and the running example’s equation becomes
where is the dimensionless wind amplitude and the dimensionless wind-stress pattern. [CHECK: the source wrote this coefficient as without defining it; we read it as the dimensionless wind amplitude, and wrote where the source had , following the table below.]
| Input | Definition | Running example | Sets |
|---|---|---|---|
rossby | |||
beta_hat | 35 | ||
delta_M | 0.07 (70 km) | ; in SI m² s⁻¹ | |
delta_S | 0.04 (40 km) | ; in SI s⁻¹ | |
delta_I | — (optional) | the wind amplitude, via Sverdrup balance |
Guards. Because and are now both lengths in units of , the factory checks them before returning: check_munk_width fails below 2 cells and warns below 4, and check_stommel_width fails below 1 and warns below 2. The running example has and , so it builds silently, whereas with (3.2 cells) it builds with a warning. check_resolution=False opts out for deliberately coarse runs.
4. Scale sets: canonical per equation family¶
There is no single scale set that suits every model, and the design does not pretend there is. Each model family has one, which is recorded on Scales.kind so that the transforms and the CLI can dispatch on it.
| Family | Constructor | Numbers the factory takes | |||
|---|---|---|---|---|---|
| QG (barotropic, baroclinic, reparameterised) | Scales.advective | basin or eddy scale | given | , , , , , per-mode | |
| Shallow water (1-D, 2-D, multilayer) | Scales.inertial | given | , , per-interface , Ekman numbers | ||
| Spherical SWM and QG | Scales.planetary | , one of / / , Ekman numbers | |||
| pde1d / pde2d (Burgers, Navier–Stokes, convection) | Scales.advective | given | given | , | |
| Pure diffusion | Scales.diffusive | given | (bookkeeping only) | none: (added during implementation; there is no velocity to build from) |
There are two things worth remembering about the sphere:
at latitude , so plays the role of . Every rate coefficient then picks up a factor of 2, as in with the Ekman number.
The mid-latitude deformation radius diverges at the equator. So the guard for global runs is the equatorial radius with and (i.e. ), which is compared against the widest interior cell. There is no on the sphere, as is fixed by geometry.
5. Standardisation is the same map¶
Non-dimensionalising and standardising are both :
where physics takes from the scales and data takes from the sample mean and standard deviation.
So somax ships one class, StateAffine, with forward (), inverse (), compose, and two constructors, from_scales and from_samples. The latter can work per grid point, with a mask, so that land cells of zero variance get . The leaves of and broadcast against the fields, so a multilayer state takes per-layer scales of shape (nl, 1, 1).
In the model. ScaledModel integrates any model in transformed coordinates. For an affine map the chain rule is exact:
where and is the model’s right-hand side, . So it serves both a dimensional model run in nondimensional units and a dimensional model run on standardised states (the ML-baseline case).
In the assimilation. state_to_vector, make_ensemble and the vardax and filterax bridges take an optional transform. Perturbing in the transformed space turns a scalar spread into the per-field covariance
in physical units, where is the scale pytree flattened like the state and . This replaces across fields which are four orders of magnitude apart.
In the exports. apply_scale_metadata writes the scale set and the scalar to dataset attributes under somax:scales:, including , which cannot be recovered from and because the families disagree about it. Re-dimensionalisation happens offline.
6. Parameters and densities: paramax and flowjax¶
We evaluated two libraries for the parameter side.
paramax: adopted as a base dependency. It is two small modules on top of equinox.
positive(x)stores , and in softplus space andinterval(x, lo, hi)stores them in logit space, andparamax.unwrapreconstitutes them at right-hand-side time. So calibration, 4D-Var parameter estimation and hybrid training all descend in unconstrained space and can never produce a negative viscosity. The gradients are with respect to the unconstrained values, and theParamsdocstring says so.flowjax: an optional extra (
somax[flows]). ItsAffineforces the scale through a trainable softplus, which is the opposite of what a fixed physical scale wants, and the log-determinant only matters when transforming densities.StateAffinetherefore exposes the flowjax-compatibletransform_and_log_det/inverse_and_log_det, with over the leaves, andsomax.flows.learned_priorcomposes a normalising flow onto a fixed physical scaling. So the flow only ever sees O(1) numbers, and the prior is still over physical states.
7. Code sketch¶
This is the running example end to end: we build the model from dimensionless numbers, take one step of (2 000 s, since s), redimensionalise, and then flatten and perturb for an ensemble. Here is the model’s flow map, the ensemble spread in transformed units, and the standard normal draws for member .
import jax
import jax.numpy as jnp
from somax import Scales, StateAffine
from somax.da import make_ensemble, state_to_vector
from somax.models import BarotropicQG, BarotropicQGState
# Shapes: Ny = Nx = 66 (64 interior cells + ghost ring); E = 16 members
# today (somax)
# Ro = U/(f₀L), β′ = βL²/U, δ_M = (ν/β)^{1/3}/L, δ_S = κ/(βL)
model, unit_scales = BarotropicQG.from_nondimensional(
nx=64, ny=64, rossby=5.3e-3, beta_hat=35.0, delta_M=0.07, delta_S=0.04,
)
# today (somax)
# L = 10⁶ m, U = 0.5 m s⁻¹, f₀ = 9.4·10⁻⁵ s⁻¹, T = L/U
gulf_stream = Scales.advective(L=1.0e6, U=0.5, f0=9.4e-5)
# today (somax)
# forward: q′ = q/(U/L); inverse: q = (U/L) q′
nondim = StateAffine.from_scales(BarotropicQGState, gulf_stream)
# user
# q′(t′ = 0) = 0
q_rest = BarotropicQGState(q=jnp.zeros((66, 66))) # → (Ny, Nx)
# today (somax)
# q′(Δt′) = Φ_Δt′(q′(0)), Δt′ = Δt/T
q_next = model.step(q_rest, 1.0e-3) # (Ny, Nx) → (Ny, Nx)
# today (somax)
# q = (U/L) q′
q_si = nondim.inverse(q_next) # (Ny, Nx) → (Ny, Nx)
# today (somax)
# x = vec(q/(U/L))
x, unravel = state_to_vector(q_si, nondim) # (Ny, Nx) → (Ny·Nx,)
# today (somax)
# x⁽ᵉ⁾ = x + α ξ⁽ᵉ⁾, ξ⁽ᵉ⁾ ~ 𝒩(0, I), e = 1, …, E
ensemble = make_ensemble( # → (E, Ny·Nx)
q_si, jax.random.key(0), size=16, std=0.2, transform=nondim
)When we run the block (against 9cb19b3), it checks three things: 1) the flattened x equals exactly, so the DA control vector is O(1) while the state handed back by unravel is in s⁻¹, 2) nondim.scale.q is s⁻¹, and 3) the Munk and Stommel guards pass without a warning.
8. Status¶
We wrote the plan below on 2026-09-09, and every PR landed on 2026-09-12.
| PR | somax | Change | Deviation from the plan |
|---|---|---|---|
| 1 | #180 | Scales, StateAffine (from_scales, from_samples, compose) | Scales.diffusive added; Scales.planetary shipped early, as planned |
| 2 | #181 | paramax: positive, interval, unwrap at RHS time, Params docstring | NonTrainable wrappers mask arrays only; interval span is bounded |
| 3 | #182 | BarotropicQG.from_nondimensional + Munk / Stommel guards | Guards live in somax/_src/core/resolution.py, not the CLI assertions; factories return (model, Scales) |
| 4 | #183 | ScaledModel (from_scales, standardised) | — |
| 5 | #184 | from_nondimensional for nonlinear SWM, multilayer SWM, baroclinic and reparameterised QG | — |
| 6 | #185 | from_nondimensional for pde1d / pde2d | — |
| 7 | #186 | transform= through DA flatten, ensembles and both bridges; apply_scale_metadata; CLI nondim: block | CLI rejects a config with both consts: and nondim:, and rejects dimensional knobs that the numbers derive |
| 8 | #187 | somax[flows]: StateAffineBijection, learned_prior | — |
| 9 | #188 | Spherical SWM and barotropic QG models, on finitevolX’s spherical operators | Not in the original plan’s scope for somax: the plan assumed the spherical model classes would arrive separately (epic #72 D4); they were built here instead |
| 10 | #189 | Spherical from_nondimensional + equatorial deformation-radius guard | Stratification given as exactly one of lamb, burger, froude |
The user documentation is somax’s content/notes/nondimensional_runs.md, and the tutorial is content/tutorials/learned_prior_flowjax.py.
9. Decisions¶
| Question | Decision |
|---|---|
| Re-dimensionalise diagnostics? | No. Diagnostics report in the model’s units; ConservationDriftMonitor reports relative drift, which is scale-invariant. Revisit only for dimensional budgets offline. |
| One universal scale set? | No: canonical per equation family (§4). |
| Spherical models? | In scope; shipped (#188, #189). |
Change create() signatures? | No. from_nondimensional composes them; nothing breaks. |
| flowjax as a core dependency? | No; optional flows extra. paramax is core. |
10. What to watch¶
Direction of the map.
forwardis physical → transformed. Passing an already nondimensional state throughstate_to_vector(…, transform)silently scales it twice, so redimensionalise withinversefirst, as the sketch does.Two examples are not a validation pair. somax’s dimensional
doublegyre_bt_qgconfig and its nondimensional counterpart describe different basins ( against ), so a dimensional-versus-nondimensional regression must build both from the same numbers.unwrapinside the RHS rebuilds the parameter pytree at every evaluation. XLA folds it, but a multilayer-SWM benchmark has not been recorded.Per-grid-point standardisation puts arrays the size of a field into . They belong in the data, so the exporter only writes attributes for scalar leaves.
11. Where it lives¶
| Piece | Library | Module |
|---|---|---|
Scales, StateAffine | somax | somax/_src/core/scales.py, somax/_src/core/transforms.py |
ScaledModel | somax | somax/_src/core/scaled.py |
| Resolution guards | somax | somax/_src/core/resolution.py (equatorial guard in somax/_src/cli/_assertions.py) |
from_nondimensional | somax | each model module under somax/_src/models/ |
positive, interval | somax on paramax | somax/_src/core/types.py |
| DA transforms | somax | somax/_src/da/flatten.py, vardax_bridge.py, filterax_bridge.py |
| Learned priors | somax on flowjax (optional) | somax/flows.py |
Nothing here belongs upstream of somax, as scales and dimensionless numbers are properties of the model and not of the C-grid operators.