Skip to content

Commit 64b0478

Browse files
Merge pull request #3187 from AayushSabharwal/as/circular-subs
feat: add circular dependency checking, substitution limit
2 parents 93db816 + 7ca38cb commit 64b0478

File tree

4 files changed

+166
-10
lines changed

4 files changed

+166
-10
lines changed

Project.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ SpecialFunctions = "0.7, 0.8, 0.9, 0.10, 1.0, 2"
131131
StaticArrays = "0.10, 0.11, 0.12, 1.0"
132132
SymbolicIndexingInterface = "0.3.31"
133133
SymbolicUtils = "3.7"
134-
Symbolics = "6.15.2"
134+
Symbolics = "6.15.4"
135135
URIs = "1"
136136
UnPack = "0.1, 1.0"
137137
Unitful = "1.1"

docs/src/examples/perturbation.md

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,46 +5,57 @@ In the [Mixed Symbolic-Numeric Perturbation Theory tutorial](https://symbolics.j
55
## Free fall in a varying gravitational field
66

77
Our first ODE example is a well-known physics problem: what is the altitude $x(t)$ of an object (say, a ball or a rocket) thrown vertically with initial velocity $ẋ(0)$ from the surface of a planet with mass $M$ and radius $R$? According to Newton's second law and law of gravity, it is the solution of the ODE
8+
89
```math
910
ẍ = -\frac{GM}{(R+x)^2} = -\frac{GM}{R^2} \frac{1}{\left(1+ϵ\frac{x}{R}\right)^2}.
1011
```
12+
1113
In the last equality, we introduced a perturbative expansion parameter $ϵ$. When $ϵ=1$, we recover the original problem. When $ϵ=0$, the problem reduces to the trivial problem $ẍ = -g$ with constant gravitational acceleration $g = GM/R^2$ and solution $x(t) = x(0) + ẋ(0) t - \frac{1}{2} g t^2$. This is a good setup for perturbation theory.
1214

1315
To make the problem dimensionless, we redefine $x \leftarrow x / R$ and $t \leftarrow t / \sqrt{R^3/GM}$. Then the ODE becomes
16+
1417
```@example perturbation
1518
using ModelingToolkit
1619
using ModelingToolkit: t_nounits as t, D_nounits as D
1720
@variables ϵ x(t)
18-
eq = D(D(x)) ~ -(1 + ϵ*x)^(-2)
21+
eq = D(D(x)) ~ -(1 + ϵ * x)^(-2)
1922
```
2023

2124
Next, expand $x(t)$ in a series up to second order in $ϵ$:
25+
2226
```@example perturbation
2327
using Symbolics
2428
@variables y(t)[0:2] # coefficients
2529
x_series = series(y, ϵ)
2630
```
2731

2832
Insert this into the equation and collect perturbed equations to each order:
33+
2934
```@example perturbation
3035
eq_pert = substitute(eq, x => x_series)
3136
eqs_pert = taylor_coeff(eq_pert, ϵ, 0:2)
3237
```
38+
3339
!!! note
40+
3441
The 0-th order equation can be solved analytically, but ModelingToolkit does currently not feature automatic analytical solution of ODEs, so we proceed with solving it numerically.
35-
These are the ODEs we want to solve. Now construct an `ODESystem`, which automatically inserts dummy derivatives for the velocities:
42+
These are the ODEs we want to solve. Now construct an `ODESystem`, which automatically inserts dummy derivatives for the velocities:
43+
3644
```@example perturbation
3745
@mtkbuild sys = ODESystem(eqs_pert, t)
3846
```
3947

4048
To solve the `ODESystem`, we generate an `ODEProblem` with initial conditions $x(0) = 0$, and $ẋ(0) = 1$, and solve it:
49+
4150
```@example perturbation
4251
using DifferentialEquations
4352
u0 = Dict([unknowns(sys) .=> 0.0; D(y[0]) => 1.0]) # nonzero initial velocity
4453
prob = ODEProblem(sys, u0, (0.0, 3.0))
4554
sol = solve(prob)
4655
```
56+
4757
This is the solution for the coefficients in the series for $x(t)$ and their derivatives. Finally, we calculate the solution to the original problem by summing the series for different $ϵ$:
58+
4859
```@example perturbation
4960
using Plots
5061
p = plot()
@@ -53,6 +64,7 @@ for ϵᵢ in 0.0:0.1:1.0
5364
end
5465
p
5566
```
67+
5668
This makes sense: for larger $ϵ$, gravity weakens with altitude, and the trajectory goes higher for a fixed initial velocity.
5769

5870
An advantage of the perturbative method is that we run the ODE solver only once and calculate trajectories for several $ϵ$ for free. Had we solved the full unperturbed ODE directly, we would need to do repeat it for every $ϵ$.
@@ -62,19 +74,23 @@ An advantage of the perturbative method is that we run the ODE solver only once
6274
Our second example applies perturbation theory to nonlinear oscillators -- a very important class of problems. As we will see, perturbation theory has difficulty providing a good solution to this problem, but the process is nevertheless instructive. This example closely follows chapter 7.6 of *Nonlinear Dynamics and Chaos* by Steven Strogatz.
6375

6476
The goal is to solve the ODE
77+
6578
```@example perturbation
66-
eq = D(D(x)) + 2*ϵ*D(x) + x ~ 0
79+
eq = D(D(x)) + 2 * ϵ * D(x) + x ~ 0
6780
```
81+
6882
with initial conditions $x(0) = 0$ and $ẋ(0) = 1$. With $ϵ = 0$, the problem reduces to the simple linear harmonic oscillator with the exact solution $x(t) = \sin(t)$.
6983

7084
We follow the same steps as in the previous example to construct the `ODESystem`:
85+
7186
```@example perturbation
7287
eq_pert = substitute(eq, x => x_series)
7388
eqs_pert = taylor_coeff(eq_pert, ϵ, 0:2)
7489
@mtkbuild sys = ODESystem(eqs_pert, t)
7590
```
7691

7792
We solve and plot it as in the previous example, and compare the solution with $ϵ=0.1$ to the exact solution $x(t, ϵ) = e^{-ϵ t} \sin(\sqrt{(1-ϵ^2)}\,t) / \sqrt{1-ϵ^2}$ of the unperturbed equation:
93+
7894
```@example perturbation
7995
u0 = Dict([unknowns(sys) .=> 0.0; D(y[0]) => 1.0]) # nonzero initial velocity
8096
prob = ODEProblem(sys, u0, (0.0, 50.0))

src/systems/problem_utils.jl

Lines changed: 121 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,25 @@ function add_parameter_dependencies!(sys::AbstractSystem, varmap::AbstractDict)
250250
add_observed_equations!(varmap, parameter_dependencies(sys))
251251
end
252252

253+
struct UnexpectedSymbolicValueInVarmap <: Exception
254+
sym::Any
255+
val::Any
256+
end
257+
258+
function Base.showerror(io::IO, err::UnexpectedSymbolicValueInVarmap)
259+
println(io,
260+
"""
261+
Found symbolic value $(err.val) for variable $(err.sym). You may be missing an \
262+
initial condition or have cyclic initial conditions. If this is intended, pass \
263+
`symbolic_u0 = true`. In case the initial conditions are not cyclic but \
264+
require more substitutions to resolve, increase `substitution_limit`. To report \
265+
cycles in initial conditions of unknowns/parameters, pass \
266+
`warn_cyclic_dependency = true`. If the cycles are still not reported, you \
267+
may need to pass a larger value for `circular_dependency_max_cycle_length` \
268+
or `circular_dependency_max_cycles`.
269+
""")
270+
end
271+
253272
"""
254273
$(TYPEDSIGNATURES)
255274
@@ -278,6 +297,12 @@ function better_varmap_to_vars(varmap::AbstractDict, vars::Vector;
278297
isempty(missing_vars) || throw(MissingVariablesError(missing_vars))
279298
end
280299
vals = map(x -> varmap[x], vars)
300+
if !allow_symbolic
301+
for (sym, val) in zip(vars, vals)
302+
symbolic_type(val) == NotSymbolic() && continue
303+
throw(UnexpectedSymbolicValueInVarmap(sym, val))
304+
end
305+
end
281306

282307
if container_type <: Union{AbstractDict, Tuple, Nothing, SciMLBase.NullParameters}
283308
container_type = Array
@@ -298,17 +323,68 @@ function better_varmap_to_vars(varmap::AbstractDict, vars::Vector;
298323
end
299324
end
300325

326+
"""
327+
$(TYPEDSIGNATURES)
328+
329+
Check if any of the substitution rules in `varmap` lead to cycles involving
330+
variables in `vars`. Return a vector of vectors containing all the variables
331+
in each cycle.
332+
333+
Keyword arguments:
334+
- `max_cycle_length`: The maximum length (number of variables) of detected cycles.
335+
- `max_cycles`: The maximum number of cycles to report.
336+
"""
337+
function check_substitution_cycles(
338+
varmap::AbstractDict, vars; max_cycle_length = length(varmap), max_cycles = 10)
339+
# ordered set so that `vars` are the first `k` in the list
340+
allvars = OrderedSet{Any}(vars)
341+
union!(allvars, keys(varmap))
342+
allvars = collect(allvars)
343+
var_to_idx = Dict(allvars .=> eachindex(allvars))
344+
graph = SimpleDiGraph(length(allvars))
345+
346+
buffer = Set()
347+
for (k, v) in varmap
348+
kidx = var_to_idx[k]
349+
if symbolic_type(v) != NotSymbolic()
350+
vars!(buffer, v)
351+
for var in buffer
352+
haskey(var_to_idx, var) || continue
353+
add_edge!(graph, kidx, var_to_idx[var])
354+
end
355+
elseif v isa AbstractArray
356+
for val in v
357+
vars!(buffer, val)
358+
end
359+
for var in buffer
360+
haskey(var_to_idx, var) || continue
361+
add_edge!(graph, kidx, var_to_idx[var])
362+
end
363+
end
364+
empty!(buffer)
365+
end
366+
367+
# detect at most 100 cycles involving at most `length(varmap)` vertices
368+
cycles = Graphs.simplecycles_limited_length(graph, max_cycle_length, max_cycles)
369+
# only count those which contain variables in `vars`
370+
filter!(Base.Fix1(any, <=(length(vars))), cycles)
371+
372+
map(cycles) do cycle
373+
map(Base.Fix1(getindex, allvars), cycle)
374+
end
375+
end
376+
301377
"""
302378
$(TYPEDSIGNATURES)
303379
304380
Performs symbolic substitution on the values in `varmap` for the keys in `vars`, using
305381
`varmap` itself as the set of substitution rules. If an entry in `vars` is not a key
306382
in `varmap`, it is ignored.
307383
"""
308-
function evaluate_varmap!(varmap::AbstractDict, vars)
384+
function evaluate_varmap!(varmap::AbstractDict, vars; limit = 100)
309385
for k in vars
310386
haskey(varmap, k) || continue
311-
varmap[k] = fixpoint_sub(varmap[k], varmap)
387+
varmap[k] = fixpoint_sub(varmap[k], varmap; maxiters = limit)
312388
end
313389
end
314390

@@ -407,6 +483,14 @@ Keyword arguments:
407483
length of `u0` vector for consistency. If `false`, do not check with equations. This is
408484
forwarded to `check_eqs_u0`
409485
- `symbolic_u0` allows the returned `u0` to be an array of symbolics.
486+
- `warn_cyclic_dependency`: Whether to emit a warning listing out cycles in initial
487+
conditions provided for unknowns and parameters.
488+
- `circular_dependency_max_cycle_length`: Maximum length of cycle to check for.
489+
Only applicable if `warn_cyclic_dependency == true`.
490+
- `circular_dependency_max_cycles`: Maximum number of cycles to check for.
491+
Only applicable if `warn_cyclic_dependency == true`.
492+
- `substitution_limit`: The number times to substitute initial conditions into each
493+
other to attempt to arrive at a numeric value.
410494
411495
All other keyword arguments are passed as-is to `constructor`.
412496
"""
@@ -416,7 +500,11 @@ function process_SciMLProblem(
416500
warn_initialize_determined = true, initialization_eqs = [],
417501
eval_expression = false, eval_module = @__MODULE__, fully_determined = false,
418502
check_initialization_units = false, tofloat = true, use_union = false,
419-
u0_constructor = identity, du0map = nothing, check_length = true, symbolic_u0 = false, kwargs...)
503+
u0_constructor = identity, du0map = nothing, check_length = true,
504+
symbolic_u0 = false, warn_cyclic_dependency = false,
505+
circular_dependency_max_cycle_length = length(all_symbols(sys)),
506+
circular_dependency_max_cycles = 10,
507+
substitution_limit = 100, kwargs...)
420508
dvs = unknowns(sys)
421509
ps = parameters(sys)
422510
iv = has_iv(sys) ? get_iv(sys) : nothing
@@ -466,7 +554,8 @@ function process_SciMLProblem(
466554
initializeprob = ModelingToolkit.InitializationProblem(
467555
sys, t, u0map, pmap; guesses, warn_initialize_determined,
468556
initialization_eqs, eval_expression, eval_module, fully_determined,
469-
check_units = check_initialization_units)
557+
warn_cyclic_dependency, check_units = check_initialization_units,
558+
circular_dependency_max_cycle_length, circular_dependency_max_cycles)
470559
initializeprobmap = getu(initializeprob, unknowns(sys))
471560

472561
punknowns = [p
@@ -503,7 +592,20 @@ function process_SciMLProblem(
503592
add_observed!(sys, op)
504593
add_parameter_dependencies!(sys, op)
505594

506-
evaluate_varmap!(op, dvs)
595+
if warn_cyclic_dependency
596+
cycles = check_substitution_cycles(
597+
op, dvs; max_cycle_length = circular_dependency_max_cycle_length,
598+
max_cycles = circular_dependency_max_cycles)
599+
if !isempty(cycles)
600+
buffer = IOBuffer()
601+
for cycle in cycles
602+
println(buffer, cycle)
603+
end
604+
msg = String(take!(buffer))
605+
@warn "Cycles in unknowns:\n$msg"
606+
end
607+
end
608+
evaluate_varmap!(op, dvs; limit = substitution_limit)
507609

508610
u0 = better_varmap_to_vars(
509611
op, dvs; tofloat = true, use_union = false,
@@ -515,7 +617,20 @@ function process_SciMLProblem(
515617

516618
check_eqs_u0(eqs, dvs, u0; check_length, kwargs...)
517619

518-
evaluate_varmap!(op, ps)
620+
if warn_cyclic_dependency
621+
cycles = check_substitution_cycles(
622+
op, ps; max_cycle_length = circular_dependency_max_cycle_length,
623+
max_cycles = circular_dependency_max_cycles)
624+
if !isempty(cycles)
625+
buffer = IOBuffer()
626+
for cycle in cycles
627+
println(buffer, cycle)
628+
end
629+
msg = String(take!(buffer))
630+
@warn "Cycles in parameters:\n$msg"
631+
end
632+
end
633+
evaluate_varmap!(op, ps; limit = substitution_limit)
519634
if is_split(sys)
520635
p = MTKParameters(sys, op)
521636
else

test/initial_values.jl

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,28 @@ end
149149
pmap = [c1 => 5.0, c2 => 1.0, c3 => 1.2]
150150
oprob = ODEProblem(osys, u0map, (0.0, 10.0), pmap)
151151
end
152+
153+
@testset "Cyclic dependency checking and substitution limits" begin
154+
@variables x(t) y(t)
155+
@mtkbuild sys = ODESystem(
156+
[D(x) ~ x, D(y) ~ y], t; initialization_eqs = [x ~ 2y + 3, y ~ 2x],
157+
guesses = [x => 2y, y => 2x])
158+
@test_warn ["Cycle", "unknowns", "x", "y"] try
159+
ODEProblem(sys, [], (0.0, 1.0), warn_cyclic_dependency = true)
160+
catch
161+
end
162+
@test_throws ModelingToolkit.UnexpectedSymbolicValueInVarmap ODEProblem(
163+
sys, [x => 2y + 1, y => 2x], (0.0, 1.0); build_initializeprob = false)
164+
165+
@parameters p q
166+
@mtkbuild sys = ODESystem(
167+
[D(x) ~ x * p, D(y) ~ y * q], t; guesses = [p => 1.0, q => 2.0])
168+
# "unknowns" because they are initialization unknowns
169+
@test_warn ["Cycle", "unknowns", "p", "q"] try
170+
ODEProblem(sys, [x => 1, y => 2], (0.0, 1.0),
171+
[p => 2q, q => 3p]; warn_cyclic_dependency = true)
172+
catch
173+
end
174+
@test_throws ModelingToolkit.UnexpectedSymbolicValueInVarmap ODEProblem(
175+
sys, [x => 1, y => 2], (0.0, 1.0), [p => 2q, q => 3p])
176+
end

0 commit comments

Comments
 (0)