zoomy_core.model.operations module

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: object

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).

property horizontal_dim#
property has_y#
class zoomy_core.model.operations.SymbolicBase(name='')#

Bases: object

Base 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: SymbolicBase

Symbolic 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 .apply mutates 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=value kwarg is split into additive terms and each term is recorded as belonging to name in 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 (via simplify) 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 canonical flux slot.

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.

name can 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.expr is classified:

  • coeff * Derivative(q, t) with q a state variable → time_derivative.

  • coeff * Derivative(F, x_i) where coeff is state-variable- free → flux (conservative form).

  • coeff * Derivative(q_j, x_i) with q_j a state variable and coeff containing state vars → nonconservative_flux.

  • coeff * Derivative(p, x_i) where p is a declared parameter and coeff contains state vars → also nonconservative_flux (the term couples a state coefficient to a parameter gradient — e.g. bathymetry-gradient forcing).

  • No derivative and purely algebraic in state_vars parameters space → 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_derivative detection).

  • 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.expr individually. 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 to expr.apply({lhs: 0}). Valid only with a single positional argument which is a sympy expression (or Expression).

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 both self.expr and the tag dict.

Operations are invoked via their unified __call__ interface; when the op produces multiple output pieces per input term (DepthIntegrate etc.) 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_relation maps variable to the solution of self.expr == 0.

Pipeline-style counterpart to _NodeProxy.solve_for: does not mutate a system tree. Downstream model.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 any Derivative wrapper. Returns a new Expression whose .terms exposes one logical equation term per Integral.

merge_integrals()#

Group sibling Integrals with matching (limits, deriv-wrapper) signature into a single Integral(Σ c·f, lim). Inverse of split_integrals(); useful when cross-integrand cancellations need sympy’s Add canonicalisation 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:

Expression

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 expansion u(z) = sum alpha_k phi_k(zeta) and evaluating the resulting integrals using the SymbolicIntegrator.

If test_mode is an integer, multiplies each integral by the test function phi_{test_mode}(zeta) before evaluating (Galerkin projection for a specific mode). If test_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:

Expression

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 (requires term_groups).

describe(header=True, final_equation=True, parameters=False, strip_args=True)#

Composable description of this expression.

Returns a Description that 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 u instead of u(t, x, z).

class zoomy_core.model.operations.DepthIntegralResult(volume, boundary_upper, boundary_lower)#

Bases: object

Result 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: object

Structured 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: SymbolicBase

A 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 = rhs for variable and return a new Relation with 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 BC w|_eta = ∂_t eta + u·∂_x eta re-solved for Derivative(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 variable in their balance lhs - rhs, all are re-solved and merged into one dict (later wins on duplicate keys, matching dict.update semantics).

Raises ValueError if no rule’s balance contains variable (so the call is never silently a no-op).

class zoomy_core.model.operations.Assumption(substitutions, name='')#

Bases: Relation

Physical assumption (kinematic BC, hydrostatic, etc.).

class zoomy_core.model.operations.Material(substitutions, name='')#

Bases: Relation

Constitutive model (Newtonian, inviscid, etc.).

class zoomy_core.model.operations.Operation(name='', description=None)#

Bases: SymbolicBase

A structural transformation on a tree node.

A node is either an Expression (leaf) or a Zstruct of 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 (Multiply with outer=True on 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 + description so System.apply can 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: Operation

Multiply 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 in Expression.apply(), so model.apply(Multiply(1/rho)) scales the whole system and keeps physical tags intact.

outer=Truerank-changing (leaf → Zstruct):

factor must be a Zstruct of scalar factors (e.g. test functions phi_0, phi_1, ...). The leaf Expression is promoted to a Zstruct with one child Expression per factor, keyed test_<index>. Each child is leaf.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 every test_<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.

class zoomy_core.model.operations.DepthIntegrate(state)#

Bases: Operation

Depth-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 Subs objects. Apply ApplyKinematicBCs to see the cancellations.

class zoomy_core.model.operations.ApplyKinematicBCs(state)#

Bases: Operation

Apply kinematic BCs globally to combined boundary terms.

Evaluates Subs boundary 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]:

  • limits is the full (var, lower, upper) triple passed to _cached_integrate (matching sympy.integrate’s call shape).

  • Return the definite integral if the pattern matches — the caller does no further transformation.

  • Return None if the rule doesn’t apply. _cached_integrate tries rules in registration order; the first non-None wins.

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: Operation

Run sympy.integrate on every Integral node in the expression.

After AffineProjection + basis expansion the volume integrals look like Integral(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 (every test_k clone after Multiply(basis.phi, outer=True)) cost one sympy.integrate call 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: Operation

Substitute 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.Sum atom rather than the unrolled Add. Two placeholders make this work as one symbolic chunk:

  • basis.phi_fn is the basis’s opaque 2-arg Function (carries _basis = basis); concrete polynomial substitution is deferred until EvaluateIntegrals.

  • coeff_fn is a private sympy Function class named coeff_<basis.symbol> and carrying a _coeff_table class attribute holding the pre-declared coefficient list. It stays opaque through ProductRule, AffineProjection, and Integrate (the Sum carries through as a single mathematical term). EvaluateIntegrals substitutes coeff_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)/h becomes 0 or 1 respectively, the Sum is Σ_k amp_k · phi_fn(k, 0|1), and EvaluateIntegrals’ final cleanup pass resolves these via basis.resolve_atoms once the Sum has been .doit()-ed.

Why Sum-form: at higher levels (L ≥ 1), the unrolled Add form has L+1 terms per expanded field — products of two expanded fields explode quadratically, and ProductRule would have to be applied to each one individually. The single-Sum representation keeps the equation compact and makes ProductRule a 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 is u(t, x, z)).

  • basis (Basisfunction) – The basis instance. basis.phi_fn is 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 equal basis.level + 1. Substituted into the Sum by EvaluateIntegrals after Sum.doit() unrolls the symbolic index to concrete integers.

  • state (StateSpace) – The state space — used to read state.b and state.h for the affine ζ-map.

class zoomy_core.model.operations.ProjectBasisIntegrals(basis_cache, name='project_basis_integrals', description=None)#

Bases: Operation

Resolve every Integral(..., (ζ, a, b)) via basis-aware cache lookup.

Replaces the old “expand polynomial basis + run sympy.integrate” chain. Every integral whose integrand contains opaque phi_k(arg) nodes is mapped to a BasisIntegralCache query, with ζ-independent factors pulled out first so the kernel the cache sees is a clean basis-only product.

Steps, applied per leaf expression:

  1. Canonicalize Subs — rewrite Subs(Derivative(phi_k(ξ), ξ), ξ, arg) (the sympy chain-rule leftover) back to Derivative(phi_k(arg), arg).

  2. Distribute AddsIntegral(Add(t1, t2), …) Σ Integral(ti, …) so each term can be factored independently.

  3. Factor constants out + cache query — for each Integral(kernel, (var, a, b)):

    • split the Mul integrand into var-independent factor C and var-dependent kernel K;

    • if K contains any phi_k, hand K to the cache and multiply its result by C;

    • otherwise leave the Integral alone.

The cache returns a sympy scalar / polynomial; nested running integrals inside K get 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.integrate is ever called on an opaque phi_k: the cache concretizes phi_k against the basis polynomial first, then does polynomial integration only.

class zoomy_core.model.operations.SimplifyIntegrals(state)#

Bases: Operation

Evaluate 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: Operation

Integrate each additive term of an equation w.r.t. var over [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_i where x_i != var): ∂_x f dvar = ∂_x [∫ f dvar]  f|_upper · ∂_x upper  + f|_lower · ∂_x lower.

  • "fundamental_theorem" — for terms containing ∂/∂var as the outermost derivative: ∂_var f dvar = f|_upper f|_lower.

  • "direct" — keep as an unevaluated Integral(expr, (var, lower, upper)).

  • "analytical" — run sympy.integrate on the whole expression (what the original small Integrate did; 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: Operation

Affine change of variable applied to every Integral in 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 each Integral: only the integration variable is replaced, only inside the integrand of the Integral it’s bound to. Sibling occurrences of var in the expression — boundary Subs terms, the outer z of 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 var throughout its own integrand, any inner Integral’s now-renamed limits (in the old var) get rewritten in terms of the outer’s reference variable — so a running integral Integral(g(ẑ), (ẑ, b, z)) sitting inside Integral(f(z), (z, b, b+h)) correctly transforms to Integral(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: Operation

Rewrite each Integral(integrand, (var, a, b)) so the integrand contains only factors that depend on var.

Two passes per Integral:

  1. Distribute Integral(Add(t1, t2, …), …) Σ Integral(t_i, …), so each term can be analysed independently.

  2. For each (now single-term) Integral, split the multiplicative factors of the integrand into a var-independent coefficient C and a var-dependent kernel K, and rewrite as C · Integral(K, (var, a, b)).

After this, every Integral has the form coeff · Integral(kernel, …) where kernel only contains var (and any opaque basis functions / derivatives evaluated at var). 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 var is read directly from its own limit tuple — there’s no ambiguity, and different Integrals can carry different var``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: Operation

Rewrite 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 coordinate z. After IntegralTransform substitutes z ζ·h + b inside an integrand, integrand basis evaluations read phi_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 every phi_k call 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 by IntegralTransform — sympy’s structural subs keeps a Subs wrapper 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. Typically state.b and state.h.

  • h (sympy expressions) – The bottom topography and column height entering the affine map z = ζ·h + b. Typically state.b and state.h.

class zoomy_core.model.operations.PartialIntegrate(var, upper_symbol=None, lo_match=None, hi_match=None, name='partial_integrate')#

Bases: Operation

Rewrite 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 Qaux variable via DerivedModel.promote_to_qaux and rewritten into a runtime primitive (e.g. column_partial_integrate).

Every Integral whose integration variable equals var is inspected. Additional matchers lo_match / hi_match restrict the rewrite to Integrals whose lower/upper bounds also match (structural equality); pass None (default) to leave them unrestricted.

The new upper bound is upper_symbol (default: var itself, 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: Operation

Transform 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 / upper given, defaults to state.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 Integral nodes 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: Operation

Change 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:

∂_s ψ |_z = ∂_s ψ̃ |_ζ − (∂_s(ζ h + b) / h) · ∂_ζ ψ̃,

s ∈ {t, x, y}

∂_z ψ = (1/h) · ∂_ζ ψ̃

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 h and applying the product rule. Reach it with model.apply(Multiply(state.h)).apply(Recombine(...)) after this transform.

Where it fits relative to AffineProjection:
  • AffineProjection rewrites integrals ∫_b^η f(z) dzh · ∫_0^1 f(ζh+b) at integration time; the underlying PDE stays in z.

  • SigmaTransform rewrites the PDE itself in (t, x, [y,] ζ).

The two operations are complementary. Use SigmaTransform when you want the PDE on the fixed reference domain ζ ∈ [0, 1] before the moment / Galerkin step (K&T 2019, §3.1). Use AffineProjection when you keep the PDE in z and only need the z-integral substituted at projection time (SME / VAM derivations that follow physical_z.py).

The operator:
  • substitutes every free state.z by ζ·h + b;

  • walks every sympy.Derivative and rewrites ∂_s and ∂_z per the formulas above;

  • substitutes z ζ in the argument list of every sympy.Function atom (so u(t, x, z) becomes u(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)).

class zoomy_core.model.operations.Recombine(rules=None, vars=(), aliases=None, name=None, description=None)#

Bases: Operation

Fold expanded expressions back into compact / conservative shapes.

Typically run at the end of a derivation to pretty up describe() output: h·∂_t α + α·∂_t h becomes ∂_t(h α), cancels like (z_1 b) can be aliased to h_0, and any trivially-zero Derivative re-introduced downstream gets collapsed.

Rule catalog. Recombine.RULES is a module-level dict mapping a rule’s name to a callable(expr, **kwargs) expr. The default implementation ships three rules:

  • anti_product_rule — the classic α·∂_v f + f·∂_v α ∂_v(α·f) fold (needs vars=[...]).

  • apply_aliases — replaces user-declared sub-expressions with shorter names (needs aliases={...}).

  • collapse_trivial_derivativeDerivative(c, v) 0 if c has no v.

Select / order rules via the rules argument:

# 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 full vars / aliases kwargs; 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: Operation

Product 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).

  • Mul with exactly one Derivative(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. any Symbol named t, x, y, or z. Pass variables=[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’s Add canonicalisation in the next simplify pulls sibling terms together.

single_term_only = False#
zoomy_core.model.operations.FullINS(state, equations=None)#

Build the incompressible Navier-Stokes as a System tree.

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: Material

Newtonian 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: Material

Inviscid fluid: all tau_ij = 0.

Parameters:

state (StateSpace) –

class zoomy_core.model.operations.materials#

Bases: object

Material model library. Usage: materials.newtonian(state)

newtonian#

alias of Newtonian

inviscid#

alias of Inviscid

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: Operation

Specialise 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 keeping u(t, x, σ) opaque, then resolve the surviving bulk integrals using the layer-mean closure ∫_{layer α} u(σ) = l_α · u_α(t, x). Interior IBP rules handle f(σ) · u^k · ∂_σ u terms via the identity u^k · ∂_σ u = (1/(k+1)) ∂_σ u^{k+1}.

The boundary atoms u(t, x, σ_{α±1/2}) and w(t, x, σ_{α±1/2}) are deliberately left opaque in the resulting per-layer leaves. That is the user’s modeling choice — apply KinematicBC with mass_flux=G_{α+1/2}/ρ at each interface to resolve w, and apply an interface-velocity choice (donor-cell / central / sign-upwind) for the surviving u boundary 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 u opaque 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: uniform l_α = 1/L.

  • max_u_power (int, optional) – Maximum monomial power of u_field to recognise in IBP patterns f(σ) · u^k · ∂_σ u (default 3 — covers ∂_σ u, u·∂_σ u (momentum convection), u²·∂_σ u (cubic shallow-water) and u³·∂_σ 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: Assumption

Kinematic 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-minted Function("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 explicit at when the system has been mapped to a reference vertical (e.g. at=sp.S.Zero after SigmaTransform for the bottom; at=sp.S.One for the surface; per-layer σ values in ML). The substitution is purely structural — replaces state.z by at in 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 m per unit area per unit time crossing the interface. Adds mass_flux / ρ to the RHS. Default None for impermeable interfaces (bottom / free surface). Use a fresh Function for 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 flux m_k; each layer’s state has its own σ but the formula is the same — only interface, at, and mass_flux change 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: Assumption

Zero 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: Assumption

p = p_atm + rho * g * (eta - z)

Parameters:

state (StateSpace) –

class zoomy_core.model.operations.StressFreeSurface(state)#

Bases: Assumption

Stress-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: Assumption

p_atm = 0 (no atmospheric pressure).

Parameters:

state (StateSpace) –

class zoomy_core.model.operations.assumptions#

Bases: object

Assumptions 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: object

Vertical polynomial basis for the SME expansion u = Σ α_k φ_k(ζ).

Wraps a Basisfunction class (Legendre_shifted etc.) and exposes symbolic pieces for the apply-based walkthrough:

  • basis.phiZstruct(phi_0, …, phi_N) of sympy expressions in state.zeta_ref (the reference-element symbol on [0, 1]). Drop-in factor for Multiply(basis.phi, outer=True) after AffineProjection has collapsed the opaque state.zeta to state.zeta_ref.

  • basis.alphaZstruct(alpha_0, …, alpha_N) of Function coefficients α_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 at z=b / z=η. Feed it to model.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 after AffineProjection so 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 — matches field(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 once u has been expanded.

layer_expand(field, layer_idx, zeta_transformed=False)#

Per-layer closure substitution for a LayeredBasis.

Inside layer i the 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 + 1 is 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 = 0 and ζ_i = 1 of the current layer’s inner basis; a neighbouring layer applying its own layer_expand produces 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.interfaces specifying the layer.

  • zeta_transformed (bool, default False) – If False (the SWE case), the bulk key is field itself — substitutes u(t,x,z) Σ α·φ_inner(ζ_i(z)). If True (the SME case, applied after AffineProjection), the bulk key becomes field.subs(z, ζ·h_i + z_i) and the substituted form reduces to Σ α·φ_inner(ζ) in the shared zeta symbol, ready for EvaluateIntegrals to 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.phi but for one layer of a LayeredBasis. Use after AffineProjection, inside integrands, when you need φ_inner_k(ζ) multiplied into an EvaluateIntegrals target.

Raises ValueError on 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_z but for one layer of a LayeredBasis. 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 ValueError on 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: Operation

a a/2 + rule(a/2).

When rule = ProductRule() and the term has the self-pair shape c · f · ∂_v(f), the cancellation under Equation.simplify leaves a clean conservative atom c/2 · ∂_v(f²). Used for the gravity self-pair fold in SME / VAM.

class zoomy_core.model.operations.ResolveDummy(dummy, value)#

Bases: Operation

Replace every dummy(arg) call across every equation in a model.

Promoted from the legacy notebook Model.resolve_dummy method to a full Operation. The new Model surface is minimal (add_equation / remove_equation / apply / describe) and substitutions go via model.apply(ResolveDummy(dummy, value)).

Parameters:
  • dummy – A sympy Function placeholder (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) with value);

    • 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)#