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.

From somax to an Earth-system workspace: plan and names

Design · esmax: a core, one package per component, and a coupler

1Motivation: models for short-window data assimilation

esmax is for data assimilation. Its models are the forward models inside an assimilation window, a forecast step or an emulator’s training loop, run over a fixed horizon of hours to weeks (a season at most). Each window stays within the predictability time of the scales the model resolves, so nothing here has to run stably for 100 years, and the requirements that come with long simulations do not apply.

Why short windows. Data assimilation itself is not limited to short horizons. Reanalyses (ERA5, ORAS5) cycle over decades; ECCO fits the MITgcm by strong-constraint 4D-Var over a single multi-decade window starting in 1992; paleoclimate reanalyses assimilate annual-mean proxies into climate-model ensembles. What limits a window is predictability. Past the predictability time of the resolved scales (hours for convection, weeks to months for ocean eddies), the trajectory depends chaotically on the initial state and the 4D-Var cost becomes too non-convex to minimise. So long-horizon state estimation works at coarse scales on slow variables, or on time means, while fine scales are assimilated in many short windows, cycled. esmax targets the second case, at the horizons of the problems it serves: hours for methane plumes, days to weeks for sea-surface-height mapping, weeks for the land surface. Long-horizon state estimation of the ECCO kind is out of scope by choice, not because data assimilation cannot do it.

The model is a prior. In data assimilation the forward model supplies the prior: the background state, the forecast that carries information from one window to the next, and the structure (balance, propagation) that spreads sparse observations through space and time. The same models serve learning as a physics prior for emulators and learned corrections. What esmax is after is good, cheap priors:

This sets the priority for what goes in the package. Anything that makes the prior cheaper or better calibrated comes before anything that makes a long run more faithful: reduced and simple models, tangent-linear and adjoint models (from JAX), model-error terms, learned corrections.

Good is relative. Over a short window a simple dynamical prior competes with purely statistical ones: optimal interpolation, Gaussian processes, and operational products such as DUACS for sea-surface height. Its advantage is propagation: it carries information along Rossby waves and advection to where the observations do not reach. BFN-QG improving on DUACS in sea-surface-height mapping is the kind of evidence that justifies a component. So every component has to pass the same test: with the same observations, it beats the best statistical prior. Without that test, “cheap” may only mean “worse than OI”.

That narrows the scope:

In scopeOut of scope
Horizona window of hours to weeks, and the forecast after itmulti-decade and centennial runs, spin-up to equilibrium
Conservationbudgets closed within the window, to the tolerance the cost function needsdrift-free energy and water over centuries
Physicssimple dynamics that are good enough over the window: shallow water, QG, slab and bucket modelsfull parameterisation suites, CMIP-style coupling
Learned partslearned closures, corrections and forcings, each with a prior—
Speed that mattersgradients through the window (4D-Var), ensembles (EnKF, vmap), batched trainingthroughput of one long trajectory

In a 4D-Var window the controls are the initial state x₀, the learned parameters θ and the forcing f, each with a prior (background x_b, θ_b, f_b; covariances B, Σ_θ, Σ_f). With observations yₖ, observation operators Hₖ, observation-error covariances Rₖ, and xₖ the model state at step k of K:

J(x0,θ,f)=12∥x0−xb∥B−12+12∥θ−θb∥Σθ−12+12∥f−fb∥Σf−12+12∑k=0K∥yk−Hk(xk)∥Rk−12J(x_0, \theta, f) = \tfrac12 \lVert x_0 - x_b \rVert^2_{B^{-1}} + \tfrac12 \lVert \theta - \theta_b \rVert^2_{\Sigma_\theta^{-1}} + \tfrac12 \lVert f - f_b \rVert^2_{\Sigma_f^{-1}} + \tfrac12 \sum_{k=0}^{K} \lVert y_k - H_k(x_k) \rVert^2_{R_k^{-1}}

What follows for the package:

2Decision

somax (“simple ocean model in JAX”) becomes a workspace of simple Earth-system components: a shared core, one package per component (ocean, atmosphere, land, ice), and a coupler on top. Today’s somax becomes the ocean component plus the core; atmosphere comes next; land and ice are added only when they have real code.

Why now:

The workspace keeps the three levels, as stack levels applies them: every component has L1 dynamics on arrays, L2 operators on carriers behind a [geo] extra, and an L3 simulate that takes readable types.

3Names

Recommended: esmax (“Earth-system models in JAX”), as a workspace named like pyrox: one distribution per package, esmax-<part>, imported as esmax_<part>.

OptionUmbrellaFits land and ice?On PyPI (22 Oct 2026)Notes
A. esmax (recommended)esmaxyesfreereads as ESM + JAX; matches the stack’s -ax names (plumax, radtrax, vardax)
B. fluidxfluidxno: land is not a fluidfreeyour first idea; names three of four components
C. keep somaxsomaxnotaken (IRCAM Somax2)could stay a GitHub name only; never installable as somax
D. one name per componentoceax, atmax, landax, icaxyesall freefour unrelated names, no umbrella; the coupler has nowhere obvious to live

Also checked and taken: terrax (Google’s NeuralGCM group, “AI-first Earth System Modeling”) and climax (an argparse wrapper). Only PyPI was checked, not GitHub or conda-forge.

The names under option A:

DistributionImportHoldsTier in the workspace
esmax-coreesmax_coreState, Params, term algebra, domains, steppers, DA adapters, slots for learned closures and forcings with their priors, monitors, xarray I/O0
esmax-oceanesmax_oceantoday’s somax ocean models: shallow water, QG, Burgers toy1
esmax-atmosesmax_atmossimple atmosphere models1
esmax-landesmax_landbucket hydrology, surface energy balance (when there is code)1
esmax-iceesmax_icethermodynamic slab sea ice, then rheology (when there is code)1
esmax-coupleesmax_coupleflux exchange, regridding between components, coupled simulate2
esmaxesmaxcode-free umbrella that installs the set, as geotoolz does—

The GitHub repository jejjohnson/somax is renamed to esmax (GitHub redirects the old URL), and esmax-ocean 0.1.0 is the continuation of somax 0.0.15.

4The workspace

Three tiers inside one workspace: the core at the bottom, one package per component above it, and the coupler on top; arrows point to what a package imports.

esmax-couple         tier 2: flux exchange, regridding,
     │                       sub-cycled steps, coupled simulate
     ▼
esmax-ocean  esmax-atmos  esmax-land  esmax-ice      tier 1
(today's     (next, P2)   (later, P4) (later, P4)
 somax)
     │
     ▼
esmax-core           tier 0: State, term algebra, steppers,
                             DA adapters, monitors,
                             Step [geo], xarray I/O [xarray]

imports below: finitevolX, spectraldiffx, gaussx (GeoNumerics),
vardax, filterax (GeoML), geoarray only behind [geo]

Dashed components start only when they have real code. The whole workspace sits in GeoModels, so it may import GeoML, GeoNumerics and, behind [geo], GeoStack’s carriers.

5Where today’s somax goes

Every somax 0.0.15 module has one destination. Model-agnostic machinery goes to the core; anything that knows it is an ocean goes to the ocean component; duplicates of GeoML go back to GeoML.

somax moduleHolds todayGoes toChange
coreState, Params, term algebra, forcings, Helmholtz cachesesmax-corenone
core spatial basesspatial_from_eof/fourier/rbf/waveletvardaxremoved; import vardax.*_basis (duplicate)
domainDomain, TimeDomainesmax-corenone
solversIMEX KenCarp3, PID step controlesmax-corenone
flowsflowjax bijection, learned prioresmax-core.dagrouped with the DA adapters
daadapters to filterax and vardax, make_ensembleesmax-core.daSubsampleObs removed; use vardax’s observation operators
evalKE, enstrophy, divergence, balance residualssplitgeneric residual machinery to esmax-core.eval; ocean diagnostics to esmax-ocean.eval, renamed off finitevolX’s names
operatorsSomaxModelOp, Burgers2DOpesmax-core.geo; Burgers to esmax-ocean.toysthe generic model operator becomes Step, behind esmax-core[geo]
ioState ↔ xarray Dataset, Zarresmax-core behind [xarray]none
monitorrun-loop monitorsesmax-corenone
modelsthe model classes (not in the capability index)esmax-ocean; any atmosphere models to esmax-atmossplit by component; the split is inferred until the module is read

Nothing moves into GeoNumerics: finitevolX and spectraldiffx stay the stencils and solvers every component imports.

6Each component at L1, L2 and L3

Every component has the same three-level shape, so a user who knows one knows them all.

ComponentL1 (arrays)L2 ([geo], carriers)L3 (readable types)
oceanshallow water, QG, tracer advection on a C-gridStep on GeoArray["y x"] statessimulate(GeoMap) -> GeoImageSeries
atmosphere2-layer QG, shallow water on the spheresamesame
landbucket hydrology, surface energy balance per columnStep on GeoArray["y x"] soil-moisture mapssame
icezero-layer thermodynamic slab ice, then EVP rheologyStep on ice-thickness mapssame
couplerbulk flux formulae, regridding weights—coupled simulate over several components

The model lists for atmosphere, land and ice are starting points, not commitments.

Demo API: the ocean component. Sketches of the target API.

# L1, esmax_ocean: a model on arrays and a grid bundle
grid: CGrid = finitevolx.grids.cartesian(nx=256, ny=256, dx=4_000.0, dy=4_000.0)
model: esmax_ocean.ShallowWater = esmax_ocean.ShallowWater(
    depth=4_000.0, dt=600.0, grid=grid
)
state: esmax_core.State = model.init(eta0)  # eta0: Float[Array, "Ny Nx"]
# ∂η/∂t = −H∇·𝐮 ; ∂𝐮/∂t + f𝐳×𝐮 = −g∇η, one IMEX step
state_next: esmax_core.State = model.step(state)

# L2, esmax_core[geo]: one step on a carrier; the grid comes from its transform
step: Operator = esmax_core.geo.Step(model)
eta_next: GeoArray["y x"] = step(eta_map)  # eta_map: GeoArray["y x"]

# L3: a run, readable types in and out; model.l1 is the raw loop for runners and vmap
series: GeoArray["time y x"] = esmax_ocean.simulate(eta_map, times, model)

# xarray I/O lives in the core, behind [xarray]
ds: Dataset["time y x"] = esmax_core.io.to_dataset(series)

Demo API: land, once it exists. Same shape, different physics.

# L1, esmax_land: a bucket model per column, vectorised over the grid
bucket: esmax_land.Bucket = esmax_land.Bucket(capacity=0.15, dt=3_600.0)
# ∂W/∂t = P − E − R, R = max(0, W − W_max) / Δt
w_next: Float[Array, "Ny Nx"] = bucket.step(w, precip, evap)

# L3: driven by ERA5 forcing, read by value first
forcing: Dataset["time lat lon"] = xrproducts.cds.open(
    "era5_single_levels", vars=["tp", "e"], region=iberia, year=2025
)
soil: GeoArray["time y x"] = esmax_land.simulate(w0_map, forcing, bucket)

7The coupler

The coupler is where the work is: components exchange fluxes at their interface, on different grids and at different time steps. It sits one tier above the components, so no component imports another.

JobL1 primitiveNotes
momentum and heat fluxesbulk formulae on arrayswind stress τ = ρₐ C_D |U| U; sensible heat Q = ρₐ cₚ C_H |U| (T_s − Tₐ)
regridding between componentssparse weights applied as einx.dotweights computed once at set-up from the two grids (derived geometry); conservative for fluxes, bilinear for states
time stepssub-cycling inside one lax.scan stepthe slower component steps once per k fast steps; k fixed at set-up so one trace serves the run
conservationbudget residuals across the interfacea coupled run must conserve the exchanged energy and water to round-off
# L1, esmax_couple: wind stress on the atmosphere grid, then onto the ocean grid
# τ = ρₐ C_D |U| U, each component (Na,)
speed: Float[Array, "Na"] = jnp.hypot(u_atm, v_atm)
tau_x_atm: Float[Array, "Na"] = RHO_AIR * C_D * speed * u_atm
# conservative remap with precomputed weights W, (No, Na)
tau_x_ocn: Float[Array, "No"] = einx.dot("o a, a -> o", w_atm_to_ocn, tau_x_atm)

# L3, esmax_couple: a coupled run, readable types in and out
coupled: esmax_couple.Coupled = esmax_couple.Coupled(
    ocean=ocean_model, atmos=atmos_model, substeps=6
)
result: CoupledSeries = esmax_couple.simulate(
    coupled, ocean_state=sst_map, atmos_state=wind_map, times=times
)

Start with the smallest useful coupling: an atmosphere over a slab ocean, before any dynamic ocean–atmosphere pair.

8Phases and gates

Each phase ships on its own and merges only when its gate passes.

PhaseScopeGate
P0 Rename and splitrepo somax → esmax; workspace with esmax-core and esmax-ocean; the module moves above; vardax bases and observation operators imported, not copiedevery somax 0.0.15 test passes under esmax-ocean with only import changes; esmax-core imports no component
P1 Levels_src/array.py per component; Step and simulate behind [geo]; I/O behind [xarray]the import check passes (primitive modules import no carrier); simulate exposes model.l1
P2 Atmosphereesmax-atmos from somax’s atmosphere models, or a 2-layer QG to startruns and tests alone, with only esmax-core installed beside it
P3 First couplingesmax-couple: atmosphere over a slab oceanexchanged energy is conserved to round-off over a 30-day assimilation window, and the coupled model stays stable over a cycle of windows
P4 Land, then icewhen there is real code for themeach component runs and tests alone

P0 and P1 change no science; P2 onwards adds it. Every component’s gate also includes the benchmark test: with the same observations, it beats the best statistical prior. For the ocean component that is a QG model against DUACS on a sea-surface-height mapping benchmark, run as a baseline in P0 and kept as a regression test. geostack’s scripts/dependency_graph.py gets the new packages and their tiers in P0.

9Open questions

IDQuestionRecommendedAlternative
ES-N1Umbrella nameesmaxfluidx (free, but land is not a fluid)
ES-N2One distribution per component, or one distribution with subpackagesone per component, like pyroxone esmax distribution with esmax.ocean etc.: simpler releases, but every user installs every component
ES-N3When land and ice startwhen there is real code (P4)stubs now, which xrtoolz’s empty ice module shows go stale
ES-N4plumax’s atmospheric transport (les_fvm)stays in plumax; it may later run on esmax-atmos windsmoves into esmax-atmos as its tracer component
ES-N5Synthetic climates (xtremax.simulations)not in esmax: a statistical weather generator is not a fluid model (see stack levels, SL-O6)a stochastic component in esmax

Evidence: somax’s modules come from the GeoModels capability index (somax 0.0.15); the name checks are PyPI lookups on 22 Oct 2026.