Add Modelica homotopy(actual, simplified) operator for initialization (spec 3.7.4)#4576
Add Modelica homotopy(actual, simplified) operator for initialization (spec 3.7.4)#4576BatchZero wants to merge 28 commits into
Conversation
Implements Modelica spec 3.7.4 trivial form: homotopy(a, s) is a registered symbolic primitive that rewrites to actual=a. Hand-written TermInterface walk (not @rule) per SciML#1385 feedback. Metadata + symtype propagated on rebuilt nodes. Runtime fallback methods for Real and AbstractArray inputs.
Per code review:
- Drop top-level 'using Symbolics' / 'using SymbolicUtils' inside the
included file — MTKBase parent module already imports iscall, operation,
arguments, maketerm, metadata bare (L21-23 of ModelingToolkitBase.jl) and
re-exports Symbolics, so qualified prefixes are redundant noise versus
surrounding files (utils.jl, codegen_utils.jl, if_lifting.jl, etc.).
- Drop unreachable AbstractArray runtime fallback (@register_symbolic is
scalar-only; users go through broadcast).
- Drop arity ArgumentError guard in _rewrite_trivial (@register_symbolic
enforces 2-arity at parse time; guard is dead code).
- Soften docstring: metadata is preserved explicitly via maketerm arg;
symtype is inferred by maketerm itself, not preserved by us.
- Replace brittle !occursin("homotopy", repr(rewritten)) assertion with
!has_homotopy(rewritten), which uses the public predicate and gives
has_homotopy test coverage as a bonus.
Adds has_homotopy_in_equations and rewrite_trivial_in_equations helpers to homotopy.jl (internal use; not exported). Adds RED integration test asserting that an InitializationProblem built from a NonlinearSystem containing homotopy(x^2 - p, x - 1) = 0 solves the actual branch (x^2 = p) rather than the simplified branch (x = 1). Currently fails because the init pipeline does not yet apply rewrite_trivial.
The previous NonlinearSystem variant never exercised the rewrite hook because generate_initializesystem_timeindependent does not migrate the parent system's algebraic equations into the initialization system, so the failure mode was an empty u0 unrelated to homotopy. The new design uses an ODESystem with an algebraic constraint `0 ~ homotopy(y^2 - p, y - 1)` and a free init guess for `y`. This routes through generate_initializesystem_timevarying, which preserves the homotopy-bearing equation in the init system. The structural assertion `!has_homotopy_in_equations(equations(prob.f.sys))` distinguishes hooked from unhooked: without the rewrite, the init system carries the opaque homotopy() symbolic call even though runtime dispatch happens to give a numerically correct answer via the homotopy(::Real, ::Real) fallback.
Detects homotopy() in initialization-system equations and applies rewrite_trivial. Gated by has_homotopy_in_equations so non-homotopy systems pay zero traversal cost. Solves the L0 trivial requirement of Modelica spec 3.7.4. The hook lands before mtkcompile(isys) so that downstream structural transforms operate on the actual equations rather than opaque homotopy() calls; future PRs that lower into a parameter sweep will replace this branch when a HomotopySweep algorithm is in use.
The new homotopy_init.jl covers rewrite_trivial (unit) plus an ODESystem InitializationProblem integration check. It belongs alongside the other Initialization-group entries (guess_propagation, initializationsystem, initial_values) rather than the broader InterfaceI bucket.
Mirrors the structure of Buildings/Fluid/FixedResistances/PressureDrop.mo (from_dp=true branch): nonlinear actual (sqrt-based turbulent law) + linear simplified (nominal-point scaling). PR1 asserts (a) hook fired (no residual homotopy in init equations), (b) init converged, (c) m_flow numerically matches the actual root sqrt(5), not the simplified root m_flow_nominal*dp/dp_nominal = 1.0. Numerical OMC parity deferred to PR2.
Lift the homotopy lowering pass from the InitializationProblem hook up to complete(), mirroring how MTK already injects Initial(x) parameters. The parent system and init system now both carry the same `__homotopy_λ` parameter, which lets MTKParametersReconstructor resolve λ when it appears in init-system observed expressions (the bug that broke PR1's Buildings PressureDrop fixture under the previous attempt). - add_homotopy_parameter(sys) lowers equations + observed and injects λ; hooked into complete() right after add_initialization_parameters - InitializationProblem hook simplified to (a) plant op[λ]=1.0 for runtime default (avoids mtkcompile substituting λ away via sys.bindings) and (b) defensive fallback for systems built without complete()
When the init system contains __homotopy_λ (injected by add_homotopy_parameter in complete()), populate two new InitializationMetadata fields: - homotopy_set_λ! — a SymbolicIndexingInterface.setp wrapper that HomotopySweep uses to mutate λ between continuation steps - homotopy_default_initializealg — a complete OverrideInit(nlsolve= TrivialThenSweep(...)) instance for Task 5 to inject as the default initializealg when no homotopy nodes are present, both fields stay nothing and the metadata behaves exactly as before. Adds an L1-Q5 testset covering the round-trip and default-alg shape.
When add_homotopy_parameter has lowered a homotopy node, the init metadata exposes an OMC-aligned OverrideInit(TrivialThenSweep(...)) instance. process_kwargs now reads it (via a new initialization_data kwarg) and merges initializealg into prob.kwargs — but only when the caller did not pass initializealg themselves. Explicit user-supplied initializealg overrides via the standard merge(kwargs1, kwargs) order. Wired up for ODEProblem; other problem constructors can opt in by passing initialization_data = f.initialization_data to process_kwargs. Adds L1-Q6 testset covering auto-inject, explicit override, and non-homotopy-no-injection paths.
…, default-injection wiring
Frozen OMC reference: m_flow(0) = sqrt(5) ≈ 2.2360679774997896, x(0) = 1.0 (captured 2026-05-26 from OMC v1.27.0-dev via the modelica-OMEdit sandbox). All three nlsolve paths — default TrivialThenSweep, explicit HomotopySweep, explicit TrivialHomotopy — converge to the OMC value within 1e-6. The .mo fixture lives in the OMC sandbox at ~/code/modelica-OMEdit/homotopy-tests/PressureDropParityFixture.mo and is NOT committed to this repo per CLAUDE.md (no .mo files in workspace).
An explicit `initializealg = OverrideInit(...)` on the parent problem is baked into the init problem's own `prob.kwargs`; `solve_up` merges it back into the inner nonlinear solve even if dropped from the forwarded call kwargs. The inner solver then runs NonlinearSolveBase's `OverrideInit` init path on a problem with `initialization_data === nothing` (that path is unguarded, unlike the default init path) and throws a `FieldError` on the divergence/reinit path — instead of surfacing a clean InitialFailure retcode. Force `initializealg = NoInit()` on the inner `TrivialHomotopy`/`HomotopySweep` solves so the call kwarg overrides any baked-in OverrideInit. Add regression test I6 (explicit TrivialHomotopy on an unsolvable init fails cleanly).
NonlinearSolve is a test-only dependency of ModelingToolkitBase, not a runtime [deps]. `_default_inner` previously `Base.require`d it and threw if it was not loaded, so merely *constructing* a homotopy `ODEProblem` (which eagerly builds the default `TrivialThenSweep`) failed for any downstream user who had not run `using NonlinearSolve`. Fall back to `SimpleNonlinearSolve.SimpleNewtonRaphson` (a runtime [deps]) so homotopy systems build and solve out of the box; loading NonlinearSolve still restores its richer `NewtonRaphson` as the default.
PIPE-1: the OMC-aligned default `OverrideInit(nlsolve = TrivialThenSweep)` was only threaded into `process_kwargs` by odeproblem.jl, so a homotopy System lowered to a NonlinearProblem / SteadyStateProblem / DAEProblem / SDEProblem never received the default initializealg and continuation did not engage without explicit user opt-in. Thread `initialization_data = f.initialization_data` into `process_kwargs` for those four constructors, mirroring odeproblem.jl, so the homotopy default reaches `prob.kwargs[:initializealg]` uniformly. Test I7 asserts the injection for a NonlinearProblem and a SteadyStateProblem.
MERGE-3: replace the construction-time `Base.require(<hardcoded NonlinearSolve UUID>)` in `_default_inner` with a package extension. A weak dependency on NonlinearSolve plus the new `MTKNonlinearSolveExt` populates a factory Ref at load time with `NonlinearSolve.NewtonRaphson`; when NonlinearSolve is absent `_default_inner` falls back to `SimpleNonlinearSolve.SimpleNewtonRaphson` (a runtime dependency), so a `homotopy(...)` system stays buildable out of the box. Removes the eager `Base.require` and its hardcoded UUID. Test S3b asserts the factory Ref is populated and resolves to NewtonRaphson when NonlinearSolve is loaded.
SWEEP-2: when a `HomotopySweep` step returns an unsuccessful retcode the algorithm returns early, but the returned solution's parameter vector still held the intermediate λ where continuation stalled, exposing a half-homotopied system to any consumer reading those parameters. Reset λ to 1.0 (actual form) on the early-return path before surfacing the failed solution; the unknowns are left untouched so they still carry the diagnostic failed iterate. Test S2b drives a fixture whose real root vanishes around λ ≈ 0.5 and asserts the failed solution reports λ = 1.0. Test L1-Q7 is a regression guard that the observed equations of a lowered homotopy System are homotopy-free (an eliminated variable's definition living in observed must be lowered, not left as an opaque homotopy node).
The `homotopy` operator docstring still said the parameter-sweep continuation was deferred to "future PRs" — but this branch ships it. Since the docstring renders on the documented API page via `@docs ModelingToolkit.homotopy`, the stale wording would advertise the feature as not-yet-implemented. Rewrite it to describe the actual behavior: runtime evaluates to `actual`; during init the node is lowered to `(1-λ)*simplified + λ*actual` and solved trivial-then-sweep.
|
Hi @AayushSabharwal, the CI workflows seem to be waiting on a maintainer to approve the run. Could you kick it off when you get a chance? |
ChrisRackauckas
left a comment
There was a problem hiding this comment.
This doesn't get the structure right. The homotopy solver should be separately designed with an MTK independent API in NonlinearSolve that is then targeted.
|
@ChrisRackauckas Understood — the continuation solver really doesn't belong in MTK. Apologies for the original structure: I went with the single-repo approach because it was the fastest way to get something landed, and I hope it didn't cause any frustration. I'll restructure it as an MTK-independent NonlinearSolve algorithm over a plain Can the existing Planned changes, per repo:
Merge order would be SciMLBase → NonlinearSolve → MTK, since each depends on the previous. A few questions before I build it where you want it:
Does this structure look right to you, and is there anything you'd change before I start? |
Yeah that's pretty clear. Because it's only polynomial systems. It should probably be renamed PolynomialHomotopyProblem or something.
It probably needs a new problem type, since it needs more information than just a normal nonlinear problem. Or a function type, like you outline, that works fine too.
Yes, the 3 repo chain is required. |
|
Closing in favor of #4601: per the review here, the solver was redesigned as an MTK-independent API — |
Summary
Implements Modelica's
homotopy(actual, simplified)operator (spec 3.7.4) for initialization. A system that annotates an equation withhomotopy(...)can now be initialized either by directly solving theactualequation (the spec's trivial form) or by continuation that sweeps a parameter λ from 0 (the easy-to-solvesimplifiedequation) to 1 (theactualequation) — the spec's transformation form.The default user experience mirrors OpenModelica: try the trivial solve first, and automatically fall back to the parameter sweep if it fails. No configuration is required. Because the trivial attempt solves at λ=1 (≡
actual), the injected default is a no-op on the happy path — numerically identical to ordinary initialization — and the sweep engages only when the plain solve fails.This unblocks downstream component libraries from adding
homotopy()annotations to pressure-driven-flow / nonlinear-equation components and getting robust initialization out of the box.No code changes to SciMLBase, NonlinearSolve, or OrdinaryDiffEq — the entire feature lives in ModelingToolkit, and the sweep algorithms plug into the existing
OverrideInit.nlsolveslot. The only dependency-table change is internal to ModelingToolkitBase: NonlinearSolve moves from[extras]to[weakdeps]and gains a smallMTKNonlinearSolveExtextension that supplies the default inner Newton solver. When NonlinearSolve is absent the feature degrades gracefully toSimpleNonlinearSolve(already a runtime dependency), so ahomotopy(...)system stays buildable and solvable with no extra packages.Progresses #3988 and #4039 from "no homotopy support" toward "both spec forms supported." RFC #1385 stays open as the design thread.
User API
Three nlsolve algorithms, all
<: SciMLBase.AbstractNonlinearAlgorithm, mapping directly onto OpenModelica's flags:initializealg)TrivialThenSweep: trivial first, sweep on failureOverrideInit(nlsolve = HomotopySweep(...))-homotopyOnFirstTryOverrideInit(nlsolve = TrivialHomotopy())-noHomotopyOnFirstTryAn explicit
initializealgpassed atsolvetime always overrides the injected default.Implementation
Operator + lowering —
lib/ModelingToolkitBase/src/systems/homotopy.jl:@register_symbolic homotopy(actual, simplified)— symbolic primitive (scalar; Julia broadcast covers the vectorized case). Runtime fallbackhomotopy(a::Real, s::Real) = actual.rewrite_trivial/rewrite_with_lambda— hand-written walks over the TermInterface protocol (NOT@rule, per @AayushSabharwal's Modelica's homotopy operator #1385 feedback that per-node matcher overhead is unacceptable at scale).rewrite_with_lambdareplaces eachhomotopy(a, s)with(1 - λ)*s + λ*ausing one shared__homotopy_λparameter (default1.0, so λ=1 ≡actual).add_homotopy_parameter(sys)— lowershomotopy(...)nodes in both equations and observed, injects__homotopy_λinto the parent system's parameter list.Lowering happens in
complete()(abstractsystem.jl), at the same lifecycle point as MTK's ownadd_initialization_parameters. This is the key design decision: the parent system and the derived initialization system end up sharing one identity for__homotopy_λ, soMTKParametersReconstructorcan resolve λ when it appears in init-system observed expressions (e.g. when an algebraic variable carrying ahomotopy()annotation is eliminated into an observed equation — the Buildings PressureDrop pattern).Sweep algorithms —
lib/ModelingToolkitBase/src/systems/homotopy_sweep.jl:HomotopySweep{Inner, Sched, Setter}— walksλ ∈ schedule(default0.0:0.1:1.0), warm-startingu0from the previous step, writing λ via aSymbolicIndexingInterface.setphandle. On a non-converging step it returns that step's unsuccessful retcode (no silent fallback); before returning it resets λ to1.0(actual form) so the surfaced solution's parameter vector never exposes a half-homotopied system.TrivialHomotopy{Inner}— single solve at λ=1.TrivialThenSweep{T, S}— composite: trivial first, sweep on failure, annotatingsol.original.pathwith:trivial/:sweep_fallback.Default inner solver via a package extension. The algorithms' default inner is
NonlinearSolve.NewtonRaphson, supplied through a newMTKNonlinearSolveExtextension keyed on a[weakdeps]dependency on NonlinearSolve. The extension populates a factoryRefat load time; when NonlinearSolve is not loaded the default falls back toSimpleNonlinearSolve.SimpleNewtonRaphson(a runtime[deps]). This replaces an earlier construction-timeBase.require(<hardcoded UUID>)with the idiomatic weak-dependency pattern.Default-alg injection — when lowering fired,
InitializationMetadatacarries thesetphandle and a readyOverrideInit(nlsolve = TrivialThenSweep(...)).process_kwargsinjects it as the defaultinitializealgintoprob.kwargs, but only when the caller didn't supply one. This is wired forODEProblem,NonlinearProblem,SteadyStateProblem,DAEProblem, andSDEProblem(each forwardsf.initialization_datatoprocess_kwargs).Tests
GROUP=Initialization, all green (101 assertions across 5 files):rewrite_trivialon nested /Base.ifelse-branched / broadcast expressions, ODESystem init integration, Buildings PressureDrop fixture.rewrite_with_lambdaalgebra (λ=0 → simplified, λ=1 → actual, shared λ),OverrideInitDatametadata round-trip, default-initializealginjection + explicit-override + non-homotopy-no-injection, and a regression guard that observed equations are homotopy-free after lowering (an eliminated variable's definition living in observed must be lowered, not left as an opaquehomotopynode).HomotopySweepconvergence + failure retcode + λ-reset-on-failure,TrivialHomotopy,TrivialThenSweeptrivial-success and sweep-fallback paths, and that the default inner is supplied by the NonlinearSolve extension.ODEProblem; an out-of-basin guess that only the sweep continuation can rescue (atan(y-3)basin escape); a clean unsuccessful-retcode (not an internal error) when the trivial path genuinely cannot converge; and that the default injection reachesNonlinearProblemandSteadyStateProblem.OMC numerical parity
The PressureDrop fixture (
actual = sign(dp)·√|dp|·k,simplified = m_flow_nominal·dp/dp_nominal) was run through OpenModelica v1.27.0-dev. OMC's init givesm_flow(0) = √5 ≈ 2.2360679774997896. All three MTK paths — defaultTrivialThenSweep, explicitHomotopySweep, explicitTrivialHomotopy— match to within1e-6. The OMC values are frozen as constants in the test (MTK CI does not depend on an OMC install).Other local verification
homotopy(...)ODEProblemstill constructs (no construction-time throw), the default inner falls back toSimpleNewtonRaphson, and the injectedTrivialThenSweep's trivial+sweep inners are bothSimpleNewtonRaphson.docs/src/API/homotopy.mdpage builds under Documenter — all four@docsentries (homotopy,TrivialThenSweep,HomotopySweep,TrivialHomotopy) resolve and render.Scope notes for reviewers
ODESystemInitializationProblempath (including the PressureDrop and out-of-basin fixtures). The default-alg injection is now also wired forNonlinearProblem/SteadyStateProblem/DAEProblem/SDEProblemand asserted structurally, but the deep numerical exercise lives on the ODE path.adaptiveGlobal,equidistantLocal,adaptiveLocalmodes) are out of scope; this PR ships theequidistantGlobalequivalent only.Test plan
SimpleNonlinearSolvefallbackODEProblem(default + both explicit modes + out-of-basin sweep rescue)NonlinearProblem/SteadyStateProblem/DAEProblem/SDEProblemdocs/src/API/homotopy.mdbuilds under Documenter (all@docsresolve)GROUP=Initializationregression: no new failures vs. master baseline[weakdeps])@rule, noIfElse.ifelse, scalar@register_symboliconly (per Modelica's homotopy operator #1385 feedback)(Diff: 21 files, +1267 / −7. New files:
homotopy.jl,homotopy_sweep.jl,ext/MTKNonlinearSolveExt.jl, fivetest/homotopy_*.jl,docs/src/API/homotopy.md.)Refs
homotopyOnFirstTry,noHomotopyOnFirstTry,--homotopyApproach): OpenModelica User's Guide, Simulation Runtime FlagsFluid/FixedResistances/PressureDrop.mo(from_dp=truebranch)