zoomy_core.model.operations module#
General-purpose INS equation framework with composable projection operations.
- Class hierarchy:
StateSpace — shared symbols (coordinates, fields, stress tensor, parameters) SymbolicBase — name + display ├── Expression — PDE terms with .project(), .ibp(), .apply(), .terms, [i] └── Relation — lhs = rhs substitution rules with .apply_to()
├── Assumption — physical conditions (kinematic BCs, hydrostatic) └── Material — constitutive models (Newtonian, inviscid)
FullINS(state) — builds INS equations from a StateSpace
Design: the user drives every step. No hidden assumptions.
- class zoomy_core.model.operations.StateSpace(dimension=2)#
Bases:
objectShared 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).
- property horizontal_dim#
- property has_y#
- class zoomy_core.model.operations.SymbolicBase(name='')#
Bases:
objectBase for all symbolic objects. Provides name and notebook display.
- class zoomy_core.model.operations.Expression(expr, name='', term_groups=None, solver_groups=None, term_tags=None, tag_order=None)#
Bases:
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_
- 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 (for
time_derivativedetection).coords (iterable of sympy Symbols) – Spatial coordinates (
x,y, …) for flux / NCP.parameters (iterable, optional) – Known external Function/Symbol atoms (bathymetry
b(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.populated. (Returns a new Expression with solver_groups) –
preserved. (Physical tags are) –
- project(test_func, var, domain=(0, 1), weight=1, scale=1, numerical=False, order=4)#
Galerkin projection: integral(expr * test * weight * scale, (var, a, b)).
- ibp(var, test_weight, domain=(0, 1), scale=1)#
Integration by parts on the outermost Derivative w.r.t. var. Returns IBPResult(integrate, boundary_upper, boundary_lower).
- apply(*conditions, rhs=<object object>)#
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.
- 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.
- Parameters:
lower (sympy expressions for the integration bounds) – (typically b and b+H, both functions of t and x)
upper (sympy expressions for the integration bounds) – (typically b and b+H, both functions of t and x)
var (Symbol) – The vertical coordinate (z)
method (str) –
‘auto’ : detect derivative direction and choose method ‘leibniz’ : pull horizontal derivative outside:
int df/dx dz = d/dx[int f dz] - f(upper)*d(upper)/dx + f(lower)*d(lower)/dx
- ’fundamental_theorem’for vertical derivatives:
int df/dz dz = f(upper) - f(lower)
’direct’ : keep as Integral(expr, (var, lower, upper))
- Returns:
DepthIntegralResult with .volume, .boundary_upper, .boundary_lower
or a plain Expression for ‘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.
- expand()#
- 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:
The fully depth-integrated equation with BCs applied.
- Return type:
- 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#
only temporal (d/dt) terms.
- Type:
View
- property convective#
only convective flux (d/dx) terms.
- Type:
View
- 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 (sympy expressions (optional)) – The integration bounds (b, eta). If None, detected from the first Integral found.
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:
With all depth integrals replaced by basis matrix products.
- Return type:
- 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) – Display
uinstead ofu(t, x, z).
- class zoomy_core.model.operations.DepthIntegralResult(volume, boundary_upper, boundary_lower)#
Bases:
objectResult of depth-integrating a term over [lower, upper].
- 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().
- 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)#
Bases:
objectStructured result from integration by parts.
- integrate#
the volume integral (Expression)
- boundary_upper#
boundary term at upper limit (Expression)
- boundary_lower#
boundary term at lower limit (Expression)
- apply_bcs(bc_lower=None, bc_upper=None)#
- assemble()#
- class zoomy_core.model.operations.Relation(substitutions, name='')#
Bases:
SymbolicBaseA symbolic relation: one or more substitution rules lhs_i = rhs_i.
Used as base for Assumption and Material. Can be applied to Expressions via expr.apply(relation), which calls relation.apply_to(expr).
Displays as a system of equations in notebooks.
- apply_to(expr)#
Substitute all lhs -> rhs in the given SymPy expression.
- solve_for(variable)#
Re-solve every rule
lhs = rhsforvariableand return a newRelationwith substitution dict{variable: solution}.Pipeline-style counterpart to
Expression.solve_for(): lets a chain author flip the substitution direction of a relation without rewriting the relation by hand. Example: a kinematic BCw|_eta = ∂_t eta + u·∂_x etare-solved forDerivative(state.h, state.t)returns{Derivative(h, t): w|_eta - Derivative(b, t) - u·∂_x eta}, which is exactly the substitution Leibniz boundary terms need to cancel against the ∂_z fundamental-theorem boundaries.Each rule contributes one substitution; if multiple rules contain
variablein their balancelhs - rhs, all are re-solved and merged into one dict (later wins on duplicate keys, matchingdict.updatesemantics).Raises
ValueErrorif no rule’s balance containsvariable(so the call is never silently a no-op).
- class zoomy_core.model.operations.Assumption(substitutions, name='')#
Bases:
RelationPhysical assumption (kinematic BC, hydrostatic, etc.).
- class zoomy_core.model.operations.Material(substitutions, name='')#
Bases:
RelationConstitutive model (Newtonian, inviscid, etc.).
- class zoomy_core.model.operations.Operation(name='', description=None)#
Bases:
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_leaf()to 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.- 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:
OperationMultiply an Expression (or tree of Expressions) by a factor.
- Default behaviour (
outer=False) — rank-preserving: Scales every term of a leaf Expression by
factor. 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
- property rank_changes_leaf#
bool(x) -> bool
Returns True when the argument x is true, False otherwise. The builtins True and False are the only two instances of the class bool. The class bool is a subclass of the class int, and cannot be subclassed.
- Default behaviour (
- class zoomy_core.model.operations.DepthIntegrate(state)#
Bases:
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.
- class zoomy_core.model.operations.ApplyKinematicBCs(state)#
Bases:
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.
- 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:
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_integrate()so 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).
- whole_leaf_op = True#
- class zoomy_core.model.operations.Expand(field, basis, coefficients, state, name='expand', description=None)#
Bases:
OperationSubstitute a vertical field with its polynomial ansatz expansion in unevaluated Sum form.
Replaces every call
field(t, x, [y,] arg_z)withSum(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.
- class zoomy_core.model.operations.ProjectBasisIntegrals(basis_cache, name='project_basis_integrals', description=None)#
Bases:
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.
- class zoomy_core.model.operations.SimplifyIntegrals(state)#
Bases:
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
- class zoomy_core.model.operations.Integrate(var, lower, upper, method='auto')#
Bases:
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
- whole_leaf_op = True#
- class zoomy_core.model.operations.IntegralTransform(ref_interval=(0, 1), ref_name='\\hat{\\zeta}', name='integral_transform', description=None)#
Bases:
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 outer z → 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.
- whole_leaf_op = True#
- class zoomy_core.model.operations.IsolateBasisIntegrand(name='isolate_basis_integrand', description=None)#
Bases:
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 differentvar``s (e.g. ``ζ̂_0,ζ̂_1, …).- whole_leaf_op = True#
- class zoomy_core.model.operations.MapBasisToReference(b, h, name='map_basis_to_reference', description=None)#
Bases:
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 (sympy expressions) – The bottom topography and column height entering the affine map
z = ζ·h + b. Typicallystate.bandstate.h.h (sympy expressions) – The bottom topography and column height entering the affine map
z = ζ·h + b. Typicallystate.bandstate.h.
- class zoomy_core.model.operations.PartialIntegrate(var, upper_symbol=None, lo_match=None, hi_match=None, name='partial_integrate')#
Bases:
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).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))
- class zoomy_core.model.operations.AffineProjection(state, lower=None, upper=None, zeta=None, name=None, description=None, *, rewrite_basis_args=True)#
Bases:
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.
- class zoomy_core.model.operations.SigmaTransform(state, src=None, *, sigma=None, name='sigma_transform', description=None)#
Bases:
OperationChange of variables (t, x, [y,] z) → (t, x, [y,] ζ) on a PDE leaf.
With ζ = (z − b) / h, every field ψ(t, x, [y,] z) becomes ψ̃(t, x, [y,] ζ). Derivatives are rewritten via the chain rule:
Reference: Kowalski & Torrilhon 2019, “Moment Approximations and Model Cascades for Shallow Flow”, Commun. Comput. Phys. 25 (2019), eqs. (3.5)–(3.9). The conservative form (their (3.8),
h · ∂_s ψ = ∂_s(h ψ̃) − ∂_ζ(∂_s(ζ h + b) ψ̃))
is the result of multiplying the chain-rule form by
hand applying the product rule. Reach it withmodel.apply(Multiply(state.h)).apply(Recombine(...))after this transform.- Where it fits relative to
AffineProjection: AffineProjectionrewrites integrals∫_b^η f(z) dz→h · ∫_0^1 f(ζh+b) dζat integration time; the underlying PDE stays in z.SigmaTransformrewrites the PDE itself in (t, x, [y,] ζ).
The two operations are complementary. Use
SigmaTransformwhen you want the PDE on the fixed reference domain ζ ∈ [0, 1] before the moment / Galerkin step (K&T 2019, §3.1). UseAffineProjectionwhen you keep the PDE in z and only need the z-integral substituted at projection time (SME / VAM derivations that followphysical_z.py).- The operator:
substitutes every free
state.zbyζ·h + b;walks every
sympy.Derivativeand rewrites ∂_s and ∂_z per the formulas above;substitutes
z → ζin the argument list of everysympy.Functionatom (sou(t, x, z)becomesu(t, x, ζ)). The function head is kept — ψ and ψ̃ share a name in K&T’s text; sympy distinguishes by the call arguments.
Only first-order derivatives are supported. Mixed / higher-order z-derivatives raise (the K&T 2019 reference system has none at the relevant derivation stage; see eqs. (3.1)–(3.3)).
- Where it fits relative to
- class zoomy_core.model.operations.Recombine(rules=None, vars=(), aliases=None, name=None, description=None)#
Bases:
OperationFold expanded expressions back into compact / conservative shapes.
Typically run at the end of a derivation to pretty up
describe()output:h·∂_t α + α·∂_t hbecomes∂_t(h α), cancels like(z_1 − b)can be aliased toh_0, and any trivially-zero Derivative re-introduced downstream gets collapsed.Rule catalog.
Recombine.RULESis a module-level dict mapping a rule’s name to acallable(expr, **kwargs) → expr. The default implementation ships three rules:anti_product_rule— the classicα·∂_v f + f·∂_v α → ∂_v(α·f)fold (needsvars=[...]).apply_aliases— replaces user-declared sub-expressions with shorter names (needsaliases={...}).collapse_trivial_derivative—Derivative(c, v) → 0ifchas nov.
Select / order rules via the
rulesargument:# Default: all rules, in catalog order. eq.apply(Recombine(vars=[t, x], aliases={h_0: z_1 - b}))
# Only the anti-product-rule, for display cleanup: eq.apply(Recombine(rules=[“anti_product_rule”], vars=[t, x]))
# Custom rule: pass a callable directly alongside / instead # of a named rule. eq.apply(Recombine(rules=[“anti_product_rule”, my_rule],
vars=[t, x]))
Extending the catalog. Add a rule once — available to every subsequent
Recombine:Recombine.RULES[“my_rule”] = my_rule_fn
A rule is any callable with signature
fn(expr, **kwargs) → expr. It receives the fullvars/aliaseskwargs; unknown kwargs are harmless (they get swallowed by**kwargs).- RULES = {'anti_product_rule': <function _rule_anti_product_rule>, 'apply_aliases': <function _rule_apply_aliases>, 'collapse_trivial_derivative': <function _rule_collapse_trivial_derivative>, 'combine_derivatives': <function _rule_combine_derivatives>}#
- whole_leaf_op = True#
- class zoomy_core.model.operations.ProductRule(variables=None, direction='inverse')#
Bases:
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).Mulwith exactly oneDerivative(f, v)factor → inverse:coeff · ∂_v(f) → ∂_v(coeff · f) − ∂_v(coeff).doit() · f(gets us into divergence form for the integration step).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.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.- single_term_only = False#
- zoomy_core.model.operations.FullINS(state, equations=None)#
Build the incompressible Navier-Stokes as a
Systemtree.Tree shape:
system ├── continuity (scalar leaf) └── momentum (intermediate Zstruct) ├── x (leaf) ├── y (leaf, only in 3D) └── z (leaf)Access:
ins = FullINS(state) ins.continuity # scalar proxy ins.momentum # intermediate proxy ins.momentum.x.apply(op) # mutate one component leaf ins.momentum.apply(op) # mutate every component leaf
- Parameters:
state (StateSpace) –
equations (list of str, optional) – Components to include. Options:
"continuity","momentum.x","momentum.y","momentum.z". Top-level"momentum"means all momentum components. Default: everything.
- class zoomy_core.model.operations.Newtonian(state, nu=None)#
Bases:
MaterialNewtonian fluid: tau_ij = mu * (du_i/dx_j + du_j/dx_i), mu = rho * nu.
- Parameters:
state (StateSpace) –
- class zoomy_core.model.operations.Inviscid(state)#
Bases:
MaterialInviscid fluid: all tau_ij = 0.
- Parameters:
state (StateSpace) –
- class zoomy_core.model.operations.materials#
Bases:
objectMaterial model library. Usage: materials.newtonian(state)
- 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:
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 (typically
state.zeta_ref).u_field (sympy.Function call) – The opaque
u(t, x, σ)atom whose layer-mean closure we use.u_layer (sequence of sympy Function calls) – Per-layer mean velocities
u_α(t, x), one per layer α.weights (sequence of sympy expressions, optional) – Layer weights
l_αsumming to 1. Default: uniforml_α = 1/L.max_u_power (int, optional) – Maximum monomial power of
u_fieldto recognise in IBP patternsf(σ) · u^k · ∂_σ u(default 3 — covers∂_σ u,u·∂_σ u(momentum convection),u²·∂_σ u(cubic shallow-water) andu³·∂_σ u).name – Standard operation labels for derivation history.
description – Standard operation labels for derivation history.
- rank_changes_leaf = True#
- class zoomy_core.model.operations.KinematicBC(state, interface, src=None, *, at=None, rho=None, mass_flux=None, name=None, description=None)#
Bases:
AssumptionKinematic 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:\[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 to
interface— 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 flux
mper 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 (str, optional) – History labels.
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))
- zoomy_core.model.operations.KinematicBCBottom(state)#
w|_{z=b} = ∂_t b + u_b ∂_x b [+ v_b ∂_y b](physical z).- Parameters:
state (StateSpace) –
- zoomy_core.model.operations.KinematicBCSurface(state)#
w|_{z=η} = ∂_t η + u_s ∂_x η [+ v_s ∂_y η](physical z).- Parameters:
state (StateSpace) –
- zoomy_core.model.operations.InterfaceKBC#
alias of
KinematicBC
- class zoomy_core.model.operations.NoTangentialBoundaryStress(state)#
Bases:
AssumptionZero 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.- Parameters:
state (StateSpace) –
- class zoomy_core.model.operations.HydrostaticPressure(state)#
Bases:
Assumptionp = p_atm + rho * g * (eta - z)
- Parameters:
state (StateSpace) –
- class zoomy_core.model.operations.StressFreeSurface(state)#
Bases:
AssumptionStress-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)
- Parameters:
state (StateSpace) –
- class zoomy_core.model.operations.ZeroAtmosphericPressure(state)#
Bases:
Assumptionp_atm = 0 (no atmospheric pressure).
- Parameters:
state (StateSpace) –
- class zoomy_core.model.operations.assumptions#
Bases:
objectAssumptions library.
- kinematic_bc_bottom()#
w|_{z=b} = ∂_t b + u_b ∂_x b [+ v_b ∂_y b](physical z).- Parameters:
state (StateSpace) –
- kinematic_bc_surface()#
w|_{z=η} = ∂_t η + u_s ∂_x η [+ v_s ∂_y η](physical z).- Parameters:
state (StateSpace) –
- hydrostatic_pressure#
alias of
HydrostaticPressure
- stress_free_surface#
alias of
StressFreeSurface
- zero_atmospheric_pressure#
alias of
ZeroAtmosphericPressure
- class zoomy_core.model.operations.Basis(state, basisfunction_cls, level=0, alpha_name='alpha', **basis_kwargs)#
Bases:
objectVertical 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.models.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.- Parameters:
state (StateSpace) –
- 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 layer
ithe field is closed against that layer’s coefficients:\[\text{field}(t, x, z) \;\longrightarrow\; \sum_{k=0}^{m-1} \alpha_{i\,m + k}(t, x)\, \phi^{\text{inner}}_{k}(\zeta_i(z))\]where
m = inner_level + 1is the number of moments per layer andζ_i(z) = (z - z_i) / h_i. The returned substitution dict has three entries (volume + lower interface + upper interface) — the side-local convention means the two interface evaluations plug inζ_i = 0andζ_i = 1of the current layer’s inner basis; a neighbouring layer applying its ownlayer_expandproduces its own (generally different) interface value, which is exactly the jump that drives the multi-layer mass-flux terms.- Parameters:
field (sympy Function call) – The variable being closed (e.g.
state.u).layer_idx (int) – Index into
self._bf.interfacesspecifying the layer.zeta_transformed (bool, default
False) – IfFalse(the SWE case), the bulk key isfielditself — substitutesu(t,x,z) → Σ α·φ_inner(ζ_i(z)). IfTrue(the SME case, applied afterAffineProjection), the bulk key becomesfield.subs(z, ζ·h_i + z_i)and the substituted form reduces toΣ α·φ_inner(ζ)in the shared zeta symbol, ready forEvaluateIntegralsto collapse the orthogonality integrals.(``Monomials (For the piecewise-constant case) –
inner_level=0``) –
you (the sum collapses to a single coefficient per layer and) –
α_i. (recover the simple SWE closure u →) –
a (Raises ValueError if the wrapped basisfunction isn't) –
LayeredBasis. –
- 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:
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.
- class zoomy_core.model.operations.ResolveDummy(dummy, value)#
Bases:
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}".
- apply_to_model(model)#