Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

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.

Keywords:non-dimensionalisationstandardisationscalingsomaxparamaxflowjaxdata assimilation

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

1. The problem

Running example. We take barotropic QG over a Gulf Stream window: a basin of side L=106L = 10^6 m at about 40° N, so f0=9.4⋅10−5f_0 = 9.4 \cdot 10^{-5} s⁻¹ and β=1.75⋅10−11\beta = 1.75 \cdot 10^{-11} m⁻¹ s⁻¹, with eddy velocities U=0.5U = 0.5 m s⁻¹, on a 64×6464 \times 64 grid (Δs=15.6\Delta s = 15.6 km).

Built from SI coefficients, this model asks for a lateral viscosity ν\nu (m² s⁻¹), a bottom drag κ\kappa (s⁻¹) and a wind amplitude τ0\tau_0. 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 ν\nu, 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 hh (order 103 m) with uu and vv (order 10-1 m/s). Flattened as-is for an ensemble filter or a 4D-Var control vector, a single perturbation size α\alpha 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.

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 βs2\beta s_2 and the wind curl. So a dimensionless model needs no change to any dynamics, only a second constructor which chooses L=U=H=1L = U = H = 1 and fills the existing create() arguments from dimensionless numbers.

In the advective scale set, T=L/UT = L/U and f0′=1/Rof_0' = 1/\mathrm{Ro}, and the running example’s equation becomes

∂t′q′+J(ψ′,q′+β′s2′)=δM3β′∇′2q′−δSβ′∇′2ψ′+τ0′curlτ′,\partial_{t'} q' + J(\psi', q' + \beta' s_2') = \delta_\text{M}^3 \beta' \nabla'^2 q' - \delta_\text{S} \beta' \nabla'^2 \psi' + \tau_0' \mathrm{curl} \tau' ,

where τ0′\tau_0' is the dimensionless wind amplitude and τ′\tau' the dimensionless wind-stress pattern. [CHECK: the source wrote this coefficient as τ^\hat{\tau} without defining it; we read it as the dimensionless wind amplitude, and wrote f0′=1/Rof_0' = 1/\mathrm{Ro} where the source had f0=1/Rof_0 = 1/Ro, following the table below.]

InputDefinitionRunning exampleSets
rossbyRo=U/(f0L)\mathrm{Ro} = U/(f_0 L)5.3⋅10−35.3 \cdot 10^{-3}f0′=1/Rof_0' = 1/\mathrm{Ro}
beta_hatβ′=βL2/U\beta' = \beta L^2/U35β′\beta'
delta_M(ν/β)1/3/L(\nu/\beta)^{1/3}/L0.07 (70 km)ν′=δM3β′\nu' = \delta_\text{M}^3 \beta'; in SI ν≈6.0⋅103\nu \approx 6.0 \cdot 10^3 m² s⁻¹
delta_Sκ/(βL)\kappa/(\beta L)0.04 (40 km)κ′=δSβ′\kappa' = \delta_\text{S} \beta'; in SI κ=7.0⋅10−7\kappa = 7.0 \cdot 10^{-7} s⁻¹
delta_I(U/β)1/2/L(U/\beta)^{1/2}/L— (optional)the wind amplitude, via Sverdrup balance

Guards. Because δM\delta_\text{M} and Δs′=1/64\Delta s' = 1/64 are now both lengths in units of LL, 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 δM/Δs′=4.5\delta_\text{M}/\Delta s' = 4.5 and δS/Δs′=2.6\delta_\text{S}/\Delta s' = 2.6, so it builds silently, whereas with δM=0.05\delta_\text{M} = 0.05 (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.

FamilyConstructorLLTTUUNumbers the factory takes
QG (barotropic, baroclinic, reparameterised)Scales.advectivebasin or eddy scaleL/UL/UgivenRo\mathrm{Ro}, β′\beta', δM\delta_\text{M}, δS\delta_\text{S}, δI\delta_\text{I}, per-mode Bu\mathrm{Bu}
Shallow water (1-D, 2-D, multilayer)Scales.inertialgiven1/f01/f_0f0LRof_0 L \mathrm{Ro}Ro\mathrm{Ro}, Fr\mathrm{Fr}, per-interface Buk\mathrm{Bu}_k, Ekman numbers
Spherical SWM and QGScales.planetaryaa1/Ω1/\Omega2ΩaRo2 \Omega a \mathrm{Ro}Ro\mathrm{Ro}, one of ε\varepsilon / Bu\mathrm{Bu} / Fr\mathrm{Fr}, Ekman numbers
pde1d / pde2d (Burgers, Navier–Stokes, convection)Scales.advectivegivenL/UL/UgivenRe\mathrm{Re}, Pe\mathrm{Pe}
Pure diffusionScales.diffusivegivenL2/DL^2/DD/LD/L (bookkeeping only)none: D′=1D' = 1 (added during implementation; there is no velocity to build L/UL/U from)

There are two things worth remembering about the sphere:

5. Standardisation is the same map

Non-dimensionalising qq and standardising qq are both Aℓ,σ\mathcal{A}_{\ell,\sigma}:

q′=Aℓ,σ[q]=q−ℓσ,q' = \mathcal{A}_{\ell,\sigma}[q] = \frac{q - \ell}{\sigma} ,

where physics takes (ℓ,σ)=(0,U/L)(\ell, \sigma) = (0, U/L) from the scales and data takes (ℓ,σ)=(μ^q,σ^q)(\ell, \sigma) = (\hat{\mu}_q, \hat{\sigma}_q) from the sample mean and standard deviation.

So somax ships one class, StateAffine, with forward (A\mathcal{A}), inverse (A−1\mathcal{A}^{-1}), 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 σ=max⁡(σ^,tol)\sigma = \max(\hat{\sigma}, \mathrm{tol}). The leaves of ℓ\ell and σ\sigma 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:

dx′dt′=TσF[σx′+ℓ](t′T),\frac{\mathrm{d}x'}{\mathrm{d}t'} = \frac{T}{\sigma} \mathcal{F}[\sigma x' + \ell](t' T) ,

where x′=Aℓ,σ[x]x' = \mathcal{A}_{\ell,\sigma}[x] and F\mathcal{F} is the model’s right-hand side, ∂tx=F[x](t)\partial_t x = \mathcal{F}[x](t). 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 α\alpha into the per-field covariance

Σx=α2diag(σ2)\gmat{\Sigma}_x = \alpha^2 \mathrm{diag}(\gvect{\sigma}^2)

in physical units, where σ∈RNyNx\gvect{\sigma} \in \mathbb{R}^{N_y N_x} is the scale pytree flattened like the state and Σx∈RNyNx×NyNx\gmat{\Sigma}_x \in \mathbb{R}^{N_y N_x \times N_y N_x}. This replaces α2I\alpha^2 \mat{I} across fields which are four orders of magnitude apart.

In the exports. apply_scale_metadata writes the scale set and the scalar (ℓ,σ)(\ell, \sigma) to dataset attributes under somax:scales:, including TT, which cannot be recovered from LL and UU 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.

7. Code sketch

This is the running example end to end: we build the model from dimensionless numbers, take one step of Δt′=10−3\Delta t' = 10^{-3} (2 000 s, since T=L/U=2⋅106T = L/U = 2 \cdot 10^6 s), redimensionalise, and then flatten and perturb for an ensemble. Here ΦΔt′\Phi_{\Delta t'} is the model’s flow map, α\alpha the ensemble spread in transformed units, and ξ(e)∈RNyNx\gvect{\xi}^{(e)} \in \mathbb{R}^{N_y N_x} the standard normal draws for member ee.

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 q′q' exactly, so the DA control vector is O(1) while the state handed back by unravel is in s⁻¹, 2) nondim.scale.q is U/L=5⋅10−7U/L = 5 \cdot 10^{-7} 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.

PRsomaxChangeDeviation from the plan
1#180Scales, StateAffine (from_scales, from_samples, compose)Scales.diffusive added; Scales.planetary shipped early, as planned
2#181paramax: positive, interval, unwrap at RHS time, Params docstringNonTrainable wrappers mask arrays only; interval span is bounded
3#182BarotropicQG.from_nondimensional + Munk / Stommel guardsGuards live in somax/_src/core/resolution.py, not the CLI assertions; factories return (model, Scales)
4#183ScaledModel (from_scales, standardised)—
5#184from_nondimensional for nonlinear SWM, multilayer SWM, baroclinic and reparameterised QG—
6#185from_nondimensional for pde1d / pde2d—
7#186transform= through DA flatten, ensembles and both bridges; apply_scale_metadata; CLI nondim: blockCLI rejects a config with both consts: and nondim:, and rejects dimensional knobs that the numbers derive
8#187somax[flows]: StateAffineBijection, learned_prior—
9#188Spherical SWM and barotropic QG models, on finitevolX’s spherical operatorsNot 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#189Spherical from_nondimensional + equatorial deformation-radius guardStratification 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

QuestionDecision
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

11. Where it lives

PieceLibraryModule
Scales, StateAffinesomaxsomax/_src/core/scales.py, somax/_src/core/transforms.py
ScaledModelsomaxsomax/_src/core/scaled.py
Resolution guardssomaxsomax/_src/core/resolution.py (equatorial guard in somax/_src/cli/_assertions.py)
from_nondimensionalsomaxeach model module under somax/_src/models/
positive, intervalsomax on paramaxsomax/_src/core/types.py
DA transformssomaxsomax/_src/da/flatten.py, vardax_bridge.py, filterax_bridge.py
Learned priorssomax 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.