Numerical System Model#
A NumericalSystemModel (NSM) is a SystemModel plus the scheme choices:
which Riemann solver, what reconstruction order, how diffusion and sources are
treated, how the depth is regularised. It is the last symbolic object in the
chain — everything downstream (the NumPy runtime, the JAX kernels, the emitted
C++) is generated from it.
from zoomy_core.numerics import NumericalSystemModel, ReconstructionSpec
nsm = NumericalSystemModel.from_system_model(
sm, reconstruction=ReconstructionSpec(order=2))
The NSM is a SystemModel (it subclasses it) and from_system_model
promotes the object in place — nsm is sm. So every operator, the shape
contract, and describe() from the System Model page carry
over unchanged.
Building one#
NumericalSystemModel.from_system_model(sm, **kwargs) — every knob, with its
default:
Argument |
Default |
What it selects |
|---|---|---|
|
|
the numerical flux |
|
order 1 (constant) |
|
|
auto |
|
|
|
|
|
|
e.g. Kurganov–Petrova |
|
|
its scale |
|
|
how the spectrum is guarded |
|
|
regularisation diagonal |
|
|
extra guard on eigenvalue powers |
|
|
hard timestep cap |
|
|
normalise face normals |
|
|
extra |
|
|
sub-systems for split solvers |
|
|
rows carrying a scaling |
Two spec objects:
ReconstructionSpec(order=1, limiter="venkatakrishnan",
free_surface_aware=False, positivity="")
DiffusionSpec(enabled=True, scheme="crank_nicolson", nu=None)
NumericalSystemModel.from_model(model, **kwargs) is the shortcut for
from_system_model(SystemModel.from_model(model), **kwargs).
Note
No numerical constant belongs in a backend. Every threshold, tolerance, epsilon and safety factor is set here and emitted through the code printer, so all backends are compatible by construction. A float literal next to a comparison in backend source is a defect, not a detail.
Riemann solvers#
From zoomy_core.fvm.riemann_solvers:
Solver |
Notes |
|---|---|
|
local Lax–Friedrichs; bed row zeroed when |
|
Davis two-wave; falls back to LLF when |
|
HLL plus contact/shear wave; requires |
|
default — Rusanov + NCP fluctuation viscosity |
|
path-conservative Roe |
|
+ Audusse–Bristeau–Klein hydrostatic reconstruction |
|
positivity-preserving compositions |
|
driven by |
|
well-balanced variant |
A custom variant subclasses Numerics (or a built-in) and overrides
numerical_flux / numerical_fluctuations. The printer picks it up
automatically — no registration:
import sympy as sp, param
from zoomy_core.fvm.riemann_solvers import Rusanov
class MyDampedRusanov(Rusanov):
name = param.String(default="MyDampedRusanov")
damping = param.Number(default=0.5)
def numerical_flux(self):
return sp.Float(self.damping) * super().numerical_flux()
Note
Well-balancing, positivity and truncation are scheme decisions and live here, at the symbolic level. Finding one hand-written inside a backend loop is finding a bug.
Handing the runtime something symbolic can’t do#
Numerical eigenvalues. When the symbolic spectrum is unwieldy, set
eigenvalue_mode = "numerical" on the Model. You supply nothing else: the
solver builds A_n = Σ_d n_d · quasilinear_matrix[:,:,d], adds the
eigenvalue_eps diagonal, and takes np.real(np.linalg.eigvals(A_n)) per face.
Keep the default "symbolic" when the spectrum closes cleanly — it is far
cheaper per step.
Conditionals. Write branchy physics as
sp.Function("conditional")(cond, true, false) or a Piecewise. No
registration needed: every printer maps it to its native form (np.where,
ufl.conditional, a C/GLSL/JS ternary). clamp_positive, clamp_momentum and
safe_denominator come the same way. Never use a Python if inside operator
code — it bakes one branch into the printed kernel.
Custom callables. register_symbolic_function(name, method_ref, sig_struct)
on the Model, Kernel or Numerics stores the function and installs a proxy
emitting name(...); the printer resolves it against the backend
implementation at lambdify time. The standard operator slots are registered
through exactly this mechanism — a custom function is not a special case.
Running it#
The NumPy solver is the reference implementation; every other backend is ported from it.
from zoomy_core.mesh import BaseMesh
from zoomy_core.fvm.solver_numpy import HyperbolicSolver
from zoomy_core.fvm import timestepping
mesh = BaseMesh.create_1d(domain=(0.0, 10.0), n_inner_cells=50)
solver = HyperbolicSolver(time_end=0.5, compute_dt=timestepping.adaptive(CFL=0.9))
Q, Qaux = solver.solve(mesh, nsm, write_output=False)
Solvers in zoomy_core.fvm: HyperbolicSolver (solver_numpy), IMEXSolver
(solver_imex_numpy), SplittingSolver (solver_splitting_numpy),
ChorinSplitVAMSolver (solver_chorin_vam_numpy), a DAE solver
(solver_dae_numpy), and the σ-3D split solver (sigma3d_split_solver).
Timestepping: timestepping.adaptive(CFL=…) or timestepping.constant(dt=…).
Two lower-level entry points, if you want the runtime without the solver:
nsm.build_numerics()— instantiate the symbolic Riemann numerics.nsm.build_runtime_numpy()— lambdify into a runtime with callable.flux,.source,.eigenvalues,.boundary_conditions.
Generating code#
Every printer accepts a Model, a SystemModel or an NSM — the input is
normalised at the front door via to_numerical_system_model.
Target |
Classes |
Entry |
|---|---|---|
Generic C++ |
|
|
Plain C++ |
|
|
AMReX |
|
|
OpenFOAM |
|
|
GLSL |
|
|
JavaScript |
|
|
NumPy runtime |
|
|
UFL / Firedrake |
|
|
from zoomy_core.transformation.to_c import CppModel
print(CppModel(nsm).create_code())
Modules live under zoomy_core.transformation; the package __init__ is empty,
so import by module path. Printers are syntax only — they translate
expressions, they never make scheme decisions.
Split solvers#
Multi-stage schemes march an ordered list of Stage(label, kind, sm), where
kind ∈ {hyperbolic, elliptic, pointwise} selects the executor and label is a
stable identifier that code-generating backends key names off (AMReX emits
Model_<label>.H). The split is data, not solver code:
model.chorin_split(dt) returns the stages, and each stage’s sm is an
ordinary SystemModel that prints like any other.
Checklist#
sm.describe()before running — the operator slots are the cheapest bug detector you have.Riemann solver matches the physics: NCP terms need a
Nonconservative*variant; wet/dry needs aPositive*one.Second-order reconstruction needs a limiter appropriate to the mesh.
Every threshold set here, none in the backend.