zoomy_core.model.operations#
Composable projection operations on INS equations (Expression, SigmaTransform, Recombine, …).
The user drives every step; there are no hidden assumptions.
Module Contents#
Classes#
Shared symbolic state: coordinates, velocity fields, pressure, stress tensor, bathymetry, and physical parameters. |
|
Base for all symbolic objects. Provides name and notebook display. |
|
Orientation surface shared by |
|
Symbolic expression for PDE terms. |
|
Result of depth-integrating a term over [lower, upper]. |
|
Structured result from integration by parts. |
|
A structural transformation on a tree node. |
|
Multiply an Expression (or tree of Expressions) by a factor. |
|
Depth-integrate all equations over [b, b+h] w.r.t. z. |
|
Apply kinematic BCs globally to combined boundary terms. |
|
Run |
|
Substitute a vertical field with its polynomial ansatz expansion in unevaluated Sum form. |
|
Resolve every |
|
Evaluate integrals with constant integrand and remove zero integrals. |
|
Integrate each additive term of an equation w.r.t. |
|
Affine change of variable applied to every |
|
Rewrite each |
|
Rewrite |
|
Rewrite a matching |
|
Transform vertical coordinate: z = ζ·(upper−lower) + lower, dz = (upper−lower)·dζ. |
|
Change of variables |
|
Fold expanded expressions back into compact/conservative shapes, e.g. |
|
Product rule — auto-routes per term based on outermost shape. |
|
Newtonian fluid: tau_ij = mu * (du_i/dx_j + du_j/dx_i), mu = rho * nu. |
|
Inviscid fluid: all tau_ij = 0. |
|
Material model library. Usage: materials.newtonian(state) |
|
Specialise a σ-resolved system to L piecewise-constant layers. |
|
Kinematic boundary condition at a moving interface. |
|
Zero tangential normal stress at both surface and bottom. |
|
p = p_atm + rho * g * (eta - z) |
|
Stress-free surface: τ·n|_{z=η} = 0. |
|
p_atm = 0 (no atmospheric pressure). |
|
Assumptions library. |
|
Vertical polynomial basis for the SME expansion |
|
|
|
Replace every |
Functions#
|
|
|
|
Register a Zoomy integration rule. |
|
Return |
|
Drop all cached integrals and reset stats (useful between runs). |
|
|
|
|
|
Standalone IBP: integral d(f)/dvar * g dvar = [f*g]_a^b - integral f * dg/dvar dvar Returns IBPResult(integrate, boundary_upper, boundary_lower). |
|
Data#
API#
- class zoomy_core.model.operations.StateSpace(dimension=2)#
Shared symbolic state: coordinates, velocity fields, pressure, stress tensor, bathymetry, and physical parameters.
dimension is the physical space dimension: 2 = xz plane (1D horizontal shallow water) 3 = xyz space (2D horizontal shallow water)
The vertical coordinate is always z. Horizontal coordinates are x (and y if dimension=3). The horizontal_dim property gives the number of horizontal directions (1 or 2).
Initialization
- property horizontal_dim#
- property has_y#
- class zoomy_core.model.operations.SymbolicBase(name='')#
Base for all symbolic objects. Provides name and notebook display.
Initialization
- class zoomy_core.model.operations.RelationMixin#
Orientation surface shared by
ExpressionandEquation.An expression/equation OPTIONALLY carries a
{lhs: rhs}relation in_as_relation— set either byExpression.solve_for(which isolates a symbol) or by constructing fromsp.Eq(lhs, rhs). When present it acts as a substitution under.apply(the dispatcher reads_as_relation) and exposes a dict-like.lhs/.rhs/.subs_mapsurface. So a SOLVED equation is the relation — there is no separateRelation/Substitutionclass.When NOT oriented, the object is a plain residual
expr == 0:.lhsis the residual,.rhsis0,.subs_mapis empty.- property oriented: bool#
True when this carries a
{lhs: rhs}relation.
- property subs_map: dict#
The
{lhs: rhs}substitution rules (empty dict if not oriented).
- property lhs#
Left side — the solved-for symbol when oriented, else the residual.
- property rhs#
Right side — the solution when oriented, else
0.
- class zoomy_core.model.operations.Expression(expr, name='', term_groups=None, solver_groups=None, term_tags=None, tag_order=None)#
Bases:
zoomy_core.model.operations.RelationMixin,zoomy_core.model.operations.SymbolicBaseSymbolic expression for PDE terms.
Supports:
Term access: .terms, [i], len(), iteration
Projection: .project(test, var, domain, …)
Integration by parts: .ibp(var, test_weight, domain) -> IBPResult
Apply conditions: .apply({old: new}, …) or .apply(relation)
SymPy: .subs(), .simplify(), .expand(), .doit()
Notebook: repr_latex
Initialization
- property terms#
- apply_to_term(index, *operations)#
Apply operations to a specific term and return the full expression.
Usage:
xmom.apply_to_term(5, ProductRule()) xmom.apply_to_term(5, {old: new})
.. note:: Public callers should prefer
xmom[5].apply(...)—Expression.__getitem__returns a live accessor whose.applymutates the parent in place. This method is kept as the internal split-apply-merge primitive that the accessor uses; library code may continue to call it directly.
- keep_groups(*group_names)#
Return a new Expression keeping only the specified tags’ terms.
- drop_groups(*group_names)#
Return a new Expression dropping the specified tags’ terms.
- tag(**named_groups)#
Attach physical tags to terms for display.
Each
name=valuekwarg is split into additive terms and each term is recorded as belonging tonamein the per-term tag map.Tags follow the terms through
.apply/.subs/.simplify: when a transformation replaces a tagged term, the new term(s) inherit the tag. Terms that cancel (viasimplify) silently disappear from the view — no parallel-sum math, no cross-tag contamination.Usage:
xmom = xmom.tag(pressure=grad_p, stress=div_tau)
- property tags#
Dict of current-expr terms grouped by physical tag.
Computed on demand by walking
Add.make_args(self.expr)and assigning each term to the tag recorded in_term_tags. Terms not in the main expression (cancelled by simplify, etc.) do not appear — the view stays in sync with the single source of truth.
- property untagged#
Sum of current-expr terms that have no physical tag.
- solver_tag(**named_groups)#
Attach/replace solver tag groups for operator routing.
Tag names are normalized via the solver-tag alias catalog, so
.solver_tag(convection=...)and.solver_tag(flux=...)both write to the canonicalfluxslot.Solver tags are dropped per-tag whenever
apply()/simplify()changes the tagged sub-expression (structural equality check).Usage:
xmom = xmom.solver_tag(flux=F_x, source=g * h * dbdx)
- property solver_tags#
Dict of solver tags (canonical names), or empty dict if none set.
- get_solver_tag(name)#
Return the sp.Expr for a solver tag, or None if not set.
namecan be any alias; it is normalized to canonical form.
- untagged_remainder()#
Return
self.expr - sum(solver_tags.values()), simplified.Zero means every term has been routed to a solver tag; a non-zero remainder is what drives the model’s
untagged_policy(warn/error).
- auto_solver_tag(*, state_vars, time_var, coords, parameters=())#
Algorithmically assign solver tags by structural matching.
Each additive term of
self.expris classified:coeff * Derivative(q, t)withqa state variable →time_derivative.coeff * Derivative(F, x_i)wherecoeffis state-variable- free →flux(conservative form).coeff * Derivative(q_j, x_i)withq_ja state variable andcoeffcontaining state vars →nonconservative_flux.coeff * Derivative(p, x_i)wherepis a declared parameter andcoeffcontains state vars → alsononconservative_flux(the term couples a state coefficient to a parameter gradient — e.g. bathymetry-gradient forcing).No derivative and purely algebraic in
state_vars ∪ parametersspace →source.Anything else (unclosed fields like
u(t, x, z), unmatched Derivative shapes) stays in the untagged remainder.
Parameters
state_vars(iterable of sympy Functions / Symbols): The evolution variables (α_k(t, x), interface heights, etc.).time_var(sympy Symbol): The time coordinate (fortime_derivativedetection).coords(iterable of sympy Symbols): Spatial coordinates (x,y, …) for flux / NCP.parameters(iterable, optional): Known external Function/Symbol atoms (bathymetryb(t, x), prescribed fluxes, etc.). These may freely appear in any term without the term being marked “unclosed”, and they may appear as the inner of a spatial Derivative in the NCP tagging.Returns a new Expression with
solver_groupspopulated.Physical tags are preserved.
- project(test_func, var, domain=(0, 1), weight=S.One, scale=S.One, numerical=False, order=4)#
Galerkin projection: integral(expr * test * weight * scale, (var, a, b)).
- ibp(var, test_weight, domain=(0, 1), scale=S.One)#
Integration by parts on the outermost Derivative w.r.t. var. Returns IBPResult(integrate, boundary_upper, boundary_lower).
- apply(*conditions, rhs=_UNSET)#
Apply substitutions / Relations / Operations — per term.
The transformation is applied to every additive term of
self.exprindividually. Each output term inherits the tag of its parent term (follow-the-term), so the per-term tag dict stays in sync with the evolving main expression automatically.Shorthand:
expr.apply(lhs, rhs=0)is equivalent toexpr.apply({lhs: 0}). Valid only with a single positional argument which is a sympy expression (orExpression).After Relation/dict-style applies the result is lightly simplified via
_simplify_preserve_integrals(linearity-only derivatives, cancellations across the full expr). Terms that cancel vanish from bothself.exprand the tag dict.Operations are invoked via their unified
__call__interface; when the op produces multiple output pieces per input term (DepthIntegrateetc.) all pieces inherit the parent tag.
- expand()#
Return a new Expression with sympy expand applied.
- copy()#
Return an independent Expression with the same expr, tags, relation.
- solve_for(variable)#
Return a new Expression whose
_as_relationmapsvariableto the solution ofself.expr == 0.Pipeline-style counterpart to
_NodeProxy.solve_for: does not mutate a system tree. Downstreammodel.apply(result)picks up the relation and performs the substitution.
- depth_integrate(lower, upper, var, method='auto')#
Depth-integrate this expression over
[lower, upper]w.r.t.var.Args
lower,upper: Integration bounds (typically b and b+H, both functions of t and x).var: The vertical coordinate z.method:'auto'detects the derivative direction;'leibniz'pulls a horizontal derivative outside;'fundamental_theorem'handles vertical derivatives;'direct'keepsIntegral(expr, (var, lower, upper)).
Returns
DepthIntegralResultwith.volume,.boundary_upper,.boundary_lower, or a plainExpressionfor'direct'and'fundamental_theorem'.
- subs(*args, **kwargs)#
- split_integrals()#
Distribute each
Integral(Add(...), lim)into a sum of single-term Integrals, recursing through anyDerivativewrapper. Returns a new Expression whose.termsexposes one logical equation term per Integral.
- merge_integrals()#
Group sibling Integrals with matching
(limits, deriv-wrapper)signature into a singleIntegral(Σ c·f, lim). Inverse ofsplit_integrals; useful when cross-integrand cancellations need sympy’sAddcanonicalisation to see them in one integrand.
- simplify()#
Simplify: expand + cancel, preserving Integral and Derivative(Integral) terms.
- doit()#
- has(*args)#
- atoms(*types)#
- xreplace(rule)#
- property free_symbols#
- map(fn)#
Apply fn to each term, reassemble into a single Expression.
fn receives an Expression (single term) and must return either an Expression or a DepthIntegralResult. DepthIntegralResults are assembled (volume + boundaries) before summing.
Example
integrated = expr.map(lambda t: t.depth_integrate(b, eta, z))
- map_with_bcs(fn, bcs)#
Like map(), but collects boundary terms and applies BCs globally.
This is the correct way to depth-integrate a full equation: boundary terms from ALL terms are combined first, then BCs are applied once (so cross-term cancellations happen properly).
Parameters
fn(callable): Applied to each term. Must return DepthIntegralResult or Expression.bcs(list of Relation): Kinematic BCs etc. applied to the combined boundary expression.
Returns
Expression: The fully depth-integrated equation with BCs applied.
- classify(t=None, x=None, z=None)#
Classify each term by its role in the PDE.
Returns a dict: {role: Expression} where role is one of: ‘temporal’, ‘convective’, ‘diffusive’, ‘source’.
Detection rules:
Has d/dt → temporal
Has d/dx (first-order) of a product → convective flux
Has d²/dz² or d/dz of d/dz → diffusive
Otherwise → source (algebraic)
- property temporal#
View: only temporal (d/dt) terms.
- property convective#
View: only convective flux (d/dx) terms.
- project_onto_basis(basis, level, field_map, z_var, lower=None, upper=None, test_mode=None)#
Project a depth-integrated equation onto a polynomial basis.
Replaces every
Integral(f(u,...), (z, b, eta))by substituting the basis expansionu(z) = sum alpha_k phi_k(zeta)and evaluating the resulting integrals using theSymbolicIntegrator.If
test_modeis an integer, multiplies each integral by the test function phi_{test_mode}(zeta) before evaluating (Galerkin projection for a specific mode). Iftest_mode=None, returns the scalar integral (e.g. for the mass equation where no test function is needed).Parameters
basis(Basisfunction class (e.g. Legendre_shifted))level(int)field_map(dict): Maps the original Function name to a list of SymPy Symbols for the basis coefficients. Example: {‘u’: [alpha_0, alpha_1, alpha_2]}z_var(Symbol): The vertical coordinate (z) that appears in the integrals.lower,upper(sympy expressions (optional)): The integration bounds (b, eta). If None, detected from the first Integral found.test_mode(int or None): If int, project onto test function phi_{test_mode}.
Returns
Expression: With all depth integrals replaced by basis matrix products.
- latex(strip_args=False, multiline=False)#
LaTeX representation.
Parameters
strip_args(bool):u(t,x,z)→u. Partial derivatives preserved.multiline(bool): Render as\begin{aligned}with one group per line (requiresterm_groups).
- describe(header=True, final_equation=True, parameters=False, strip_args=True)#
Composable description of this expression.
Returns a
Descriptionthat renders as markdown in Jupyter.Parameters
header(bool): Show expression name + term count.final_equation(bool): Show the symbolic equation.parameters(bool): List free symbols.strip_args(bool): Displayuinstead ofu(t, x, z).
- zoomy_core.model.operations.is_bracket_body(integrand, var)#
Trueiffintegrandis a Galerkin BRACKET body — a pure function of the integration variablevar, depending on NOTHING else (integer mode indicesi/kexcepted, since they parametrise the bracket).This is THE definition of “this ∫ is a bracket” and the single source of truth reused everywhere it is needed:
the LaTeX printer — render
∫_0^1 (…) dζas⟨…⟩only when this holds;ExtractBrackets— the naming gate (at/x-bearing body is not yet a bracket);the bracket-quadrature step that turns
⟨…⟩into NUMBERS — pick the integrals to evaluate via the basis instead of sympy.
A factor that carries any non-index continuous symbol (
a_i(t,x),h,τ̃,ω, a parameterg) disqualifies it.
- zoomy_core.model.operations.is_bracket(integral)#
Trueiffintegralis a Galerkin BRACKET: ansp.Integralover a CONSTANT interval whose body satisfiesis_bracket_body— hence a pure NUMBER, evaluable by basis quadrature. A running∫_0^ζ(variable upper limit) is therefore NOT a bracket; it is a ζ-function that may sit inside one.
- class zoomy_core.model.operations.DepthIntegralResult(volume, boundary_upper, boundary_lower)#
Result of depth-integrating a term over [lower, upper].
Attributes
volume: the volume integral (Expression)boundary_upper: boundary term at z=upper (Expression)boundary_lower: boundary term at z=lower (Expression)
The full integral = volume + boundary_upper - boundary_lower. Kinematic BCs can be applied to the boundary terms via .apply_bcs().
Initialization
- apply_bcs(bc_lower=None, bc_upper=None)#
- assemble()#
Combine all terms: volume + boundary_upper + boundary_lower.
The sign convention is that each component already carries its correct sign. For Leibniz: upper = -f(eta)*d(eta)/dx, lower = +f(b)*db/dx. For fundamental theorem: upper = +f(eta), lower = -f(b).
- class zoomy_core.model.operations.IBPResult(integrate, boundary_upper, boundary_lower)#
Structured result from integration by parts.
Attributes
integrate: the volume integral (Expression)boundary_upper: boundary term at upper limit (Expression)boundary_lower: boundary term at lower limit (Expression)
Initialization
- apply_bcs(bc_lower=None, bc_upper=None)#
- assemble()#
- class zoomy_core.model.operations.Operation(name='', description=None, log_level=None)#
Bases:
zoomy_core.model.operations.SymbolicBaseA structural transformation on a tree node.
A node is either an
Expression(leaf) or aZstructof nodes (intermediate). The unified entry point is__call__(node):Subclasses override
_apply_leafto transform a leaf Expression. The default__call__dispatches over the tree, recursing into intermediate Zstructs.Rank-changing Operations (
Multiplywithouter=Trueon a leaf, or contraction on a Zstruct) override__call__directly and may return a node of different shape than they received.
Every Operation carries
name+descriptionsoSystem.applycan record what was done in the derivation history.log_level(1–5) is the operation’s VERBOSITY in the derivation graph:1= major (always reported — the default for every op), rising to5= minor bookkeeping (e.g.Simplifycanonicalisation). A history view can then filter, e.g.describe(..., max_log_level=1)shows only the major steps. Set it per class (log_level = 5) or per instance (Op(..., log_level=3)).Initialization
- log_level#
1
- rank_changes_leaf#
False
- whole_leaf_op#
False
- system_level#
False
- single_term_only#
False
- class zoomy_core.model.operations.Multiply(factor, outer=False, name=None, description=None)#
Bases:
zoomy_core.model.operations.OperationMultiply an Expression (or tree of Expressions) by a factor.
Default behaviour (
outer=False) — rank-preserving: Scales every term of a leaf Expression byfactor. Tags follow the terms through the per-term dispatch inExpression.apply, somodel.apply(Multiply(1/rho))scales the whole system and keeps physical tags intact.outer=True— rank-changing (leaf → Zstruct):factormust be aZstructof scalar factors (e.g. test functionsphi_0, phi_1, ...). The leaf Expression is promoted to aZstructwith one child Expression per factor, keyedtest_<index>. Each child isleaf.expr * factor_k.Per-term tag inheritance: each parent tag is re-keyed through the multiplication (same rule as
Expression.apply), so physical tags follow into everytest_<k>child.Usage:
model.apply(Multiply(1 / state.rho)) # scalar scaling phis = Zstruct(**{f"phi_{k}": ... for k in range(N)}) model.momentum.x.apply(Multiply(phis, outer=True)) # Galerkin test
Initialization
- property rank_changes_leaf#
- class zoomy_core.model.operations.DepthIntegrate(state)#
Bases:
zoomy_core.model.operations.OperationDepth-integrate all equations over [b, b+h] w.r.t. z.
Applies Leibniz rule and fundamental theorem term-by-term. Boundary values (w at z=b, u at z=b+h, etc.) remain as
Subsobjects. ApplyApplyKinematicBCsto see the cancellations.Initialization
- class zoomy_core.model.operations.ApplyKinematicBCs(state)#
Bases:
zoomy_core.model.operations.OperationApply kinematic BCs globally to combined boundary terms.
Evaluates
Subsboundary values, applies kinematic BCs at surface and bottom, and simplifies. The Leibniz boundary u-terms cancel with the fundamental theorem w-terms.Must be applied immediately after
DepthIntegrate.Initialization
- zoomy_core.model.operations.register_integration_rule(rule)#
Register a Zoomy integration rule.
A rule is
(integrand, limits) -> Optional[sp.Expr]:limitsis the full(var, lower, upper)triple passed to_cached_integrate(matchingsympy.integrate’s call shape).Return the definite integral if the pattern matches — the caller does no further transformation.
Return
Noneif the rule doesn’t apply._cached_integratetries rules in registration order; the first non-Nonewins.
- zoomy_core.model.operations.integrate_cache_stats()#
Return
(hits, misses)for the cached-integrate helper.
- zoomy_core.model.operations.integrate_cache_clear()#
Drop all cached integrals and reset stats (useful between runs).
- class zoomy_core.model.operations.EvaluateIntegrals(state=None, name='evaluate_integrals', description=None)#
Bases:
zoomy_core.model.operations.OperationRun
sympy.integrateon everyIntegralnode in the expression.After
AffineProjection+ basis expansion the volume integrals look likeIntegral(polynomial_in_zeta, (zeta, 0, 1)). This op collapses each such integral to a scalar.Walks
Derivative(Integral(...), x)too: the inner Integral is evaluated first, then the outer Derivative survives symbolically.Uses
_cached_integrateso repeated integrands across leaves (everytest_kclone afterMultiply(basis.phi, outer=True)) cost onesympy.integratecall each, not one-per-leaf.Leaves unevaluatable Integrals untouched (e.g. non-polynomial ones or ones whose result would need a closed form sympy can’t find).
Initialization
- whole_leaf_op#
True
- class zoomy_core.model.operations.Expand(field, basis, coefficients, state, name='expand', description=None)#
Bases:
zoomy_core.model.operations.OperationSubstitute a vertical field with its polynomial ansatz expansion in unevaluated Sum form.
Replaces every call
field(t, x, [y,] arg_z)with`Sum(coeff_fn(k) · basis.phi_fn(k, (arg_z − b)/h), (k, 0, L))`
— a single
sp.Sumatom rather than the unrolled Add. Two placeholders make this work as one symbolic chunk:basis.phi_fnis the basis’s opaque 2-arg Function (carries_basis = basis); concrete polynomial substitution is deferred untilEvaluateIntegrals.coeff_fnis a private sympy Function class namedcoeff_<basis.symbol>and carrying a_coeff_tableclass attribute holding the pre-declared coefficient list. It stays opaque throughProductRule,AffineProjection, andIntegrate(the Sum carries through as a single mathematical term).EvaluateIntegralssubstitutescoeff_fn(k_concrete) → coefficients[k_concrete]after the Sum has been.doit()-ed.
Boundary evaluations of the field (
arg_z = b,arg_z = b+h) are handled automatically:(arg_z − b)/hbecomes 0 or 1 respectively, the Sum isΣ_k amp_k · phi_fn(k, 0|1), and EvaluateIntegrals’ final cleanup pass resolves these viabasis.resolve_atomsonce the Sum has been.doit()-ed.Why Sum-form: at higher levels (L ≥ 1), the unrolled Add form has
L+1terms per expanded field — products of two expanded fields explode quadratically, andProductRulewould have to be applied to each one individually. The single-Sum representation keeps the equation compact and makesProductRulea single apply per expansion, which is the way the math reads on paper.Parameters
field(sympy Function call): The vertical field, e.g.state.u(which isu(t, x, z)).basis(Basisfunction): The basis instance.basis.phi_fnis the 2-arg opaque Function used inside the Sum.coefficients(sequence of sympy Function calls): Pre-declared horizontal coefficient functions (U_0(t, x), U_1(t, x), …) — one per basis level. Length must equalbasis.level + 1. Substituted into the Sum byEvaluateIntegralsafterSum.doit()unrolls the symbolic index to concrete integers.state(StateSpace): The state space — used to readstate.bandstate.hfor the affine ζ-map.
Initialization
- class zoomy_core.model.operations.ProjectBasisIntegrals(basis_cache, name='project_basis_integrals', description=None)#
Bases:
zoomy_core.model.operations.OperationResolve every
Integral(..., (ζ, a, b))via basis-aware cache lookup.Replaces the old “expand polynomial basis + run
sympy.integrate” chain. Every integral whose integrand contains opaquephi_k(arg)nodes is mapped to aBasisIntegralCachequery, withζ-independent factors pulled out first so the kernel the cache sees is a clean basis-only product.Steps, applied per leaf expression:
Canonicalize Subs — rewrite
Subs(Derivative(phi_k(ξ), ξ), ξ, arg)(the sympy chain-rule leftover) back toDerivative(phi_k(arg), arg).Distribute Adds —
Integral(Add(t1, t2), …) → Σ Integral(ti, …)so each term can be factored independently.Factor constants out + cache query — for each
Integral(kernel, (var, a, b)):split the Mul integrand into
var-independent factorCandvar-dependent kernelK;if
Kcontains anyphi_k, handKto the cache and multiply its result byC;otherwise leave the Integral alone.
The cache returns a sympy scalar / polynomial; nested running integrals inside
Kget evaluated via the cache too (their polynomial results are substituted into the outer integrand on the next pass, and the fixpoint loop below handles this).No
sympy.integrateis ever called on an opaquephi_k: the cache concretizesphi_kagainst the basis polynomial first, then does polynomial integration only.Initialization
- class zoomy_core.model.operations.SimplifyIntegrals(state)#
Bases:
zoomy_core.model.operations.OperationEvaluate integrals with constant integrand and remove zero integrals.
∫ 0 dz → 0∫ c dz → c·h(if integrand has no z-dependence)∂/∂x ∫ 0 dz → 0
Initialization
- class zoomy_core.model.operations.Integrate(var, lower, upper, method='auto')#
Bases:
zoomy_core.model.operations.OperationIntegrate each additive term of an equation w.r.t.
varover [lower, upper].Per-term strategy is driven by
method:"auto"(default) — detect the outermost derivative direction per term and pick Leibniz / fundamental theorem / direct accordingly."leibniz"— force the Leibniz rule (for terms containing∂/∂x_iwherex_i != var):∫ ∂_x f dvar = ∂_x [∫ f dvar] − f|_upper · ∂_x upper + f|_lower · ∂_x lower."fundamental_theorem"— for terms containing∂/∂varas the outermost derivative:∫ ∂_var f dvar = f|_upper − f|_lower."direct"— keep as an unevaluatedIntegral(expr, (var, lower, upper))."analytical"— runsympy.integrateon the whole expression (what the original smallIntegratedid; useful for z-momentum analytic integration when the integrand has no horizontal derivatives).
Boundary terms produced by Leibniz / fundamental-theorem are kept as
Subs(f, var, bound)expressions — no kinematic BCs are applied here. Resolve them later via an explicit.apply({Subs-pattern: value})or a convenience Relation (ApplyKinematicBCs,StressFreeSurface, …).Partial integration (
upper == var) is supported by the"direct"and"analytical"methods — sympy will produce a running integral.Usage:
zmom.apply(Integrate(state.z, state.b, state.z)) # partial zmom.apply(Integrate(state.z, state.b, state.eta)) # full depth zmom.apply(Integrate(state.z, state.b, state.b + state.h, method="analytical")) # sympy.integrate
Initialization
- whole_leaf_op#
True
- class zoomy_core.model.operations.IntegralTransform(ref_interval=(0, 1), ref_name='\\hat{\\zeta}', name='integral_transform', description=None)#
Bases:
zoomy_core.model.operations.OperationAffine change of variable applied to every
Integralin an expression. Maps each integration interval to a reference interval (default: the unit interval[0, 1]) via the affine map:`var = a + (b - a) · (ref - ref_lo) / (ref_hi - ref_lo)`
with Jacobian
|dφ/dref| = (b - a) / (ref_hi - ref_lo).Unlike substitution-based approaches (
expr.subs(z, ζ·h + b)), this is strictly local to eachIntegral: only the integration variable is replaced, only inside the integrand of the Integral it’s bound to. Sibling occurrences ofvarin the expression — boundary Subs terms, the outerzof a running integral after an outer transform — are untouched, because those aren’t bound by this Integral.Nested Integrals are handled naturally: the walk recurses into integrands first (bottom-up), so the innermost Integral is transformed before its outer host. After the outer transform replaces
varthroughout its own integrand, any inner Integral’s now-renamed limits (in the oldvar) get rewritten in terms of the outer’s reference variable — so a running integralIntegral(g(ẑ), (ẑ, b, z))sitting insideIntegral(f(z), (z, b, b+h))correctly transforms toIntegral(g(…), (ẑ′, 0, 1)) · (b + h·ζ − b)after the outerz → b + h·ζstep.Parameters
ref_interval(tuple (ref_lo, ref_hi), default(0, 1)): The target reference interval. Always the unit interval in typical use.ref_name(str, optional): Base name for the reference-variable Dummies (rendered with a hat by default). Each transformed Integral gets its own Dummy — different Dummies never collide even with the same display name.
Initialization
- whole_leaf_op#
True
- class zoomy_core.model.operations.IsolateBasisIntegrand(name='isolate_basis_integrand', description=None)#
Bases:
zoomy_core.model.operations.OperationRewrite each
Integral(integrand, (var, a, b))so the integrand contains only factors that depend onvar.Two passes per Integral:
Distribute
Integral(Add(t1, t2, …), …) → Σ Integral(t_i, …), so each term can be analysed independently.For each (now single-term) Integral, split the multiplicative factors of the integrand into a
var-independent coefficientCand avar-dependent kernelK, and rewrite asC · Integral(K, (var, a, b)).
After this, every Integral has the form
coeff · Integral(kernel, …)wherekernelonly containsvar(and any opaque basis functions / derivatives evaluated atvar). This is the shape a basismatrix-lookup pattern-matches against:∫_0^1 phi_k(z)·phi_l(z) dz,∫_0^1 z·phi_k(z)·phi_l(z) dz, etc.The Op walks Integrals bottom-up so nested running integrals are isolated first. Each Integral’s integration variable
varis read directly from its own limit tuple — there’s no ambiguity, and different Integrals can carry differentvars (e.g.ζ̂_0,ζ̂_1, …).Initialization
- whole_leaf_op#
True
- class zoomy_core.model.operations.MapBasisToReference(b, h, name='map_basis_to_reference', description=None)#
Bases:
zoomy_core.model.operations.OperationRewrite
phi_k(arg) → phi_k((arg − b) / h)in every leaf.With the
phi_k(state.z)convention the basis is opaque on the raw coordinatez. AfterIntegralTransformsubstitutesz → ζ·h + binside an integrand, integrand basis evaluations readphi_k(ζ·h + b)— semantically the physical basis at pointζ·h + b. This Op states the FEM convention that the physical basis is the reference basis composed with the affine map:phi_k(z) := phi_k_ref((z − b)/h). After the rewrite, sympy auto-simplifies arguments —(b − b)/h → 0,((b + h) − b)/h → 1,((ζ h + b) − b)/h → ζ— so everyphi_kcall lands on a single argument in[0, 1]: the canonical pattern a basismatrix lookup recognises.Also forces the chain rule on any
Derivative(f(t, x, arg), v)whose third argument got rewritten to a (b, h)-dependent form byIntegralTransform— sympy’s structuralsubskeeps aSubswrapper around such Derivatives instead of distributing, so we explicitly.doit()them so the chain-rule metric terms materialise inside the integrand.Parameters
b,h(sympy expressions): The bottom topography and column height entering the affine mapz = ζ·h + b. Typicallystate.bandstate.h.
Initialization
- class zoomy_core.model.operations.PartialIntegrate(var, upper_symbol=None, lo_match=None, hi_match=None, name='partial_integrate')#
Bases:
zoomy_core.model.operations.OperationRewrite a matching
Integral(f, (var, lo, hi))to a running integral with variable upper bound:Integral(f, (var, lo, upper)).The result is left symbolic — no evaluation. Downstream, the expression can be promoted to a
Qauxvariable viaDerivedModel.promote_to_qauxand rewritten into a runtime primitive (e.g.column_partial_integrate).Matching
Every
Integralwhose integration variable equalsvaris inspected. Additional matcherslo_match/hi_matchrestrict the rewrite to Integrals whose lower/upper bounds also match (structural equality); passNone(default) to leave them unrestricted.The new upper bound is
upper_symbol(default:varitself, producing the classic running integral∫_lo^{var} f dvar).Usage:
# depth integral [b, b+h] → running integral [b, z] eq.apply(PartialIntegrate(state.z, lo_match=state.b, hi_match=state.eta)) # unrestricted: rewrite every ∫ dz to ∫_·^z dz eq.apply(PartialIntegrate(z))
Initialization
- class zoomy_core.model.operations.AffineProjection(state, lower=None, upper=None, zeta=None, name=None, description=None, *, rewrite_basis_args=True)#
Bases:
zoomy_core.model.operations.OperationTransform vertical coordinate: z = ζ·(upper−lower) + lower, dz = (upper−lower)·dζ.
Transforms z-integrals whose bounds match the configured
(lower, upper):∫_{lower}^{upper} f(z) dz → (upper − lower) · ∫_0^1 f(ζ·(upper−lower)+lower) dζWith no
lower/uppergiven, defaults tostate.b/state.eta— the full free-surface transform used by the single-layer SME walkthrough. Pass layer-specific bounds (e.g.lower=state.b, upper=z_1) in the multi-layer case so each layer’s integrals get mapped to their own ζ-interval.Only
Integralnodes whose bounds structurally equal the configured pair are rewritten; other integrals (including already-transformed(zeta, 0, 1)ones) are left untouched. That makes it safe to apply multiple AffineProjections back-to-back on a branch with several integration ranges.Initialization
- class zoomy_core.model.operations.SigmaTransform(state, src=None, *, sigma=None, name='sigma_transform', description=None)#
Bases:
zoomy_core.model.operations.OperationChange of variables
(t, x, [y,] z) -> (t, x, [y,] ζ)withζ = (z - b) / hon a PDE leaf, rewriting derivatives by the chain rule.Only first-order derivatives are supported.
References
Kowalski, Torrilhon 2019, https://doi.org/10.4208/cicp.OA-2017-0263
Initialization
- class zoomy_core.model.operations.Recombine(rules=None, vars=(), aliases=None, name=None, description=None)#
Bases:
zoomy_core.model.operations.OperationFold expanded expressions back into compact/conservative shapes, e.g.
h·∂_t α + α·∂_t h->∂_t(h α).rulespicks fromRecombine.RULESby name (anti_product_rule,apply_aliases,collapse_trivial_derivative; default all) or takes callablesfn(expr, **kwargs);varsandaliasesare passed on as kwargs.Initialization
- RULES#
None
- whole_leaf_op#
True
- class zoomy_core.model.operations.ProductRule(variables=None, direction='inverse', fields=None)#
Bases:
zoomy_core.model.operations.OperationProduct rule — auto-routes per term based on outermost shape.
Default
direction="both"— applied to a leaf, dispatches per additive term:Bare
Derivative(Π f_i, v)outer → forward:∂_v(Π f_i) → Σ_i (Π_{j≠i} f_j) · ∂_v(f_i).Mulcarrying a first-orderDerivative(f, v)factor → inverse:coeff · ∂_v(f) → ∂_v(coeff · f) − ∂_v(coeff).doit() · f(gets us into divergence form for the integration step). When the term carries MORE THAN ONE derivative factor — e.g. a σ-metric coefficient∂_x(ζh)multiplying the field derivative∂_x ũ— the rule pulls out the field-bearing outermost first-order derivative and keeps the other derivative factors inside the conserved fluxcoeff. (The old rule bailed whenever a second derivative factor was present, so the conservative fold no-op’d on exactly the raw-field σ-metric terms the pre-ansatz Galerkin projection needs.)Anything else → unchanged (no-op).
Use
direction="inverse"/direction="forward"to force one direction.variables=None(default) — act on derivatives w.r.t. anySymbolnamedt,x,y, orz. Passvariables=[state.t, state.x, ...]for an explicit whitelist by Symbol identity.fields=None(default) — when a term carries several allowed first-order derivatives, the field-bearing one (the derivative whose argument contains a user field application) is pulled out; passfields=[u, w, ...](heads or applications) to disambiguate explicitly against geometry fields (h,b).Cross-term combinations (
h·∂_t α + α·∂_t h → ∂_t(h·α)) are not the operation’s job — apply ProductRule once on the leaf and sympy’sAddcanonicalisation in the nextsimplifypulls sibling terms together.Initialization
- single_term_only#
False
- class zoomy_core.model.operations.Newtonian(state: zoomy_core.model.operations.StateSpace, nu=None)#
Bases:
zoomy_core.model.operations.ExpressionNewtonian fluid: tau_ij = mu * (du_i/dx_j + du_j/dx_i), mu = rho * nu.
Initialization
- class zoomy_core.model.operations.Inviscid(state: zoomy_core.model.operations.StateSpace)#
Bases:
zoomy_core.model.operations.ExpressionInviscid fluid: all tau_ij = 0.
Initialization
- class zoomy_core.model.operations.materials#
Material model library. Usage: materials.newtonian(state)
- newtonian#
None
- inviscid#
None
- class zoomy_core.model.operations.LayerMeanClosure(state, sigma, u_field, u_layer, *, weights=None, max_u_power=3, name='layer_mean_closure', description=None)#
Bases:
zoomy_core.model.operations.OperationSpecialise a σ-resolved system to L piecewise-constant layers.
For each leaf that depends on the σ-coordinate, project onto each layer
α = 1..L: integrate symbolically over the layer’s σ-bounds[σ_{α-1/2}, σ_{α+1/2}]while keepingu(t, x, σ)opaque, then resolve the surviving bulk integrals using the layer-mean closure∫_{layer α} u(σ) dσ = l_α · u_α(t, x). Interior IBP rules handlef(σ) · u^k · ∂_σ uterms via the identityu^k · ∂_σ u = (1/(k+1)) ∂_σ u^{k+1}.The boundary atoms
u(t, x, σ_{α±1/2})andw(t, x, σ_{α±1/2})are deliberately left opaque in the resulting per-layer leaves. That is the user’s modeling choice — applyKinematicBCwithmass_flux=G_{α+1/2}/ρat each interface to resolvew, and apply an interface-velocity choice (donor-cell / central / sign-upwind) for the survivinguboundary atoms. AHS26 (9) is the standard donor-cell-by-G choice; other closures are equally valid and now visible as separate substitutions rather than buried in the projection.Why not substitute a piecewise ansatz before integration:
Piecewise(u_α, σ in layer α)substituted globally would force sympy’s integration to pick a specific value at the layer boundary (dominant-branch convention), silently committing the user to a specific (asymmetric) interface velocity.Σ u_α · 𝟙_{[σ_{α-1/2}, σ_{α+1/2}]}(σ)(Heaviside form) trips sympy’s Meijer-G fallback when products of Heavisides meet ∂_σ in the integrand.
Keeping
uopaque and integrating term-by-term avoids both traps and surfaces the modeling choice explicitly.Parameters
state(StateSpace): The state-space the operation lives on.sigma(sympy.Symbol): σ-coordinate of the σ-mapped system (typicallystate.zeta_ref).u_field(sympy.Function call): The opaqueu(t, x, σ)atom whose layer-mean closure we use.u_layer(sequence of sympy Function calls): Per-layer mean velocitiesu_α(t, x), one per layer α.weights(sequence of sympy expressions, optional): Layer weightsl_αsumming to 1. Default: uniforml_α = 1/L.max_u_power(int, optional): Maximum monomial power ofu_fieldto recognise in IBP patternsf(σ) · u^k · ∂_σ u(default 3 — covers∂_σ u,u·∂_σ u(momentum convection),u²·∂_σ u(cubic shallow-water) andu³·∂_σ u).name, description: Standard operation labels for derivation history.
Initialization
- rank_changes_leaf#
True
- class zoomy_core.model.operations.KinematicBC(state=None, interface=None, src=None, *, at=None, w=None, u=None, v=None, rho=None, mass_flux=None, name=None, description=None)#
Bases:
zoomy_core.model.operations.ExpressionKinematic boundary condition at a moving interface.
One explicit signature covering every kinematic-BC pattern in the codebase — solid bed, free surface, σ-domain boundary in a σ-transformed system, and every internal interface in a multi-layer derivation.
The interface
interface(t, x[, y])moves with the fluid:.. math::
w\big|_\text{at} \;=\; \partial_t \,\text{interface} + u\big|_\text{at}\, \partial_x \,\text{interface} \;[\,+\, v\big|_\text{at}\, \partial_y \,\text{interface}\,] \;+\; \frac{\text{mass\_flux}}{\rho}\,.Parameters
state(StateSpace)interface(sympy expression): The moving surface, a function of(t, x[, y]). Typical choices:state.b(bottom),state.eta(free surface), a freshly-mintedFunction("z_k")(t, x[, y])for an internal multi-layer interface.at(sympy expression, optional): Vertical-coordinate value at which the velocity fields are sampled. Defaults tointerface— the natural physical-z choice “evaluate at the interface itself”. Use an explicitatwhen the system has been mapped to a reference vertical (e.g.at=sp.S.ZeroafterSigmaTransformfor the bottom;at=sp.S.Onefor the surface; per-layer σ values in ML). The substitution is purely structural — replacesstate.zbyatin the velocity arg lists — so it works in physical(t, x[, y], z)or post-σ(t, x[, y], ζ)form regardless.mass_flux(sympy expression, optional): Mass fluxmper unit area per unit time crossing the interface. Addsmass_flux / ρto the RHS. DefaultNonefor impermeable interfaces (bottom / free surface). Use a freshFunctionfor internal layer interfaces whose flux is an unknown of the resulting multi-layer system.name,description(str, optional): History labels.
Examples
Single-layer in physical z (default
at=interface):sys.apply(KinematicBC(state, state.b)) sys.apply(KinematicBC(state, state.eta))
Single-layer after
SigmaTransform— evaluate at the σ endpoints rather than at the (now opaque) physical interface:sys.apply(KinematicBC(state, state.b, at=sp.S.Zero)) sys.apply(KinematicBC(state, state.eta, at=sp.S.One))
Multi-layer (interfaces
z_k; internals carry a mass fluxm_k; each layer’s state has its own σ but the formula is the same — onlyinterface,at, andmass_fluxchange per call):for k, z_k in enumerate(interfaces): m_k = mass_fluxes[k] if 0 < k < N else None sys_layer_above.apply(KinematicBC(state_above, z_k, at=0, mass_flux=m_k)) sys_layer_below.apply(KinematicBC(state_below, z_k, at=1, mass_flux=m_k))
Initialization
- zoomy_core.model.operations.KinematicBCBottom(state: zoomy_core.model.operations.StateSpace)#
w|_{z=b} = ∂_t b + u_b ∂_x b [+ v_b ∂_y b](physical z).
- zoomy_core.model.operations.KinematicBCSurface(state: zoomy_core.model.operations.StateSpace)#
w|_{z=η} = ∂_t η + u_s ∂_x η [+ v_s ∂_y η](physical z).
- zoomy_core.model.operations.InterfaceKBC#
None
- class zoomy_core.model.operations.NoTangentialBoundaryStress(state: zoomy_core.model.operations.StateSpace)#
Bases:
zoomy_core.model.operations.ExpressionZero tangential normal stress at both surface and bottom.
Closes the Leibniz boundary terms
tau_xx(z=b),tau_xx(z=eta)(and y-components in 3D) that remain after depth-integrating∂_x tau_xx. Uses a Newtonian-style “no flow variation” closure at the vertical boundaries.Initialization
- class zoomy_core.model.operations.HydrostaticPressure(state: zoomy_core.model.operations.StateSpace)#
Bases:
zoomy_core.model.operations.Expressionp = p_atm + rho * g * (eta - z)
Initialization
- class zoomy_core.model.operations.StressFreeSurface(state: zoomy_core.model.operations.StateSpace)#
Bases:
zoomy_core.model.operations.ExpressionStress-free surface: τ·n|_{z=η} = 0.
For a free surface with normal n ≈ (0,0,1), this gives: τ_xz|{z=η} = 0, τ_zz|{z=η} = 0 (and τ_yz|_{z=η} = 0 in 3D)
Initialization
- class zoomy_core.model.operations.ZeroAtmosphericPressure(state: zoomy_core.model.operations.StateSpace)#
Bases:
zoomy_core.model.operations.Expressionp_atm = 0 (no atmospheric pressure).
Initialization
- class zoomy_core.model.operations.assumptions#
Assumptions library.
- kinematic_bc_bottom#
None
- kinematic_bc_surface#
None
- hydrostatic_pressure#
None
- stress_free_surface#
None
- zero_atmospheric_pressure#
None
- class zoomy_core.model.operations.Basis(state: zoomy_core.model.operations.StateSpace, basisfunction_cls, level=0, alpha_name='alpha', **basis_kwargs)#
Vertical polynomial basis for the SME expansion
u = Σ α_k φ_k(ζ).Wraps a
Basisfunctionclass (Legendre_shiftedetc.) and exposes symbolic pieces for the apply-based walkthrough:basis.phi—Zstruct(phi_0, …, phi_N)of sympy expressions instate.zeta_ref(the reference-element symbol on[0, 1]). Drop-in factor forMultiply(basis.phi, outer=True)afterAffineProjectionhas collapsed the opaquestate.zetatostate.zeta_ref.basis.alpha—Zstruct(alpha_0, …, alpha_N)ofFunctioncoefficientsα_k(t, x, [y]). Using Functions (not bare Symbols) keeps downstream partial derivatives honest and makes linear-stability analysis straightforward.basis.expand(field)— single substitution dict covering the volume evaluation (in ζ-transformed form) and both boundary evaluations atz=b/z=η. Feed it tomodel.apply(...).
Usage:
from zoomy_core.model.derivation.basisfunctions import Legendre_shifted basis = Basis(state, Legendre_shifted, level=2) model.momentum.x.apply(Multiply(basis.phi, outer=True)) model.apply(AffineProjection(state)) model.apply(basis.expand(state.u))
The volume substitution uses the ζ-transformed key
field.subs(z, ζ·h + b)— apply it afterAffineProjectionso it matches what’s under the integrals.Initialization
Wrap a
Basisfunctionsubclass for the apply-based pipeline.basis_kwargsare forwarded to thebasisfunction_clsconstructor — use them for bases that need extra structure beyond the level index (e.g.PiecewiseConstant(interfaces=[state.b, z_1, state.eta])). Whenbasis_kwargsis non-empty,levelis ignored and re-read from the instantiated basisfunction.- expand(field)#
Substitution dict for every bulk / boundary evaluation of
field.Four keys:
field→Σ α_k(t,x) · φ_k((z−b)/h)(pointwise form — matchesfield(t,x,z)in inner running integrals produced by the w-closure pipeline).field.subs(z, ζ·h + b)→Σ α_k(t,x) · φ_k(ζ)(ζ-transformed form occurring inside outer ζ-integrals).field.subs(z, b)→Σ α_k · φ_k(0)(bottom eval).field.subs(z, b + h)→Σ α_k · φ_k(1)(surface eval).
Including the pointwise key is free — it only fires where the expression structurally carries
field(t, x, z)— and makes the w-closure’s inner∫_b^z ∂_x u dz'close cleanly onceuhas been expanded.
- layer_expand(field, layer_idx, zeta_transformed=False)#
Per-layer closure substitution for a
LayeredBasis: inside layerlayer_idxthe field is closed against that layer’s coefficients.Args
field: Variable being closed (e.g.state.u).layer_idx: Index intoself._bf.interfacesspecifying the layer.zeta_transformed:False: the bulk key isfielditself;True(afterAffineProjection):field.subs(z, ζ·h_i + z_i).
Returns
Substitution dict with three entries (volume, lower and upper interface of the current layer).
Raises
ValueError: If the wrapped basis function is not aLayeredBasis.
- layer_phi(layer_idx)#
Zstruct of inner test functions in the shared
ζsymbol.Equivalent to
basis.phibut for one layer of aLayeredBasis. Use after AffineProjection, inside integrands, when you needφ_inner_k(ζ)multiplied into anEvaluateIntegralstarget.Raises
ValueErroron a non-layered basis.
- layer_phi_of_z(layer_idx)#
Zstruct of inner test functions at
ζ_i(z) = (z − z_i)/h_i.Equivalent to
basis.phi_of_zbut for one layer of aLayeredBasis. Use before depth-integration —Multiply(basis.layer_phi_of_z(i), outer=True)on a layer’s branch produces its Galerkin test equations, one per inner moment, each carrying the rescaledφ_inner_k((z − z_i)/h_i)factor that the Leibniz rule will dismantle.Raises
ValueErroron a non-layered basis.
- zoomy_core.model.operations.integrate_by_parts(f, g, var, domain=(0, 1))#
Standalone IBP: integral d(f)/dvar * g dvar = [f*g]_a^b - integral f * dg/dvar dvar Returns IBPResult(integrate, boundary_upper, boundary_lower).
- zoomy_core.model.operations.gauss_legendre_integrate(expr, var, a, b, order=4)#
- class zoomy_core.model.operations.Symmetrize(rule)#
Bases:
zoomy_core.model.operations.Operationa → a/2 + rule(a/2).When
rule = ProductRule()and the term has the self-pair shapec · f · ∂_v(f), the cancellation underEquation.simplifyleaves a clean conservative atomc/2 · ∂_v(f²). Used for the gravity self-pair fold in SME / VAM.Initialization
- class zoomy_core.model.operations.ResolveDummy(dummy, value)#
Bases:
zoomy_core.model.operations.OperationReplace every
dummy(arg)call across every equation in a model.Promoted from the legacy notebook
Model.resolve_dummymethod to a full Operation. The new Model surface is minimal (add_equation / remove_equation / apply / describe) and substitutions go viamodel.apply(ResolveDummy(dummy, value)).Parameters
dummy: A sympy
Functionplaceholder (e.g.phi_u_fn,psi_u,omega) that the derivation introduced as an opaque modal-ansatz / weight stand-in.value:
sympy expression / scalar → single substitution (replace every
dummy(arg)withvalue);callable
f(*args) → sp.Expr→ call-with-args substitution (dummy(a, b) → value(a, b));list / tuple → branch each equation containing the dummy into one sub-equation per list element (outer product on the dummy axis). Each sub-equation is named
"{eq_name}_{i}".
Initialization
- apply_to_model(model)#