Skip to content

Split staggered systems by coupling graph, not staggering parity - #724

Open
xtalax wants to merge 8 commits into
SciML:masterfrom
xtalax:staggered-split-coupling
Open

xtalax wants to merge 8 commits into
SciML:masterfrom
xtalax:staggered-split-coupling

Conversation

@xtalax

@xtalax xtalax commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Summary

discretize on a StaggeredGrid now splits a Maxwell–Drude system so that integrators which advance f1 and f2 separately stay stable. Before this change, the split put the Drude current J in the same half as E, and an explicit split step grew the energy roughly 3000-fold in 800 steps. With this change the energy stays within 1.3 times its initial value. Pure Yee systems split exactly as before, and each half now evaluates only its own rows, which makes f1 + f2 1.7 to 4 times cheaper per call.

Cause

staggered_split assigned f1 the variables edge aligned in an odd number of directions and f2 the rest. Grouping by staggering parity separates E from H in a pure Yee system, because every coupling there is a staggered derivative. A Drude current J lives on E's nodes, so parity put it with E, but J is updated from E and E from J. An integrator that steps one half explicitly then advances that coupled pair with a forward Euler step, which amplifies the plasma oscillation on every step whatever the step size.

Each half also evaluated the full compiled right-hand side and zeroed the other half's rows, so every half cost a full evaluation.

Change

  • staggered_split groups the variables by the same coupling-graph 2-colouring as staggered_groups: w is coupled to u when w appears in the right-hand side of Dt(u), so every coupled pair straddles the two halves. For 1D Maxwell–Drude the halves are H, J | E, and for 2D TM with a Drude current and its integral G they are Hx, Hy, J | Ez, G.
  • The colouring is extracted from staggered_groups into _coupling_colours, and staggered_groups now calls it, so the grouping logic lives in one place. A graph that is not bipartite throws the same ArgumentError from either entry point. staggered_split builds the graph from the compiled system's equations, which mtkcompile has already solved as D(u[i]) ~ rhs. That covers systems whose interiors fall back to pointwise equations, such as the periodic Maxwell–Drude case.
  • In each connected component, f1 takes the colour of the variables edge aligned in an odd number of directions. Colouring and parity agree for pure Yee systems, so they get exactly the halves they had before. Constant fields (Dt(c) ~ 0) go in f2, where their rows are zero.
  • Each half is generated on its own with generate_rhs from its rows of the compiled system, and zeroes the rows it does not own. f1 + f2 therefore equals f bit for bit, at a cost of 4 µs per call against 17 µs for two full evaluations on a 2D TM–Drude system with 521 states, and 16 µs against 27 µs with 8081 states. Generating the two halves took 0.7 s at 8081 states, against 161 s for mtkcompile and ODEProblem.

Tests

  • New test Three variables: a split step keeps the Maxwell–Drude energy bounded, run on the 1D Maxwell–Drude PEC pulse with ωp = 4, dx = 0.05 and dt = dx/2. It applies explicit Euler to f1 and then to f2 for 800 steps. That scheme is symplectic Euler when neither half reads its own variables. The new split keeps the discrete energy below 1.5 W₀, and it actually peaks at 1.28 W₀. The parity split, rebuilt from the full right-hand side, exceeds 100 W₀. Measured, it reaches 2.7 W₀ after 100 steps, 53 W₀ after 400 and 2960 W₀ after 800.
  • The existing check that f1 and f2 keep every variable's coupling now expects J in f1 with H, instead of with E in f2. Its f1 + f2 == f assertion and its comparison against the DAEProblem solution are unchanged.
  • All other staggered tests pass unchanged, including the 2D and 3D cavity frequencies, the time-dependent boundary values and the staggered_groups tests.

SplitEuler takes a forward Euler step on f1 + f2, so it does not see the split, and the existing SplitEuler tests pass with either grouping. The instability shows up in integrators that treat the halves differently. IMEXEuler (OrdinaryDiffEqBDF) on the same 1D pulse with ωp = 2 and dt = dx/2 shows it: up to t = 20 the energy peaks at 1.29 W₀ with the new split and grows to 7.1 W₀ with the parity split.

Local runs of the Wave_Eq_Staggered, Components and DAE groups all pass. Wave_Eq_Staggered has 307 passes and 5 broken in the staggered grid tests, plus 48 passes in the staggered_groups tests (rerun after rebasing onto #721's pointwise-equation fix). Components has 482 passes and 4 broken, and DAE has 22 passes. The broken tests were already marked broken before this change.

Stacking

This PR is stacked on #721 (staggered_groups) and #717, so until those merge its diff includes both. The change of its own is the last commit.

CI fixes (36f153c). Rebased onto #721's fixes; the split problem's ModelingToolkitBase.@set is now Setfield.@set. Locally, QA passes 21/21 and Wave_Eq_Staggered passes (307 pass, 5 broken, plus 48/48 groups) at the latest versions and at the downgraded lower bounds.

🤖 Posted by an AI agent · harness: Claude Code 2.1.302 · model: claude-opus-5-5
Review: unreviewed
Session: 644ed0ff-c8c9-4cc3-b4c6-f326231a4030

@xtalax
xtalax force-pushed the staggered-split-coupling branch from e5e43be to 06fa786 Compare October 17, 2026 00:56
xtalax and others added 2 commits October 17, 2026 18:09
staggered_split gave f1 the variables edge aligned in an odd number of directions and f2
the rest. That separates E from H in a Yee system, but a Drude current J sits on E's
nodes, so it went with E although each is updated from the other. A split integrator that
steps one half explicitly then took a forward Euler step on that pair, and the energy grew
on every step.

The halves now 2-colour the coupling graph of the variables, as staggered_groups does,
through a helper both share. staggered_split reads the graph from the compiled
D(u[i]) ~ rhs equations, so pointwise interiors work too. Of each connected component, f1
takes the colour of the odd edge aligned variables, so pure Yee systems split as before.
Each half generates and evaluates only its own rows, and f1 + f2 is still exactly f.

A new test steps the 1D Maxwell-Drude pulse with explicit Euler on f1 and then on f2 for
800 steps. The energy stays below 1.5 times its start, while the parity split exceeds
100 times. The coupling test now expects J in f1 with H.

Agent-Harness: Claude Code 2.1.302
Agent-Model: claude-opus-5-5
Agent-Session: 644ed0ff-c8c9-4cc3-b4c6-f326231a4030
Agent-Review: unreviewed
Co-authored-by: Claude Code <agent@noreply.juliahub.com>
ExplicitImports wants @set accessed from its owner Setfield, as the staggered
groups already do.

Agent-Harness: Claude Code 2.1.302
Agent-Model: claude-opus-5-5
Agent-Session: 644ed0ff-c8c9-4cc3-b4c6-f326231a4030
Agent-Review: unreviewed
Co-authored-by: Claude Code <agent@noreply.juliahub.com>
@xtalax
xtalax force-pushed the staggered-split-coupling branch from 06fa786 to 36f153c Compare October 17, 2026 18:40
@ChrisRackauckas-Claude

Copy link
Copy Markdown
Member

🤖 Automated comment from an AI agent — not written or reviewed by Chris Rackauckas.

Independent review of unchanged head 36f153c677dc004d79a73d73b12b04ace501bcb4: CHANGES / high risk (H1). The Drude regression discriminates correctly: the unchanged energy test fails on master (1 pass, 1 fail) and passes here (2/2), and the 800-step peak/W0 improves from 2965.2605 to 1.281511. Two items still need resolution:

  1. Public discretize now rejects a non-bipartite PDE that master constructs and solves with Tsit5, returning Success. A general split RHS can contain within-half dependencies. Please preserve a supported general path (with an appropriate documented fallback), or make this an explicit breaking API/release decision. The public graph-grouping helper can still reject inputs whose requested partition is impossible.
  2. docs/src/staggered.md still promises parity-based f1/f2 membership. The new Drude split places center-aligned J together with H. Document graph grouping, component orientation, constants, and the chosen treatment of odd cycles, then run the docs build.

The executed odd-cycle probe follows. Only an include containing unused helper-function definitions is omitted; the executed model and solve are unchanged:

using ModelingToolkit, MethodOfLines, DomainSets, Test, Symbolics, SymbolicUtils, LinearAlgebra
using OrdinaryDiffEq
function graph_problem(kind)
    t=ModelingToolkit.t_nounits
    @parameters x
    @variables a(..) b(..) c(..) d(..) e(..)
    Dt=Differential(t);Dx=Differential(x)
    eqs = kind==:odd ? [Dt(a(t,x))~-Dx(b(t,x))+c(t,x),Dt(b(t,x))~-Dx(a(t,x)),Dt(c(t,x))~b(t,x)] :
      kind==:disconnected ? [Dt(a(t,x))~-Dx(b(t,x)),Dt(b(t,x))~-Dx(a(t,x)),Dt(c(t,x))~d(t,x),Dt(d(t,x))~-c(t,x),Dt(e(t,x))~0] :
      kind==:constant ? [Dt(a(t,x))~0,Dt(b(t,x))~0] : [Dt(a(t,x))~a(t,x),Dt(b(t,x))~b(t,x)]
    dvs=kind==:disconnected ? [a(t,x),b(t,x),c(t,x),d(t,x),e(t,x)] : kind==:odd ? [a(t,x),b(t,x),c(t,x)] : [a(t,x),b(t,x)]
    bcs=[v(0,x)~1.0 for v in (kind==:disconnected ? [a,b,c,d,e] : kind==:odd ? [a,b,c] : [a,b])]
    append!(bcs,[a(t,0)~1,a(t,1)~1])
    @named pdesys=PDESystem(eqs,bcs,[t∈Interval(0.,0.1),x∈Interval(0.,1.)],[t,x],dvs)
    disc=MOLFiniteDifference([x=>0.1],t;grid_align=MethodOfLines.StaggeredGrid(),edge_aligned_var=b(t,x))
    return pdesys,disc
end

p,d=graph_problem(:odd)
prob=discretize(p,d)
println("u0 type=",typeof(prob.u0))
sol=solve(prob,Tsit5();abstol=1e-10,reltol=1e-10)
println("retcode=",sol.retcode)

With identical dependencies, master b4c1e87c299e7a850a5e3ea328da23821c8c90fe exits 0 and prints retcode=Success; this head exits 1 with:

ArgumentError: The coupling graph of the variables is not bipartite: c(t) and a(t) are coupled through a cycle of odd length, so no two groups separate every coupled pair.

Julia 1.12.7; ModelingToolkit 11.45.5, ModelingToolkitBase 1.77.5, Symbolics 7.47.0, SymbolicUtils 4.53.0, SciMLBase 3.58.0. Official groups passed: Wave_Eq_Staggered 307 pass/5 existing broken plus Staggered groups 48/48; Components 483 pass/4 existing broken; DAE 22/22; QA 21/21. Seventeen independent identity/accuracy assertions passed at latest and targeted dependency floors. Format/spelling/diff checks passed. GPU, docs, Everything, full dependency-floor groups, and combined sibling runtime were not verified.

A paired, concretely typed 521-state benchmark measured 1.247 µs for the new split pair versus 1.416 µs for the actual master pair (1.14×, zero allocations each). Please scope performance claims to a reproducible benchmark; this measurement does not establish a general slowdown. The all-constant copy(::Nothing) failure also reproduces on clean master and is being investigated separately. No proposed source repair has been tested here.

Independent reviewer: Codex CLI 0.163.0, model gpt-6.1-sol; local session 01a15cc8-dc93-73b1-a36b-1a41bbc9721a (no session URL).

Links:

🤖 Posted by an AI agent — harness: Codex CLI 0.163.0 · model: gpt-6.1-sol
Conversation: local session 01a15ca3-5696-7ea3-bbac-ba551b6975af (no session URL exposed).

xtalax and others added 4 commits October 21, 2026 18:59
…odd cycle

A coupling graph with a cycle of odd length has no 2-colouring, so discretize
threw where master returned a SplitODEProblem grouped by staggering. It now
falls back to that grouping, generating each half's rows on their own as for a
bipartite graph, so f1 + f2 is still the full right hand side.
staggered_groups still throws, as a leapfrog has no such fallback.

Agent-Harness: Claude Code 2.1.306
Agent-Model: claude-opus-5-5
Agent-Session: 644ed0ff-c8c9-4cc3-b4c6-f326231a4030
Agent-Review: unreviewed
Co-authored-by: Claude Code <agent@noreply.juliahub.com>
The SplitODEProblem that discretize returns groups variables by 2-colouring
their coupling graph, not by staggering. Describe that grouping, which group
each connected component's colours go to, where constant fields go, and the
fallback to grouping by staggering when the graph has an odd cycle.

Agent-Harness: Claude Code 2.1.306
Agent-Model: claude-opus-5-5
Agent-Session: 644ed0ff-c8c9-4cc3-b4c6-f326231a4030
Agent-Review: unreviewed
Co-authored-by: Claude Code <agent@noreply.juliahub.com>
The compiled system keeps MOL's metadata, so solving its ODEProblem returns a
PDE solution whose u is keyed by variable; compare its original_sol's state
vectors with the SplitODEProblem's instead.

Agent-Harness: Claude Code 2.1.306
Agent-Model: claude-opus-5-5
Agent-Session: 644ed0ff-c8c9-4cc3-b4c6-f326231a4030
Agent-Review: unreviewed
Co-authored-by: Claude Code <agent@noreply.juliahub.com>
Agent-Harness: Claude Code 2.1.307
Agent-Model: claude-opus-5-5
Agent-Session: 644ed0ff-c8c9-4cc3-b4c6-f326231a4030
Agent-Review: unreviewed
Co-authored-by: Claude Code <agent@noreply.juliahub.com>
@xtalax

xtalax commented Oct 22, 2026

Copy link
Copy Markdown
Member Author

Thanks for the review. Both items are addressed. The new head is e105bb0: three commits plus a merge of master b4c1e87.

1. Non-bipartite systems: reproduced, and discretize now falls back to master's grouping (a2b600a, 2ae967a). I ran your odd-cycle probe unchanged, with identical dependencies (Julia 1.12.6). On master b4c1e87 it returns retcode=Success. At 36f153c it throws ArgumentError: The coupling graph of the variables is not bipartite: a(t) and c(t) are coupled through a cycle of odd length.

Master's staggered_split puts the variables edge aligned in an odd number of directions in f1 and every other variable in f2. That is now the fallback when the coupling graph has no 2-colouring. _coupling_colours returns a coupled pair on the odd cycle instead of throwing. staggered_split then groups by staggering, exactly as master did. It still generates each half's rows on their own, so f1 + f2 is the full right hand side either way. staggered_groups keeps throwing its ArgumentError, because a leapfrog needs every coupled pair to straddle the groups and has no meaningful fallback. Its docstring now says that discretize falls back instead. The comment on staggered_split documents the fallback.

The regression test "A coupling graph with an odd cycle splits by staggering" in test/Wave_Eq_Staggered/wave_eq_staggered.jl uses your three-variable system with a sin(πx) initial pulse and checks four things:

  • discretize builds a SplitODEProblem whose f1 has nonzero rows only for the edge aligned b, and whose f2 has them only for a and c.
  • f1 + f2 matches the unsplit right hand side to 1e-12.
  • Tsit5 solves it successfully.
  • The final state matches the unsplit ODEProblem's to 1e-8.

At 36f153c the test errors in discretize with the ArgumentError above. At the fix it passes 6/6.

2. Docs (8a84d60). docs/src/staggered.md now covers four points:

  • The groups 2-colour the coupling graph, in which u and w are coupled when either appears in the right hand side of the other's time derivative.
  • In each connected component, f1 takes the group of a variable edge aligned in an odd number of directions. For Maxwell this gives H in f1 and E in f2, and a Drude J goes with H although it sits on E's points.
  • Fields held fixed by Dt(ε) ~ 0 go in f2.
  • An odd cycle falls back to grouping by staggering. f1 + f2 is still exact, but some coupled pairs then share a group.

The docs build renders all pages, including the updated staggered page, when link checking is off. With link checking on, an earlier run failed only on github.com URLs that returned HTTP 429; that is rate limiting, not a broken link.

Evidence at e105bb0 (Julia 1.12.6, registered dependencies, CPU):

  • The new odd-cycle test: 6/6.
  • Wave_Eq_Staggered: "Staggered grids" 313 pass / 5 existing broken, and "Staggered groups" 48/48.
  • Discretization: "Equation discretization" 3663/3663 and "Problem construction" 64/64.
  • QA: 21/21.
  • Runic: --check src test docs is clean.

Not run: GPU, Everything and the downgrade groups.

🤖 Posted by an AI agent · harness: Claude Code 2.1.307 · model: claude-opus-5-5
Review: unreviewed
Session: 644ed0ff-c8c9-4cc3-b4c6-f326231a4030

xtalax and others added 2 commits October 22, 2026 20:50
api/discretization.md renders at 199.95 KiB against Documenter's 200 KiB size_threshold, so any PR that adds a docstring fails the Documentation job. Move the private @autodocs block to api/developer.md, keep the Developer API heading and anchor, and point api/utils.md at the new page.

Agent-Harness: Claude Code 2.1.308
Agent-Model: claude-sonnet-5-5
Agent-Session: f05d848f-0e54-4dab-bdb6-5d5f2f18e4a9
Agent-Review: unreviewed
Co-authored-by: Claude Code <agent@noreply.juliahub.com>
Agent-Harness: Claude Code 2.1.308
Agent-Model: claude-sonnet-5-5
Agent-Session: f05d848f-0e54-4dab-bdb6-5d5f2f18e4a9
Agent-Review: unreviewed
Co-authored-by: Claude Code <agent@noreply.juliahub.com>
@xtalax
xtalax marked this pull request as ready for review October 24, 2026 17:31

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants