Skip to content

Commit 543e24b

Browse files
committed
Add the homotopy operator and HomotopyProblem construction
Implement the `homotopy(actual, simplified)` operator as a general nonlinear-solving construct: a `System` whose equations carry `homotopy` annotations is lowered into a `SciMLBase.HomotopyProblem` whose residual is the convex blend `(1 - λ)*simplified + λ*actual`, compiled as `f(u, p, λ)` and solved by natural-parameter continuation (`NonlinearSolveBase.HomotopySweep`). - systems/homotopy_operator.jl: `@register_symbolic homotopy` with the numeric fallback `homotopy(a, s) = a`; nodewise derivatives kept as opaque `homotopy(1, 0)`/`homotopy(0, 1)` nodes so jacobians and index reduction stay consistent under the blend; the `has_*homotopy` detection family; the fixed continuation sentinel `HOMOTOPY_LAMBDA` (not `gensym`, to keep codegen precompile-stable) with a collision guard; the structure-preserving `lower_homotopy` shadow pass; and `generate_homotopy_residual`. - problems/homotopyproblem.jl: `SciMLBase.HomotopyProblem(sys, op)` builds the standard residual to obtain `u0`/`p`/observed, then swaps in the swept `f(u, p, λ)`. `λ` is a trailing argument, never added to `p`. - problems/nonlinearproblem.jl: `SciMLBase.AbstractNonlinearProblem(sys, op)` auto-selects `HomotopyProblem` when `sys` contains `homotopy` nodes, otherwise `NonlinearProblem`. - systems/codegen.jl: `generate_rhs` gains an additive `extra_args` to thread `λ` as a trailing scalar argument; empty by default, leaving the standard codegen path byte-identical. - docs (basics/Homotopy.md, problems API reference) and tests for the operator, lowering, broadcasting, problem construction, auto-selection, the codegen guards, and pressure-drop parity. Initialization wiring (injecting the continuation solver as the default `initializealg`) is intentionally left to a follow-up PR.
1 parent 6d0dd38 commit 543e24b

13 files changed

Lines changed: 1014 additions & 7 deletions

File tree

docs/pages.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ pages = [
4747
"Basics" => Any[
4848
"basics/Composition.md",
4949
"basics/Events.md",
50+
"basics/Homotopy.md",
5051
"basics/Linearization.md",
5152
"basics/InputOutput.md",
5253
"basics/MTKLanguage.md",

docs/src/API/problems.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ SciMLBase.ImplicitDiscreteProblem
3636
```@docs
3737
SciMLBase.NonlinearFunction
3838
SciMLBase.NonlinearProblem
39+
SciMLBase.AbstractNonlinearProblem(::System, ::Any)
40+
SciMLBase.HomotopyProblem
3941
SciMLBase.SCCNonlinearProblem
4042
SciMLBase.NonlinearLeastSquaresProblem
4143
SciMLBase.SteadyStateProblem

docs/src/basics/Homotopy.md

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
# [Homotopy](@id homotopy)
2+
3+
ModelingToolkit implements the Modelica `homotopy(actual, simplified)` operator
4+
([Modelica Specification 3.7.4.2](https://specification.modelica.org/master/operators-and-expressions.html#homotopy))
5+
as a way to robustly solve nonlinear systems that are hard to solve from a cold
6+
start. It is most commonly reached for during initialization, but it is a general
7+
nonlinear-solving construct: any nonlinear system whose equations carry a
8+
`homotopy` annotation can be solved by continuation.
9+
10+
## What Is Homotopy?
11+
12+
The `simplified` equations are a set of equations for which the nonlinear system
13+
is easier to get a convergent (Newton) iteration for, and the `actual` equations
14+
are the more complex equations you actually want to solve. A homotopy solver
15+
starts by solving the nonlinear system with the `simplified` equations, and uses
16+
that solution as the starting point for solving the `actual` problem, deforming
17+
continuously from one to the other.
18+
19+
There are many ways this can be used. For example, if you have equations with
20+
multiple solutions — like a quadratic equation with a positive and a negative
21+
root — you can simplify it down to an approximating linear problem that has a
22+
single (say, positive) solution, and then deform it to the actual equation to
23+
stabilise the process of converging to that positive solution.
24+
25+
The operator encodes both expressions in a single annotation:
26+
27+
```julia
28+
homotopy(actual, simplified)
29+
```
30+
31+
Concretely, the continuation introduces a scalar parameter ``\lambda`` and solves
32+
33+
```math
34+
(1 - \lambda)\,\text{simplified} + \lambda\,\text{actual}
35+
```
36+
37+
sweeping ``\lambda`` from `0` (the easy `simplified` system) to `1` (the
38+
`actual` system), warm-starting each step from the previous solution.
39+
40+
## Runtime Semantics
41+
42+
The operator stays an opaque symbolic function through `System` construction,
43+
`mtkcompile`, and runtime code generation — no continuation parameter is added to
44+
the system. Wherever the operator is evaluated numerically outside a continuation
45+
solve, the generated code calls the numeric fallback
46+
47+
```julia
48+
homotopy(actual::Real, simplified::Real) = actual
49+
```
50+
51+
so the operator evaluates to `actual`, as the Modelica specification prescribes.
52+
Note the honest cost: the `simplified` argument expression is still evaluated and
53+
its value discarded (arguments are evaluated before the call). This small
54+
overhead is borne only by systems that use `homotopy` — systems without the
55+
operator go through a byte-identical pipeline and are completely unaffected.
56+
57+
Symbolic differentiation works through the operator: nodewise derivative rules
58+
keep symbolic jacobians, `tgrad`, and index reduction consistent. At runtime,
59+
differentiated equations reproduce `actual`'s derivative; along the continuation
60+
they follow the derivative of the blended expression above.
61+
62+
## Building a `HomotopyProblem`
63+
64+
A system whose equations contain `homotopy` nodes is built into a
65+
[`SciMLBase.HomotopyProblem`](@ref) whose residual is the blended expression
66+
above, compiled as `f(u, p, λ)`. `λ` is an explicit trailing argument — it is
67+
never added to the system's parameters, and your parameter object `p` passes
68+
through untouched. All `homotopy` calls in a system share the single `λ`, per the
69+
Modelica spec's recommendation of (conceptually) one homotopy iteration over the
70+
whole model; this includes nested `homotopy` calls.
71+
72+
There are two ways to construct it:
73+
74+
```julia
75+
# Explicit: always returns a HomotopyProblem (errors if `sys` has no `homotopy`).
76+
prob = HomotopyProblem(sys, op)
77+
78+
# Automatic: returns a HomotopyProblem when `sys` contains `homotopy` nodes, and
79+
# a plain NonlinearProblem otherwise.
80+
prob = AbstractNonlinearProblem(sys, op)
81+
```
82+
83+
The `HomotopyProblem`'s `λspan` defaults to `(0.0, 1.0)`. It is solved by
84+
`NonlinearSolveBase.HomotopySweep`, a natural-parameter continuation solver that
85+
sweeps `λ` from `0` to `1`, solving a standard nonlinear problem at each step and
86+
warm-starting from the previous step's solution. A `HomotopyProblem` with no
87+
algorithm (`solve(prob)`) defaults to this solver.
88+
89+
## Example: Out-of-Basin Rescue
90+
91+
The equation `0 = atan(y - 3)` has a root at `y = 3`, but a Newton solver
92+
starting from `y = 12` diverges because `atan` saturates. Using `homotopy` with
93+
`simplified = y` (whose root is `y = 0`) lets the continuation walk from the easy
94+
root to the true one:
95+
96+
```julia
97+
using ModelingToolkit, NonlinearSolve
98+
99+
@variables y
100+
@mtkcompile sys = System([0 ~ homotopy(atan(y - 3), y)])
101+
prob = HomotopyProblem(sys, [y => 12.0])
102+
sol = solve(prob, HomotopySweep())
103+
sol[y] # ≈ 3.0 — the continuation rescued the out-of-basin guess
104+
```
105+
106+
The operating point (`[y => 12.0]`) provides the starting point of the
107+
continuation; the sweep deforms the equations so the solver reaches `y ≈ 3` at
108+
`λ = 1`.
109+
110+
## Broadcasting Over Arrays
111+
112+
`homotopy` is a scalar operator. For array equations, broadcast it elementwise:
113+
114+
```julia
115+
eqs = 0 .~ homotopy.(actual_array, simplified_array)
116+
```
117+
118+
This creates one `homotopy` node per element; the continuation lowering rewrites
119+
each node independently, and all of them share the single continuation parameter
120+
`λ`.
121+
122+
## Customizing the Continuation Solver
123+
124+
To tune the sweep, pass your own continuation algorithm to `solve`:
125+
126+
```julia
127+
sol = solve(prob, HomotopySweep(nsteps = 30))
128+
```
129+
130+
`HomotopySweep` accepts the keyword arguments `inner` (the nonlinear algorithm
131+
used at each step), `nsteps`, `adaptive`, `initial_step_factor`, and `min_dλ`;
132+
see the `NonlinearSolveBase.HomotopySweep` docstring for their meanings and
133+
defaults.
134+
135+
## Limitations
136+
137+
- **`expression = Val{true}` is not yet supported** for the homotopy
138+
constructor; build the problem directly (the default
139+
`expression = Val{false}`). This can be added in a future PR.
140+
- **The jacobian/sparsity of the standard build are dropped.** They encode the
141+
`λ = 1` (opaque-`actual`) system and would be wrong mid-sweep; continuation
142+
steps solve with a freshly differentiated residual. Per-problem analytic
143+
jacobians for the swept residual are future work.
144+
- **Scalar `Real` expressions only** for a single `homotopy` node, matching
145+
Modelica's restriction; use broadcasting (above) for arrays.
146+
- **Only equations and observed equations are rewritten.** A `homotopy` call
147+
inside a parameter binding or default value is left as-is and evaluates as
148+
`actual` at all `λ`.
149+
150+
## API Reference
151+
152+
```@docs
153+
ModelingToolkit.homotopy
154+
```
155+
156+
## See Also
157+
158+
- [Modelica Specification 3.7.4.2](https://specification.modelica.org/master/operators-and-expressions.html#homotopy)
159+
— the upstream specification this operator implements.

lib/ModelingToolkitBase/Project.toml

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,11 @@ ModelingToolkitStandardLibrary = "2.20"
143143
Mooncake = "0.5"
144144
Moshi = "0.3.6"
145145
NaNMath = "0.3, 1"
146-
NonlinearSolve = "4.3"
146+
NonlinearSolve = "4.19"
147+
# Test-only floor: `HomotopySweep` (which the homotopy tests solve with, via
148+
# NonlinearSolve's re-export) lives in NonlinearSolveBase >= 2.31; NonlinearSolve
149+
# 4.19's own bound (>= 2.20) does not force it, so pin it for downgrade CI.
150+
NonlinearSolveBase = "2.31"
147151
OffsetArrays = "1"
148152
OptimizationBase = "5"
149153
OptimizationIpopt = "1"
@@ -170,12 +174,12 @@ ReferenceTests = "0.10"
170174
RuntimeGeneratedFunctions = "0.5.12"
171175
SCCNonlinearSolve = "1.8.1"
172176
SafeTestsets = "0.1"
173-
SciMLBase = "3.18"
177+
SciMLBase = "3.19"
174178
SciMLPublic = "1.0.0"
175179
SciMLStructures = "1.7"
176180
Serialization = "1"
177181
Setfield = "0.7, 0.8, 1"
178-
SimpleNonlinearSolve = "0.1.0, 1, 2"
182+
SimpleNonlinearSolve = "2.11.1"
179183
SparseArrays = "1"
180184
SpecialFunctions = "1, 2"
181185
StableRNGs = "1"
@@ -209,6 +213,7 @@ LinearSolve = "7ed4a6bd-45f5-4d41-b270-4a48e9bafcae"
209213
Logging = "56ddb016-857b-54e1-b83d-db4d58db5568"
210214
ModelingToolkitStandardLibrary = "16a59e39-deab-5bd0-87e4-056b12336739"
211215
NonlinearSolve = "8913a72c-1f9b-4ce2-8d82-65094dcecaec"
216+
NonlinearSolveBase = "be0214bd-f91f-a760-ac4e-3421ce2b2da0"
212217
Optimization = "7f7a1694-90dd-40f0-9382-eb1efda571ba"
213218
OptimizationBase = "bca83a33-5cc9-4baa-983d-23429ab6bcbb"
214219
OptimizationIpopt = "43fad042-7963-4b32-ab19-e2a4f9a67124"
@@ -234,4 +239,4 @@ Sundials = "c3572dad-4567-51f8-b174-8c6c989267f4"
234239
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
235240

236241
[targets]
237-
test = ["BenchmarkTools", "BoundaryValueDiffEqMIRK", "BoundaryValueDiffEqAscher", "ControlSystemsBase", "DataInterpolations", "DelayDiffEq", "NonlinearSolve", "ForwardDiff", "ModelingToolkitStandardLibrary", "Optimization", "OptimizationIpopt", "OptimizationOptimJL", "OrdinaryDiffEq", "OrdinaryDiffEqDefault", "REPL", "Random", "ReferenceTests", "SafeTestsets", "StableRNGs", "Statistics", "SteadyStateDiffEq", "Test", "StochasticDiffEq", "Sundials", "Pkg", "OrdinaryDiffEqNonlinearSolve", "Logging", "OptimizationBase", "LinearSolve", "Latexify", "Distributed", "DiffEqNoiseProcess", "DynamicQuantities", "OrdinaryDiffEqSDIRK", "OrdinaryDiffEqRosenbrock", "OrdinaryDiffEqBDF", "OrdinaryDiffEqFunctionMap", "OrdinaryDiffEqTsit5"]
242+
test = ["BenchmarkTools", "BoundaryValueDiffEqMIRK", "BoundaryValueDiffEqAscher", "ControlSystemsBase", "DataInterpolations", "DelayDiffEq", "NonlinearSolve", "ForwardDiff", "ModelingToolkitStandardLibrary", "NonlinearSolveBase", "Optimization", "OptimizationIpopt", "OptimizationOptimJL", "OrdinaryDiffEq", "OrdinaryDiffEqDefault", "REPL", "Random", "ReferenceTests", "SafeTestsets", "StableRNGs", "Statistics", "SteadyStateDiffEq", "Test", "StochasticDiffEq", "Sundials", "Pkg", "OrdinaryDiffEqNonlinearSolve", "Logging", "OptimizationBase", "LinearSolve", "Latexify", "Distributed", "DiffEqNoiseProcess", "DynamicQuantities", "OrdinaryDiffEqSDIRK", "OrdinaryDiffEqRosenbrock", "OrdinaryDiffEqBDF", "OrdinaryDiffEqFunctionMap", "OrdinaryDiffEqTsit5"]

lib/ModelingToolkitBase/src/ModelingToolkitBase.jl

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ using Compat
5353
using AbstractTrees
5454
using SciMLBase: StandardODEProblem, StandardNonlinearProblem, handle_varmap, TimeDomain,
5555
PeriodicClock, Clock, SolverStepClock, ContinuousClock, OverrideInit,
56-
NoInit
56+
NoInit, AbstractNonlinearProblem
5757
import Moshi
5858
using Moshi.Data: @data
5959
using Reexport
@@ -191,6 +191,9 @@ include("systems/codegen_utils.jl")
191191
include("problems/docs.jl")
192192
include("systems/codegen.jl")
193193
include("systems/problem_utils.jl")
194+
# Operator + lowering layer; must load before the problem constructors that
195+
# consume it (problems/nonlinearproblem.jl selector + problems/homotopyproblem.jl).
196+
include("systems/homotopy_operator.jl")
194197

195198
include("problems/compatibility.jl")
196199
include("problems/odeproblem.jl")
@@ -199,6 +202,7 @@ include("problems/daeproblem.jl")
199202
include("problems/sdeproblem.jl")
200203
include("problems/sddeproblem.jl")
201204
include("problems/nonlinearproblem.jl")
205+
include("problems/homotopyproblem.jl")
202206
include("problems/intervalnonlinearproblem.jl")
203207
include("problems/implicitdiscreteproblem.jl")
204208
include("problems/discreteproblem.jl")
@@ -246,6 +250,10 @@ export ImplicitDiscreteProblem, ImplicitDiscreteFunction
246250
export ODEProblem, SDEProblem
247251
export NonlinearFunction
248252
export NonlinearProblem
253+
# `AbstractNonlinearProblem(sys, op)` is the automatic constructor that selects a
254+
# `HomotopyProblem` when `sys` contains `homotopy(...)` nodes (see
255+
# `problems/homotopyproblem.jl`); re-exported so it is reachable unqualified.
256+
export AbstractNonlinearProblem
249257
export IntervalNonlinearFunction
250258
export IntervalNonlinearProblem
251259
export OptimizationProblem, constraints
@@ -300,6 +308,8 @@ export generate_initializesystem, Initial, isinitial, InitializationProblem
300308
export alg_equations, diff_equations, has_alg_equations, has_diff_equations
301309
export get_alg_eqs, get_diff_eqs, has_alg_eqs, has_diff_eqs
302310

311+
export homotopy
312+
303313
export @variables, @parameters, @independent_variables, @constants, @brownians, @brownian,
304314
@poissonians, @discretes
305315
export @named, @nonamespace, @namespace, extend, compose, complete, toggle_namespacing
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""
2+
SciMLBase.HomotopyProblem(sys::System, op; λspan = (0.0, 1.0), kwargs...)
3+
4+
Build a [`SciMLBase.HomotopyProblem`](@ref) from a `System` whose equations
5+
contain Modelica `homotopy(actual, simplified)` nodes (Modelica spec 3.7.4.2).
6+
7+
The standard nonlinear residual is built from `sys` (and used only to obtain
8+
`u0`, `p`, the observed function, and the residual prototype), then the residual
9+
is regenerated with every `homotopy(actual, simplified)` replaced by the convex
10+
blend `(1 - λ)*simplified + λ*actual` and compiled as `f(u, p, λ)`. `λ` is an
11+
explicit trailing argument — it is never added to the system's parameters, and
12+
`p` passes through untouched — so the result solves by natural-parameter
13+
continuation (`NonlinearSolveBase.HomotopySweep`), sweeping `λ` across `λspan`
14+
(default `(0.0, 1.0)`, i.e. from `simplified` to `actual`).
15+
16+
`sys` must be `complete`d and must contain at least one `homotopy` node (use
17+
[`SciMLBase.NonlinearProblem`](@ref) otherwise, or `AbstractNonlinearProblem` to
18+
select automatically). The jacobian/sparsity of the standard build are
19+
deliberately dropped: they encode the `λ = 1` (opaque-`actual`) system and would
20+
be wrong mid-sweep.
21+
22+
!!! note
23+
24+
`expression = Val{true}` (codegen-to-`Expr`) is not yet supported for the
25+
homotopy constructor; this can be added in a future PR.
26+
"""
27+
function SciMLBase.HomotopyProblem(
28+
sys::System, op;
29+
expression = Val{false}, λspan = (0.0, 1.0),
30+
check_length = true, check_compatibility = true,
31+
eval_expression = false, eval_module = @__MODULE__,
32+
checkbounds = false, cse = true, kwargs...
33+
)
34+
if expression !== Val{false}
35+
throw(
36+
ArgumentError(
37+
"`HomotopyProblem(sys, op)` does not yet support " *
38+
"`expression = Val{true}`; build the problem directly " *
39+
"(the default `expression = Val{false}`)."
40+
)
41+
)
42+
end
43+
check_complete(sys, SciMLBase.HomotopyProblem)
44+
if is_time_dependent(sys)
45+
sys = NonlinearSystem(sys)
46+
end
47+
check_compatibility && check_compatible_system(SciMLBase.NonlinearProblem, sys)
48+
if !has_any_homotopy(sys)
49+
throw(
50+
ArgumentError(
51+
"`HomotopyProblem` requires a system containing " *
52+
"`homotopy(actual, simplified)` nodes; none were found. Use " *
53+
"`NonlinearProblem` for systems without `homotopy`."
54+
)
55+
)
56+
end
57+
58+
_iip = resolve_iip(Both, op)
59+
f, u0,
60+
p = process_SciMLProblem(
61+
SciMLBase.NonlinearFunction{_iip}, sys, op;
62+
check_length, check_compatibility, expression,
63+
eval_expression, eval_module, checkbounds, cse, kwargs...
64+
)
65+
66+
# Swap the opaque-`actual` residual for the homotopy-swept `f(u, p, λ)`. The
67+
# observed function and residual prototype carry over; `initialization_data`,
68+
# jacobian, and sparsity are deliberately not carried (the latter two encode
69+
# the `λ = 1` system and would be wrong mid-sweep).
70+
shadow, λ = lower_homotopy(sys)
71+
hf = generate_homotopy_residual(
72+
shadow, λ; eval_expression, eval_module, checkbounds, cse
73+
)
74+
swept_f = SciMLBase.NonlinearFunction{_iip}(
75+
hf; sys = f.sys, observed = f.observed, resid_prototype = f.resid_prototype
76+
)
77+
78+
kwargs = process_kwargs(sys; kwargs...)
79+
args = (; f = swept_f, u0, p)
80+
return maybe_codegen_scimlproblem(
81+
expression, SciMLBase.HomotopyProblem{_iip}, args; λspan, kwargs...
82+
)
83+
end

lib/ModelingToolkitBase/src/problems/nonlinearproblem.jl

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,31 @@ end
118118
)
119119
end
120120

121+
"""
122+
get_nonlinear_problem_type(sys::System)
123+
124+
The concrete `AbstractNonlinearProblem` type that `AbstractNonlinearProblem(sys, op)`
125+
builds for `sys`: [`SciMLBase.HomotopyProblem`](@ref) when `sys` contains Modelica
126+
`homotopy(actual, simplified)` nodes, otherwise [`SciMLBase.NonlinearProblem`](@ref).
127+
"""
128+
function get_nonlinear_problem_type(sys::System)
129+
return has_any_homotopy(sys) ? SciMLBase.HomotopyProblem : SciMLBase.NonlinearProblem
130+
end
131+
132+
"""
133+
SciMLBase.AbstractNonlinearProblem(sys::System, op; kwargs...)
134+
135+
Build a nonlinear problem from `sys`, automatically selecting the concrete type via
136+
`get_nonlinear_problem_type`: a [`SciMLBase.HomotopyProblem`](@ref) when `sys`
137+
contains Modelica `homotopy(actual, simplified)` nodes (so the equations are solved by
138+
continuation from the `simplified` form), otherwise a plain
139+
[`SciMLBase.NonlinearProblem`](@ref). Keyword arguments are forwarded to the selected
140+
constructor; `λspan` only applies to the `HomotopyProblem` branch.
141+
"""
142+
function SciMLBase.AbstractNonlinearProblem(sys::System, op; kwargs...)
143+
return get_nonlinear_problem_type(sys)(sys, op; kwargs...)
144+
end
145+
121146
@fallback_iip_specialize function SciMLBase.NonlinearLeastSquaresProblem{iip, spec}(
122147
sys::System, op; check_length = false, lb = nothing, ub = nothing,
123148
check_compatibility = true, expression = Val{false}, kwargs...

0 commit comments

Comments
 (0)