Authoring a Model#
A Model is a parameterized symbolic PDE: a state vector Q, parameters
p, and operator methods (flux, source, nonconservative matrix, …)
returning sympy expressions over Q and p. The class owns the
derivation graph: every assumption, substitution, and projection that
produced the operators is recorded by .apply(...) and replayed via
.describe(). The operator-form sibling that solvers and analysis
consume is the SystemModel; see system-model.md.
Model and SystemModel are independent siblings, not inherited:
SystemModel.from_model(m) extracts the operators once and freezes them.
The pipeline#
Model ───────────▶ SystemModel ───────────────▶ NumericalSystemModel ──────▶ Solver
symbolic frozen operators + numerics specs fvm runtime
derivation graph F, P, B, S, M Riemann / reconstruction (NumPy is the
.apply(...) SystemModel.from_model(m) .from_system_model(sm, …) reference; other
backends port from it)
Code printers (transformation/to_c.py, to_amrex.py, to_ufl.py, …) and
analysis (dispersion, hyperbolicity) both hang off SystemModel. The
NumericalSystemModel stage is in numerics.md; the solver in
../backends/numpy.md.
New to Zoomy? Read this page, then SystemModel → Numerics → a backend (NumPy is the reference).
Build from the existing blocks — reuse, don’t reinvent#
Zoomy models are composed from framework blocks, not hand-rolled. A model
never carries its own solver, time loop, or ad-hoc parameter, and is never
patched with a flag, an if-branch, or a private attribute — those bypass the
GUI/CLI introspection and the tagging system (an untagged term silently returns
zero). Before adding new structure, be explicit about two things: (a) where it
fits — which existing block absorbs it — and (b) what is genuinely new and
cannot. Then:
A new model = subclass the closest existing one and compose closures. e.g.
MalpassetSWE(SWE)(adds Manning + eddy viscosity),KESME(SME),ElderSME(SME). Add physics with aClosurein theclosures=list (model/models/closures.py), not by editing the parent.A new scheme = a
Numerics/Closuresubclass, or a different spec on the NumericalSystemModel — beside its siblings, not a fork.Configure via
paramknobs and derivation-time tags, not runtime branches: a plain attribute that bypassesparamdisappears from the GUI/CLI; an untagged term returns zero.Fix root causes. Hooks, adapters, aliases (
Old=New),from_Xfactories, translation maps, andhasattr/getattr(…, None)“graceful no-op” fallbacks are the smell of dodging the real change. Break, don’t skip — let missing wiring fail loudly.Find the existing thing first:
model/models/for a model, the Riemann / reconstruction lists for numerics,analysis/__init__.pyfor analysis, a closetutorials/example, thengit log -- <area>(many primitives were refactored; the old form may be in history).
Class skeleton#
from zoomy_core.model.basemodel import Model
class MyModel(Model):
dimension = 1
variables = ["h", "qU"]
aux_variables = ["b_x"]
parameters = {"g": (9.81, "positive"), "nu": (1e-6, "positive")}
variables, aux_variables, parameters may be an int (auto-named
q_0, q_1, ...), a list of names, or a dict mapping name to
(default, "positive" | "real"). They are parsed into Zstruct containers
of real sympy Symbols. See library/zoomy_core/zoomy_core/model/basemodel.py.
Operator methods return ZArray instances — build a sympy.Matrix of the right
shape, assign the non-zero entries, and wrap it:
def flux(self):
u = self.variables.u
F = sympy.Matrix.zeros(self.n_variables, self.dimension)
F[0, 0] = u**2 / 2 # inviscid Burgers
return ZArray(F)
The shape contract:
Method |
Shape |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Override surface#
Time evolution: mass_matrix only when non-canonical (default I); the
closure pipeline calls InvertMassMatrix before any solver sees the
system. update_variables / update_aux_variables are the per-step state
map (default: identity).
Spatial operators: flux (conservative divergence),
nonconservative_matrix B · ∂_x Q, hydrostatic_pressure (held separate
from flux so well-balanced reconstruction reads it off), and the two
diffusion slots diffusion_matrix (implicit, at Qnp1) /
diffusion_matrix_explicit (explicit, at Qn, parabolic CFL). All are
pure (Q, Qaux, p); state derivatives enter through Qaux.
Source: source (implicit, at Qnp1) and source_explicit (explicit,
Forward-Euler at Qn); an IMEX backend evaluates each at its own stage.
Spectrum: eigenvalues is optional — the default derives the spectrum from
the flux Jacobian symbolically (for Burgers it returns [n0·u], the
normal-projected wave speed, with no author effort). Override it only to supply a
closed form; or set eigenvalue_mode = "numerical" to defer to
np.linalg.eigvals at runtime (numerics.md).
Conditions — mind the singular/plural split. The overridable methods
initial_condition(self) / boundary_conditions(self) (no runtime args) are the
symbolic-default surface used by codegen; both default to a sensible no-op. The
runtime initial and boundary data are passed to the constructor as the
plural kwargs:
m = MyModel(
initial_conditions=IC.UserFunction(lambda x: ...), # x: position array → state
boundary_conditions=BC.BoundaryConditions([BC.Extrapolation(tag="left"), ...]),
)
aux_boundary_conditions defaults to per-tag Extrapolation. Full signatures
live in library/zoomy_core/zoomy_core/model/basemodel.py.
Zstruct dot-access#
m.variables.h # sympy Symbol
m.parameters.g # sympy Symbol on the symbolic side
m.parameters.g = 9.81 # numeric override on the user-facing struct
m.aux_variables.b_x
m.variables["h"] works but is reserved for runtime-string keys.
Q vs Qaux — primary state and everything derived#
The solver advances Q, the primary conserved state (variables,
group "model"). Qaux holds everything the operators need that is not
itself integrated in time: eliminated fields, topography, and — critically —
spatial derivatives of the state.
In a declarative model
Qis the set of unknowns that still appear in a primary equation; a field leavesQforQauxthe moment it has been fully substituted out (e.g.pafter the hydrostatic reduction,w̃after the ω-elimination). This is automatic — do not hand-maintain the split.A hand-written model declares the split directly:
variables=[...],aux_variables=[...].
Runtime derivatives are aux, not magic. When SystemModel.from_model freezes
the operators, expose_aux_atoms() scans every matrix and replaces each spatial
Derivative(field) atom with a fresh aux Symbol (named dhdx, dq0dx, …),
appended to aux_state and recorded in sm.aux_registry with a multi_index
(the per-axis derivative orders, matching LSQMesh.compute_derivatives). The
solver fills every derivative-aux in one least-squares gradient pass each step —
you never write a finite-difference stencil. Topography b(t,x) and TVD-limited
gradients (limit(...), see numerics.md) are exposed the same way
(kind = "function" / "limited_derivative"). After from_model, solvers walk
aux_registry and never re-scan the operator matrices.
3-D lift — interpolate_to_3d / project_from_3d#
A depth-averaged state is a reduced description of a 3-D column. The two inverse maps make the reduction explicit, and they are what couples Zoomy to a 3-D solver (the preCICE exchange in openfoam.md is literally these two functions):
interpolate_to_3d()reconstructs the canonical profile column[b, h, u, v, w, p]at a vertical coordinateζfrom the state.project_from_3d()is its Galerkin inverse: a sampled 3-D profile → state.
A derived model populates them in derive_model by stashing _interpolate_rows /
_project_rows, or component-wise via register_group(slot, index, relation)
(model/derivation/model.py). The wiring is self-contained in the state: SME
inlines its ŵ_j closure and the conservative change of variables so that
interp[4] = w(ζ) = Σ_j ŵ_j(q, ∂_x q, ∂_x h, ∂_x b)·φ_j(ζ) depends only on Q
and its derivatives.
Jacobians#
All Jacobians are symbolic, computed by sp.derive_by_array — you never
supply one:
quasilinear_matrix=∂F/∂Q + ∂P/∂Q + B(the flux Jacobian is computed inline here; there is no separateflux_jacobianmethod).source_jacobian=∂S/∂Q(and a separate∂S/∂Qaux).
On the frozen SystemModel these recompute automatically in __post_init__ and
after any change_state_variables. The one opt-out is eigenvalues: see
User-supplied functions in numerics.md for the
eigenvalue_mode = "numerical" path that defers the spectrum to np.linalg.eigvals
at runtime.
Boundary conditions — per-field routing#
boundary_conditions / aux_boundary_conditions are BoundaryConditions
containers (or a flat per-field list). Each tag is resolved into a
PerFieldBoundary by resolve_and_attach(sm, bcs, aux_bcs): an on= selector
routes a BC to specific state slots ("all", an exact name, a family base
q → q_0,q_1,…, or an alias like momentum → q); unclaimed slots default to
Extrapolation. The BC types are Extrapolation, Dirichlet, Flux
(Neumann/Robin ∂Q/∂n = gradient), Wall (reflects only the normal momentum
component), FromModel (a model-derived boundary:<name> group), Periodic,
and Coupled (the preCICE patch). Route per-field BCs through
resolve_and_attach, never a raw Dirichlet(on=field) inside a flat list — the
latter zeroes the whole ghost cell including h. The moving-interface kinematic BC is not a BC
class — it is the derivation-time KinematicBC operation above.
Parameters#
The parameters dict becomes two parallel Zstructs: self.parameters (sympy
Symbols carrying the "positive"/"real" assumption) and
self.parameter_values (numeric defaults). Construction fails loudly if any free
symbol in the equations lacks a default. Override a value numerically with
model.parameter_values.g = 12.0; it is carried to the SystemModel and lifted
into the runtime parameter argument at the lambdify boundary. Declarative models
may declare lazily with model.parameter("nu", 0.0).
Derivation via apply()#
model.apply(operation) records the operation for .describe() and transforms
the equations in place. A depth-averaged model is derived, not typed out: you
start from the 3-D balances, reduce them with a sequence of operations, and the
final operator form drops out. Two operator surfaces exist and it matters which
you use:
model/derivation/— the live surface. Every current model (SME, VAM, ML-SME, ML-VAM, KE-SME, …) is built from these. Prefer them.model/operations.py— the legacy surface. A large older library (DepthIntegrate,ApplyKinematicBCs,EvaluateIntegrals,AffineProjection,SigmaTransform,IntegralTransform,MapBasisToReference,SimplifyIntegrals,ProjectBasisIntegrals,Expand). The current models do not call these — use themodel/derivation/equivalents below. Only four leaf ops are still imported from here (Multiply,ProductRule, the verticalIntegrate/IntegrateZ, andKinematicBC).
Operation catalogue (model/derivation/)#
Coordinate / σ-transform — derivation/transformations.py
PDETransformation(coord_map, *, decorate="tilde")— the σ-map. Sends the physical verticalzto the referenceζ ∈ [0,1]through an explicit geometryz = b(t,x) + h(t,x)·ζ, chain-ruling every∂_t, ∂_x, ∂_zacross the whole model. (Replaces the legacySigmaTransform/AffineProjection.)
Abstract integration / Galerkin projection — derivation/projection.py
Integrate(var=None, bounds=(0,1))— wrap each additive term in an unevaluated∫, and stop. No fundamental-theorem step, no evaluation.Project(test_function, var=None, bounds=(0,1))— Galerkin project:Multiply(test_function)then integrate over(ζ, 0, 1).ExpandSums()— expand products/powers of unevaluatedSumfactors into one multi-indexSumwith distinct dummies (cross terms kept).EvaluateSums()— unroll every finiteSumto explicit modes.PullConstants()— pull every factor independent of a binder out of∫and∂.ExtractBrackets(basis, var=None)— replace∫(φ-product)with a named Galerkin bracket atom⟨…⟩, pulling ζ-independent factors out first.ResolveBasis(basis, var=None)— resolve every bracket to a number against a concrete basis (thin op overBasisfunction.resolve).
Modal separation of variables — derivation/modal.py
separation_of_variables(field, coeff, basis, order)(opSeparationOfVariables) — replace a field by its modal sumf = Σ_i f̂_i φ_i(ζ)and swap the unknown family inQ.
Mode-axis resolution — derivation/model.py
ResolveModes(index, modes)— the Galerkin moment-axis shape bump: turn an equation into an indexedMomentFamilyovermodes. Promotesm.Xin place, so captured references to a moment family go stale — re-fetch after applying.
Algebra / closure — derivation/closure.py
Resolve(test_function, basis_cls, level)— project-and-resolve in one step.ResolveIntegral(basis_cls=None, *, method="auto", level=None)— resolve the unevaluated∫…dζleft by an abstract projection, by a selectable method.GaussQuadrature(var=None, order=8)— replace survivingIntegralatoms with an n-point Gauss–Legendre sum. The numerical escape hatch for non-analytic integrands (Bingham, k–ε eddy viscosity).InvertMassMatrix(time=None)— invert the Galerkin mass matrix by reading it off the equation. The canonical finaliser of every derivation. Lives inderivation/closure.py, notoperations.py.FoldConservative(flux_fields, *, h, b, x, gravity=None)— re-bundle a momentum row’s spatial part into the Kowalski–Torrilhon conservative form.Split(variables=None)— fold a self-product fluxc·f·∂_x f → ∂_x(c·f²/2).Consolidate()— foldM·a·∂b + M·b·∂a → M·∂(ab)(recover conservative grouping), also inside integrals.Simplify(sort=True)— canonicalise while keeping fluxes as fluxes.AutoTag(detect_ncp=True)— tag each untagged term with its physics category (time_derivative / flux / diffusion / nonconservative_flux / source).SortByTag()/Sort(detect_ncp=True)— reorder terms into the canonicalTAG_ORDER;Sort=AutoTagthenSortByTag. Apply last.
System solve / variable swaps — derivation/operations.py
SolveFor(variable)— reorient an equation intovariable = solution, in place.SolveLinearSystem(equations, variables)— assemble and solve a coupled linear system for a family of variables;.solve()returns the substitutable solution. This is how SME closes the vertical-velocity coefficientsŵ_j.ChangeOfVariables(old, new, relation)— coefficient-family change of variables that swaps the unknown family (the conservative CoVû_i → q_i/h).
Leaf ops still imported from model/operations.py
Multiply(factor, outer=False)— scale every term;outer=Truewith a Zstruct of test functions promotes a leaf to a Zstruct of children.ProductRule(variables=None, direction="inverse")— product rule, auto-routed per term by the outermost shape (inversefoldsa∂b + b∂a → ∂(ab)).Integrate(var, lower, upper, method="auto")(imported asIntegrateZ) — integrate each term over[lower, upper]in a real coordinate; used for the vertical hydrostatic-pressure integral.KinematicBC(state, interface, *, at=None, mass_flux=None)— the moving-interface kinematic BC.atdefaults tointerface; set it for σ-transformed systems and per-layer ML σ values.mass_flux=Noneis impermeable; a sympy expression adds the interface transfer fluxmass_flux/ρto the RHS (the multilayerG).
Physics closures (model/models/closures.py)#
Constitutive physics is never typed into a model — it is composed as a list
of Closure ops and injected at the projected-momentum stage by
apply_stress_closures(closures, m, axes, state, horiz). Each Closure declares
the parameters it needs (register), validates its requirements (check), and
supplies either a bulk constitutive stress (expression) or a boundary traction
(traction). Add physics by adding a closure to the list, never by editing the
parent model.
Closure |
|
Role |
|---|---|---|
|
bulk |
|
|
bulk |
k–ε eddy viscosity |
|
bulk |
regularised viscoplastic |
|
bulk |
parabolic eddy viscosity |
|
bottom |
linear bed slip |
|
bottom |
turbulent quadratic bed drag `τ_b = ρ C_f u_b |
|
bottom |
Manning bed friction `−g n² |
|
horizontal |
horizontal eddy-viscosity mixing for depth-averaged SWE |
|
surface |
free surface |
|
interface |
multilayer transfer velocity |
Bingham and KEpsilonViscosity produce non-polynomial integrands, so the host
model must be built with quadrature_order > 0 (it routes through
GaussQuadrature). The multilayer variants use apply_layer_stress_closures(...)
and interface_closure(...) instead.
describe()#
print(m.describe(derivation="mermaid")) # graph TD block; verbose=True for one edge per apply()
print(m.describe(derivation="markdown")) # parent / operations / self
The rendered diagram lives on the SystemModel page.
Worked derivation — SME step by step#
SME.derive_model (model/models/sme.py) is the canonical example: the Shallow
Moment Equations derived from the incompressible 3-D balances. Read it top to
bottom — every reduction is one tracked apply(...). The thirteen stages:
Parameters & dimension. Defaults
g, ρ, ν, λ_s, e_x; user overrides merged.dimension=2 → (t,x,z)with one horizontal momentum;dimension=3 → (t,x,y,z)with two. Buildm = DModel(coords, parameters).Assemble the 3-D system from blueprints (
model/models/equations.py):declare_state(h), a trivial bed equation∂_t b = 0, incompressibleMass(m)(registersu[,v], w), full-stressMomentum(m)(registersp,τ_*), thenmoment_scaling(m, mom)drops the in-plane stresses — the one shallow simplification — keeping onlyτ_xz[, τ_yz].Kinematic BCs. Build
kbc_topatb+handkbc_botatb(KinematicBC).Hydrostatic pressure. On the
z-momentum row drop the inertial vertical terms,IntegrateZ(z, z, b+h, "analytical"), imposep(b+h)=0,solve_for(p), broadcast the hydrostatic columnρg(h+b−z)into every horizontal momentum, remove thez-row,Simplify().σ-map the whole model:
PDETransformation({z: (ζ, Eq(z, b + h·ζ))})— move to the fixed reference columnζ ∈ [0,1].Set up the basis:
Basis(symbol="phi", weight="c"), reserved test indexk = test_index(), shifted Legendre to levelN_u + 2.Project mass + KBCs:
Multiply(h),Multiply(c(ζ)·φ_k),ProductRule([ζ]),Integrate(ζ,(0,1)),ResolveIntegral(), substitutekbc_bot/kbc_top, kill∂_t b,Simplify()→ the depth-integrated moment form of mass.Project each horizontal momentum and close the stress: the same project-and-resolve per axis, then
apply_stress_closures(self.closures, …)injects the boundary tractions + bulk constitutive stress;small_slope_scalingif requested.Separation of variables:
u_i → û_iat orderN_u,w → ŵat orderN_u + 1(separation_of_variables).Resolve mass → h-equation + ŵ closure:
ExpandSums, PullConstants, ExtractBrackets, EvaluateSums, ResolveModes(index=k, modes=range(N_u+3)), ResolveBasis; thenh_eq = mass[0].solve_for(∂_t h)and the vertical-velocity closurew_closure = SolveLinearSystem(mass[1:], [ŵ_j]).solve().Substitute ŵ into momentum, resolve: per axis
ExpandSums … w_closure, ResolveModes(modes=range(N_u+1)), ResolveBasis, plusGaussQuadratureifquadrature_order > 0.Finalise: substitute
h_eq,Consolidate(), conservativeChangeOfVariables(û_d → q_d, λ q_i: q_i/h), thenInvertMassMatrix(). The symbolic derivation ends here.Register reconstruction / projection: build
_interpolate_rows(the 3-D profile[b,h,u,v,w,p]with the ŵ closure inlined), the lateral-wall mirror BC viaregister_group("boundary:wall", q_i, −q_i), the well-balanced_reconstruction_rows, and the Galerkin-inverse_project_rows.
Solver tagging is not done in derive_model: SystemModel.from_model(m)
routes the frozen terms into flux/source/NCP slots structurally (the hydrostatic
flux P = g h²/2 is tagged explicitly in the model’s system_model property).