SystemModel — the frozen operator contract#
A SystemModel is the operator-form snapshot of a Model, holding the
matrices solvers consume in the canonical form
Model and SystemModel are siblings, not inherited. Model owns the
derivation tree; SystemModel owns the operator surface.
SystemModel.from_model(m) walks the model’s operator API once, freezes the
matrices, and from then on carries only the post-freeze apply log. See
model.md for derivations, numerics.md for
tag-routed solver operators, ../backends/numpy.md for
the consuming runtime.
Operator slots#
from zoomy_core.systemmodel import SystemModel
Field |
Shape |
Meaning |
|---|---|---|
|
|
\(M(Q)\), canonical \(= I\) |
|
|
\(F(Q)\), conservative |
|
|
\(P(Q)\), split off \(F\) |
|
|
\(B(Q)\) in \(B\,\partial_x Q\) |
|
|
\(S\), implicit / explicit |
|
|
\(A\) in \(\nabla\!\cdot(A\!:\!\nabla Q)\) |
|
|
\(\partial F/\partial Q + \partial P/\partial Q + B\) |
|
|
\(\partial S/\partial Q\) |
|
|
spectrum of \(\sum_d n_d A^{(d)}_{ql}\); |
time, space are sympy Symbols; state, aux_state are lists; parameters,
parameter_values are Zstruct (dot-access). equation_to_state_index records
which state slot each row updates — identity for square systems, non-identity
for splitter sub-systems. Derived operators auto-recompute on every mutation.
Freeze — SystemModel.from_model(m)#
from_model calls each operator method on the source model and freezes the
result. Boundary kernels (_boundary_conditions, _aux_boundary_conditions,
_boundary_gradients) and initial-condition callbacks ride along verbatim.
__post_init__ computes quasilinear_matrix and source_jacobian;
eigenvalues is carried over (None ⇒ runtime numerical wavespeed).
from_model then calls expose_aux_atoms(), which scans every operator entry
and replaces every undeclared Function or Derivative atom by an aux
Symbol. The mapping lands in sm.aux_registry as a list of dicts with keys
kind ("function" | "derivative" | "limited_derivative"), name, row,
atom, aux_symbol; derivative entries additionally carry target_name,
target_kind ("state" | "function" | "unknown"), multi_index (spatial
orders per axis, matching LSQMesh.compute_derivatives), and optional
state_index / function_row / limiter_scheme. Time derivatives are
skipped — they live in mass_matrix. state is the model author’s
declaration; aux_state starts from the source Model and grows here. After
from_model, solvers walk aux_registry, never re-scan operator matrices.
Post-freeze operations#
SystemModel.apply(operation, *, name=None, description=None) is the same
mechanism as Model.apply — see model.md. Operations that act
on a frozen SystemModel live in
library/zoomy_core/zoomy_core/systemmodel/system_model.py:
InvertMassMatrix— divides each evolution row by its diagonalM_ii; precondition checked viaassert_diagonal_mass_matrix(). Post:M = Ion evolution rows.RemoveNonDiagonalH— substitutes the mass equation into rows whoseM[:, h]column is non-zero, pushing theM[i, h] ∂_t hcross-term intoBandS; clearsM[:, h]on affected rows.HydrostaticReconstruction— repackages chain-derived \(g\,h\,\partial_x \eta\) from(P, B)into standard SWE \(P = g\,h^2/2\) for Audusse Riemann solvers. Drops \(-g\,h\,b_x\); HR fluctuation supplies it at runtime.
Kinematic boundary conditions#
KinematicBC(state, interface, *, at=None, mass_flux=None, name=None, description=None)
from library/zoomy_core/zoomy_core/model/operations.py is the
single entry point for moving-interface kinematic BCs (applied during the
upstream Model / System derivation). at defaults to interface; set it
for σ-transformed systems and per-layer ML σ values. mass_flux=None is
impermeable; a sympy expression adds mass_flux / ρ to the RHS.
from zoomy_core.model.operations import KinematicBC
sys.apply(KinematicBC(state, state.b, at=sp.S.Zero)) # post-σ bottom
sys.apply(KinematicBC(state, state.eta, at=sp.S.One)) # post-σ surface
sys.apply(KinematicBC(state, z_k, at=0, mass_flux=m_k)) # ML interface
State change of variables and describe#
sm.change_state_variables(new_state, transform)
sm.refresh_derived_operators(eigenvalues=False)
sm.describe() # compact: n_eq, state, parameters, op log
sm.describe(full=True) # adds canonical form + every matrix as LaTeX
transform maps each old state Symbol being replaced to its expression in
the new state (identity entries omitted). With
\(J[i, j] = \partial T_i / \partial Q_{\mathrm{new}, j}\), the operators update
as \(F_\mathrm{new} = F_\mathrm{old}(T)\),
\(B_\mathrm{new}[i,k,d] = \sum_j B_\mathrm{old}[i,j,d](T)\,J[j,k]\),
\(M_\mathrm{new} = M_\mathrm{old}(T)\,J\). Aux-registry entries targeting
replaced state variables are chain-rule propagated. Pass eigenvalues=True
only when the spectrum was symbolic and the CoV alters characteristic
structure. SystemModelDescription renders via _repr_markdown_ in Jupyter
and str(...) to plain text.
Visualizing the derivation#
The derivation log lives on the upstream System — the equation tree the
Model walked before from_model. sys.history_mermaid() renders one;
combined_history_mermaid(*systems) from
zoomy_core.model.models.derived_system renders multiple side-by-side,
auto-merging identical history prefixes and forking explicit
System.branch(...) chains at the parent’s branch_point node. Both return a
MermaidDiagram whose _repr_markdown_ emits a fenced ```mermaid
block. Post-freeze SystemModel.apply(op) calls append {"name", "description"}
dicts to sm.history; the pre-freeze graph is not regenerated from the
SystemModel.
The block below is captured verbatim from sys_ns.history_mermaid() after the
Kowalski σ-transform sequence (inviscid closure → hydrostatic pressure →
SigmaTransform → bottom and surface KinematicBC) from
thesis/notebooks/modeling/transparent_derivations/kowalski_sigma_transform.ipynb:
flowchart LR
S0(["<b>INS</b>"])
S1["inviscid_closure"]
S0 --> S1
S2["hydrostatic_pressure"]
S1 --> S2
S3["sigma_transform"]
S2 --> S3
S4["kbc_bottom"]
S3 --> S4
S5["kbc_surface"]
S4 --> S5
click S1 "root — inviscid: tau_ij = 0"
click S2 "momentum/x — p = rho g (eta - z)"
click S3 "root — σ-transform: ζ = (z − b(t, x)) / h(t, x), chain-rule on ∂_t, ∂_x[, ∂_y], ∂_z"
click S4 "root — w|_{at=0} = ∂_t b(t, x) + u|·∂_x b(t, x)"
click S5 "root — w|_{at=1} = ∂_t b(t, x) + h(t, x) + u|·∂_x b(t, x) + h(t, x)"
(The trailing + h(t, x) terms inside each derivative are the surface height:
read the surface condition as w|_{surface} = ∂_t(b+h) + u·∂_x(b+h).)
Standalone use — analysis without a solver#
A SystemModel is useful on its own: it is the natural input to linear
stability and dispersion analysis, because it already carries M, the
quasilinear matrix, and the source Jacobian symbolically. The tools in
zoomy_core/analysis/ take a SystemModel directly — no mesh, no runtime:
from zoomy_core.systemmodel import SystemModel
from zoomy_core.analysis import (
linearise, symbolic_eigenvalues_at,
extract_quasilinear_pencil, sample_hyperbolicity,
)
sm = SystemModel.from_model(MyModel())
evs = symbolic_eigenvalues_at(sm, {h: h0, q: q0}, axis=0) # symbolic λ(state)
sm_lin = linearise(sm, {h: 1.0, q: 0.0}) # Q = Q0 + ε δQ
M_t, M_xa, _ = extract_quasilinear_pencil(sm_lin) # pencil (M_x − λ M_t)
report = sample_hyperbolicity(M_xa[0], M_t, {h0: (0.1, 5.0)}, n_samples=2000)
print(report.summary())
linearise(sm, base_state)(analysis/linearisation.py) insertsQ = Q₀ + ε δQand returns a newSystemModelin the perturbation fields.plane_wave_dispersion(linear_sm, k=…, omega=…, axis=…)(analysis/plane_wave.py) solvesdet M(ω,k) = 0for the dispersion relation.extract_quasilinear_pencil/generalised_eigenvalues/sample_generalised_eigenvalues(analysis/pencil.py) give the symbolic and numerical spectrum of the pencildet(M_x − λ M_t) = 0.is_hyperbolic_at/sample_hyperbolicity(analysis/hyperbolicity.py) sweep a parameter box and return aHyperbolicityReport— the standard check before trusting a derived model in a hyperbolic solver.
Splitting a SystemModel — the VAM pressure projection#
A rectangular SystemModel (more equations than evolved state, with
equation_to_state_index recording the row→slot map) can be split into
sub-systems that different discretisations consume. This is how the
non-hydrostatic VAM chain is solved: model/splitter.py separates the monolithic
system into a predictor, an elliptic pressure block, and a corrector.
from zoomy_core.model.splitter import split_for_pressure
result = split_for_pressure(sm, pressure_vars=["P_0", "P_1"], dt=dt)
sm_pred, sm_press, sm_corr = result.predictor, result.pressure, result.corrector
The structural variant split_for_pressure_structural(sm, pressure_vars, dt)
detects each constraint row as an identically-zero mass_matrix row → the
elliptic block; evolution rows carrying pressure terms get the corrector update
Q[s] ← Q[s] − dt·(pressure part)/M[row,s]. Each sub-system is itself a
SystemModel, so the same printers and the ChorinSplitVAMSolver
(numpy.md) consume them unchanged.