Skip to content

Build ODEProblem from array differential equations with complete only - #5101

Closed
ChrisRackauckas-Claude wants to merge 5 commits into
SciML:masterfrom
ChrisRackauckas-Claude:preserve-array-equations-ode
Closed

ChrisRackauckas-Claude wants to merge 5 commits into
SciML:masterfrom
ChrisRackauckas-Claude:preserve-array-equations-ode

Conversation

@ChrisRackauckas-Claude

@ChrisRackauckas-Claude ChrisRackauckas-Claude commented Sep 9, 2026 •

Copy link
Copy Markdown
Member

Summary

Rewritten (no longer stacked on #5100, which is closed): this PR makes ODEProblem(complete(sys), ...) work for systems whose differential equations are written over array slices, the same way DAEProblem already does, without mtkcompile and without any new keyword.

For a finite-difference discretization such as D(u[2:(n - 1)]) ~ lap(u) (or the residual form D(u[2:(n - 1)]) .- lap(u) ~ 0 that MethodOfLines emits), ODEProblem used to throw "The system has array equations. Call mtkcompile", and mtkcompile scalarizes, so the generated code and compile time grow with n. Now the array equation stays one equation and the generated function is independent of n.

Making mtkcompile (tearing / index reduction) keep array equations intact is separate work; this PR stops at complete → ODEProblem.

What changes (all in ModelingToolkitBase)

  • generate_rhs (explicit ODE branch): array equations are rewritten to D(x) ~ f (explicit_array_derivative_form, which handles D(x) .- f ~ 0 and D(x) .+ g ~ 0), then the right-hand sides are packed into du with array_residual_maker — the same contiguous-region assembly the implicit-DAE path uses. check_lhs accepts D(x) where x is an array or slice whose elements are all unknowns; check_operator_variables checks uniqueness of the derivatives element-wise, so D(u[1:3]) together with D(u[3]) is still rejected.
  • calculate_massmatrix gives an array equation one row per element (identity rows for differential equations, zero rows for zeros(n) ~ g), laid out like the generated code.
  • __process_SciMLProblem skips the array-equation check for ODEFunction constructors (array unknowns are still rejected: pass collect(u) as unknowns, as for DAEProblem).
  • full_equations expands array equations into scalar rows (after the residual → explicit rewrite), so the symbolic jacobian, sparsity pattern, tgrad and the initialization system see one equation per row. jac = true, sparse = true and the default initialization problem work from such systems.
  • ODEFunction only computes W_sparsity when sparse or sparsity is set; it was computed unconditionally and is an O(n) symbolic computation that nothing consumed otherwise.
  • build_explicit_observed_function records the element derivatives of a slice equation instead of calling default_toterm on D(u[2:4]) (which has no toterm name and threw).

Example

using ModelingToolkit, OrdinaryDiffEqRosenbrock
using ModelingToolkit: t_nounits as t, D_nounits as D

n = 96
@variables u(t)[1:n]
dx = 1 / (n - 1)
lap = (u[1:(n - 2)] .- 2 .* u[2:(n - 1)] .+ u[3:n]) ./ dx^2
@named heat = System([0 ~ u[1], D(u[2:(n - 1)]) ~ lap, 0 ~ u[n]], t, collect(u), [])
sys = complete(heat)

prob = ODEProblem(sys, [u => sinpi.(range(0, 1, length = n))], (0.0, 0.1))
prob.f.mass_matrix   # Diagonal([0, 1, ..., 1, 0])
sol = solve(prob, Rodas5P())

D(u) ~ -u over a whole array gives mass_matrix === I and solves with Tsit5.

Tests

lib/ModelingToolkitBase/test/array_equation_ode.jl (new; array_equation_dae.jl is also now wired into runtests.jl):

  • D(u) ~ -u from complete: mass_matrix === I, in-place and out-of-place functions, solution with Tsit5, with and without the default initialization problem;
  • heat equation over a slice in explicit and residual form: diagonal mass matrix, du equals the discrete Laplacian, solution matches the analytic solution, the mtkcompile path and a hand-scalarized complete path to 1e-10, default initialization works;
  • the generated in-place and out-of-place Exprs have the same node count for n = 24, 48, 96 and contain no array_literal;
  • a 2D slice D(w[2:n-1, 2:n-1]) ~ lap matches the scalarized system row for row;
  • jac = true, sparse = true gives the tridiagonal-plus-boundary pattern and solves;
  • full_equations returns n scalar rows for both forms;
  • duplicated derivatives, slices with non-unknown elements and array unknowns are rejected.

Checked separately against the real (tearing) mtkcompile from ModelingToolkit for n = 21, 41: complete → ODEProblem → Rodas5P agrees with mtkcompile → ODEProblem → Tsit5 to 2e-11, with and without the initialization problem.

Notes

  • Algebraic equations from complete still have to be written 0 ~ g (as before for scalar systems); u[1] ~ 0 keeps raising "The LHS cannot contain nondifferentiated variables". MethodOfLines emits its boundary conditions in the latter form, so its ODEProblem path needs either that rewrite on the MethodOfLines side or a follow-up here.
  • Initialization from array equations is built from the scalarized rows, so it is correct but not O(1) in n; build_initializeprob = false gives the O(1) construction.

Made with Cursor

…ile`

`complete(sys)` followed by `ODEProblem` rejected array equations such as
`D(u[2:n-1]) ~ lap(u)`, forcing the finite-difference use case through the
scalarizing `mtkcompile`, whose output and compile time grow with the grid.
`DAEProblem` already accepts them, so give the explicit ODE path the same
treatment: the right-hand side of an array equation is written to a
contiguous block of `du` through `array_residual_maker`, the mass matrix
gets one row per element, and the residual form `D(x) .- f ~ 0` emitted by
MethodOfLines is rewritten to `D(x) ~ f` first. The generated code is
independent of the array length.

`full_equations` expands array equations into scalar rows so that the
symbolic jacobian, sparsity and initialization paths keep one equation per
row, and `ODEFunction` only computes the W sparsity pattern when `sparse`
or `sparsity` asks for it.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ChrisRackauckas-Claude ChrisRackauckas-Claude changed the title Generate ODEProblem code from preserved array differential equations Build ODEProblem from array differential equations with complete only Sep 9, 2026
Comment thread lib/ModelingToolkitBase/src/utils.jl Outdated
ChrisRackauckas and others added 4 commits September 9, 2026 20:56
Co-authored-by: Aayush Sabharwal <aayush.sabharwal@gmail.com>
…lements

Co-authored-by: Cursor <cursoragent@cursor.com>
…rownFullBasicInit

The downgrade CI environment (DiffEqBase 7.15) trips a despecialization
wrapper assertion inside BrownFullBasicInit for DAEFunctions, which aborted
the InterfaceII group before the new ODEProblem tests could run. The tests
only need a solve to compare against the analytic solution, so hand the
solver a consistent du0 and skip DAE initialization.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ChrisRackauckas-Claude

Copy link
Copy Markdown
Member Author

CI status after 232d7a4 (the array-equation DAE tests now solve from a consistent du0 with NoInit(); BrownFullBasicInit trips a ParameterDespecializationWrapper assertion in the downgrade environment's DiffEqBase 7.15, which aborted the group before the new ODE tests ran):

  • Array Equation ODEProblem Test (69) and Array Equation DAEProblem Test (15) pass on Julia 1 / lts / pre and in the downgrade InterfaceII job.
  • Root tests / InterfaceI, InterfaceII, Initialization, SymbolicIndexingInterface, Downgrade InterfaceI, and the MethodOfLines / Catalyst / MTKStdlib downstream jobs pass.
  • Remaining red jobs are the same as on master (9b458b0): MTKBase InterfaceII fails only in JumpSystem Test (abstol field error), QA fails on the same 234 JET may be undefined reports from @match expansions, x86 InterfaceI can't precompile QuasiMonteCarlo, and StructuralIdentifiability hits a power_series_solution MethodError inside its own Nemo code.

ChrisRackauckas added a commit that referenced this pull request Sep 14, 2026
…ODE support and MultiObjectiveOptimizationFunction (#5148)

* feat: accept array equations on the NonlinearProblem path

* fix: keep array-equation downgrade CI on DiffEqBase 7.18.1

Import unwrap explicitly in the new NL tests and raise the DiffEqBase floor so nested AutoDespecialize accepts already-unwrapped parameters.

* ci: retrigger checks

* Apply suggestion from @ChrisRackauckas

* Zero the derivatives of array unknowns before expanding slices

* Test that array unknowns carry an array equation

* Gate array equations on a per-constructor capability trait

Stacked on #5049: array *equations* are accepted by the implicit-DAE and
nonlinear paths, but the opt-in is a hardcoded
`!implicit_dae && !(constructor <: NonlinearFunction)` test in
`__process_SciMLProblem` — a codegen flag and a constructor-type check
doing capability duty. This treats accepting array equations as a named,
per-constructor capability.

- `accepts_array_equations(constructor)` gates `__process_SciMLProblem`.
  `DAEFunction` and `NonlinearFunction` opt in, matching #5049's behavior
  exactly (`HomotopyProblem` inherits nonlinear support through the same
  constructor). Every other problem type still needs `mtkcompile`, and
  opting a new constructor in is a one-line method.
- `has_array_equations(eqs)` gets a docstring; it is the shared,
  shape-based detection predicate behind `check_array_equations` and the
  Jacobian guards.
- `DAEFunction` throws the same `ArgumentError` for `jac = true` /
  `sparse = true` on array residuals that `NonlinearFunction` gets: today
  `jac = true` dies inside `executediff` and `sparse = true` silently
  builds a wrong-shaped `jac_prototype` (one row per equation instead of
  one per residual row).

Related: #4983, #5049, #5131, #5136.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Agent-Harness: Devin CLI 3000.10.21
Agent-Model: SWE-2 Max
Agent-Session: local Devin CLI session, no shareable URL (workspace /home/crackauc/sandbox/tmp_20260913_101544_55869)

* Build `ODEProblem` from array differential equations without `mtkcompile`

`complete(sys)` followed by `ODEProblem` rejected array equations such as
`D(u[2:n-1]) ~ lap(u)`, forcing the finite-difference use case through the
scalarizing `mtkcompile`, whose output and compile time grow with the grid.
`DAEProblem` already accepts them, so give the explicit ODE path the same
treatment: the right-hand side of an array equation is written to a
contiguous block of `du` through `array_residual_maker`, the mass matrix
gets one row per element, and the residual form `D(x) .- f ~ 0` emitted by
MethodOfLines is rewritten to `D(x) ~ f` first. The generated code is
independent of the array length.

`full_equations` expands array equations into scalar rows so that the
symbolic jacobian, sparsity and initialization paths keep one equation per
row, and `ODEFunction` only computes the W sparsity pattern when `sparse`
or `sparsity` asks for it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Update lib/ModelingToolkitBase/src/utils.jl

Co-authored-by: Aayush Sabharwal <aayush.sabharwal@gmail.com>

* Expect ODEProblem to accept array equations over scalar unknowns in the #2597 test

Co-authored-by: Cursor <cursoragent@cursor.com>

* Assert the operator type when re-applying a slice derivative to its elements

Co-authored-by: Cursor <cursoragent@cursor.com>

* Solve the array-equation DAE tests from a consistent du0 instead of BrownFullBasicInit

The downgrade CI environment (DiffEqBase 7.15) trips a despecialization
wrapper assertion inside BrownFullBasicInit for DAEFunctions, which aborted
the InterfaceII group before the new ODEProblem tests could run. The tests
only need a solve to compare against the analytic solution, so hand the
solver a consistent du0 and skip DAE initialization.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Share the array-equation predicate and accept them on the ODE path

`is_array_equation` (from the ODE array-equation support) and
`has_array_equations` were two spellings of the same shape check; keep the
per-equation predicate in `utils.jl` and make `has_array_equations` delegate
to it. `ODEFunction` opts into `accepts_array_equations`, matching what its
`generate_rhs` branch already assembles through `array_residual_maker`.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Agent-Harness: Devin CLI 3000.10.21
Agent-Model: SWE-2 Max
Agent-Session: local Devin CLI session, no shareable URL (workspace /home/crackauc/sandbox/tmp_20260913_101544_55869)

* Expect array unknowns to flatten in the ODE array-equation tests

Post-#5131 `flat_unknowns` handles the array unknown unconditionally, so an
array equation over `u(t)[1:4]` declared as `[u]` constructs and evaluates
rather than throwing "array unknowns".

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Agent-Harness: Devin CLI 3000.10.21
Agent-Model: SWE-2 Max
Agent-Session: local Devin CLI session, no shareable URL (workspace /home/crackauc/sandbox/tmp_20260913_101544_55869)

* Update rejection tests for the ODE array-equation capability

`ODEProblem` accepts array equations now, so it no longer belongs in the
"still require scalarized equations" testset (`SDEProblem` rejection remains
covered in the same file), and a `D(u) ~ -u` system over the array unknown
`u` constructs because `flat_unknowns` handles it.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Agent-Harness: Devin CLI 3000.10.21
Agent-Model: SWE-2 Max
Agent-Session: local Devin CLI session, no shareable URL (workspace /home/crackauc/sandbox/tmp_20260913_101544_55869)

* Reject residual-form array differential equations on the ODE path

The scalar path only accepts `D(x) ~ f` from `complete`; `D(x) - f ~ 0` is a DAE
and needs `mtkcompile` or `DAEProblem`. Treat `D(u[2:4]) .- f ~ 0` the same way
instead of pattern-matching it back into explicit form in every consumer.

Also drop the duplicate InterfaceII include of `array_equation_dae.jl` (InterfaceI
already runs it) and scope the docs note about `jac`/`sparse` to array equations.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Agent-Harness: Claude Code
Agent-Model: claude-fable-5-1
Agent-Session: https://claude.ai/code/session_01VorhgJYhDasyVLpGNrviga
Claude-Session: https://claude.ai/code/session_01VorhgJYhDasyVLpGNrviga

* Construct MultiObjectiveOptimizationFunction from a system's cost vector

`OptimizationProblem` accepts `f::MultiObjectiveOptimizationFunction` in
SciMLBase, but MTK could never produce one: `generate_cost` always folds
`get_costs(sys)` through `consolidate` into a scalar.

- `costs(sys)` returns the unconsolidated objective vector: the system's own
  costs plus the consolidated cost of each subsystem, namespaced.
- `MultiObjectiveOptimizationFunction(sys)` generates a vector-valued objective
  over `costs(sys)`. `jac` builds the objective jacobian; `hess` is a vector
  with one generated hessian function per objective, the shape
  `OptimizationBase.instantiate_function` iterates.
- The constraint fields are assembled by `generate_constraint_fields`, shared
  with `OptimizationFunction` instead of duplicated.
- `OptimizationProblem(sys, op; multiobjective = true)` routes through it.
  `weights` cannot combine with it.

Also drop the `jac`/`sparse` guards on `NonlinearFunction` and `DAEFunction`
for array equations: since `full_equations` scalarizes array equations, the
symbolic jacobian and sparsity pattern already come out with one row per
residual, so both now work and are tested against ForwardDiff and the analytic
stencil. The array-residual DAE tests go back to `BrownFullBasicInit`, which
runs on the DiffEqBase 7.18.1 floor.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Agent-Harness: Claude Code
Agent-Model: claude-fable-5-1
Agent-Session: https://claude.ai/code/session_01VorhgJYhDasyVLpGNrviga
Claude-Session: https://claude.ai/code/session_01VorhgJYhDasyVLpGNrviga

* Approve the multi-objective names as ModelingToolkit reexports

`costs` and the `generate_multiobjective_*` / `calculate_multiobjective_*`
functions are exported from ModelingToolkitBase and therefore reexported by the
ModelingToolkit facade; list them in the root QA allow-list.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Agent-Harness: Claude Code
Agent-Model: claude-fable-5-1
Agent-Session: https://claude.ai/code/session_01VorhgJYhDasyVLpGNrviga
Claude-Session: https://claude.ai/code/session_01VorhgJYhDasyVLpGNrviga

---------

Co-authored-by: utkuyilmaz1903 <utkyilmz@gmail.com>
Co-authored-by: Christopher Rackauckas <accounts@chrisrackauckas.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Aayush Sabharwal <aayush.sabharwal@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
ChrisRackauckas added a commit that referenced this pull request Sep 14, 2026
- Document the PDE solution interface shared by discretizers (#5149)
- Point the docs `ModelingToolkit` source at the repository root (#5152)
- Unify array-equation support behind a capability trait; fold in #5101 ODE support and MultiObjectiveOptimizationFunction (#5148)
- Raise SymbolicIndexingInterface compat lower bound to 0.3.47 (#5153)
- Compare `complete` and `mtkcompile` array-unknown problems through symbolic indices (#5154)



Agent-Harness: Claude Code
Agent-Model: claude-opus-5[1m]
Claude-Session: https://claude.ai/code/session_014FEzNTLFutCmTEAZ3zBg5R

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ChrisRackauckas-Claude

Copy link
Copy Markdown
Member Author

Superseded: the ODEProblem array-DE support here was folded into #5148 ("Unify array-equation support behind a capability trait; fold in #5101 ODE support"), merged upstream and released in ModelingToolkitBase 1.75 / MTK 11.45. Verified on the released version: ODEProblem(complete(sys), ...) builds and solves a whole-array differential equation without mtkcompile. Closing to keep the queue honest.

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.

3 participants