Skip to content

Auto-generated bounds forwarded to init NonlinearProblem inject -Inf into cos -> NaN (regression from #4308) #4672

Description

@baggepinnen

Description

Since SciML/ModelingToolkit.jl#4308 ("Forward unknown bounds metadata to NonlinearProblem/NonlinearLeastSquaresProblem"), NonlinearProblem construction attaches lb/ub vectors built from the unknowns' bounds metadata. The problem: generate_nonlinear_bounds emits a full lb/ub vector as soon as any unknown has a finite bound, filling in -Inf/Inf for every unbounded unknown.

# lib/ModelingToolkitBase/src/problems/nonlinearproblem.jl
function generate_nonlinear_bounds(sys::AbstractSystem, op)
    dvs = unknowns(sys)
    isempty(dvs) && return nothing, nothing
    lb = first.(getbounds.(dvs))   # unbounded unknown -> -Inf
    ub = last.(getbounds.(dvs))    # unbounded unknown ->  Inf
    ...
    if all(==(-Inf), lb) && all(==(Inf), ub)
        return nothing, nothing
    end
    return lb, ub                  # one finite bound anywhere => keep the -Inf/Inf entries
end

These spurious ±Inf bounds are passed straight into the (initialization) NonlinearProblem. They cause two problems:

  1. NaN from the residual/Jacobian. Any solver step that evaluates at a bound substitutes -Inf into the model. For an unbounded angle appearing inside cos/sin, cos(-Inf) == NaN, so the residual becomes NaN and initialization fails.
  2. The ±Inf bounds also confuse the nonlinear solve itself — several solvers stall, hit MaxIters, or clamp the unbounded component onto the spurious bound instead of converging.

The user never requested a bounded solve here — the bounds are auto-generated, and an unbounded unknown should not contribute a -Inf lower bound to a bounds-aware solver.

Minimal reproduction (pendulum)

A simple pendulum (x = L*sin(θ), y = -L*cos(θ)) with an initialization that specifies the tip location. θ is unbounded and appears inside cos; an auxiliary state b carries a finite physical bound. Two mutually-coupled initialization equations keep θ and b in the same SCC, so the init becomes a single NonlinearProblem holding both unknowns.

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

@parameters L = 1.0 g = 9.81
# θ: pendulum angle — unbounded (default bounds (-Inf, Inf)), appears inside cos/sin.
# b: an auxiliary state carrying a finite (physical) bound.
@variables θ(t) ω(t) x(t) y(t) b(t) [bounds = (0.0, 10.0)]

eqs = [
    D(θ) ~ ω
    D(ω) ~ -(g / L) * sin(θ)
    D(b) ~ 0
    x ~ L * sin(θ)
    y ~ -L * cos(θ)
]

# Two mutually-coupled initialization equations so that θ and b end up in the
# same strongly-connected component => one NonlinearProblem with both unknowns.
init_eqs = [
    cos(b) + sin(θ) ~ 1.0
    sin(b) + x ~ 0.5          # x = L*sin(θ): the tip location enters here
]

@named pend = System(eqs, t; initialization_eqs = init_eqs,
    guesses ==> 0.3, b => 0.5, ω => 0.0])
pend = mtkcompile(pend)

prob = ODEProblem(pend, [ω => 0.0], (0.0, 1.0))
initprob = prob.f.initialization_data.initializeprob

println("init unknowns : ", unknowns(initprob.f.sys))
println("initprob.lb   : ", initprob.lb)   # => [-Inf, 0.0]  (θ is unbounded)
println("initprob.ub   : ", initprob.ub)   # => [Inf, 10.0]

# The unbounded unknown's lower bound is -Inf. Any solver step that evaluates the
# residual/Jacobian at the bound substitutes -Inf into cos -> NaN.
res = similar(initprob.u0)
initprob.f(res, initprob.lb, initprob.p)
println("residual at lb: ", res)                          # => [NaN, NaN]
println("has NaN/Inf   : ", any(v -> isnan(v) || isinf(v), res))

Output:

init unknowns : SymbolicUtils...[θ(t), b(t)]
initprob.lb   : [-Inf, 0.0]
initprob.ub   : [Inf, 10.0]
residual at lb: [NaN, NaN]
has NaN/Inf   : true

Even smaller, solver-level reproduction

Stripping away the ODE, the same defect shows up in a plain coupled NonlinearProblem, where it also breaks the solve:

using ModelingToolkit, NonlinearSolve

@variables a [bounds = (0.0, 10.0)] θ      # a bounded, θ unbounded and inside cos
eqs = [
    0 ~ cos(a) + sin(θ) - 0.5
    0 ~ sin(a) + cos(θ) - 0.3
]
@named ns = System(eqs); ns = mtkcompile(ns)

prob = NonlinearProblem(ns, [a => 1.0, θ => 0.3])
@show prob.lb           # [-Inf, 0.0]
@show prob.ub           # [Inf, 10.0]

res = similar(prob.u0)
prob.f(res, prob.lb, prob.p)
@show res               # [NaN, NaN]   (cos(-Inf))

solve(prob)             # retcode = Stalled (clamps θ onto the spurious bound)

Suggested fix

generate_nonlinear_bounds should not hand -Inf/Inf entries to the solver for unbounded unknowns. Options:

  • Only build/pass bounds when all relevant entries are finite, or otherwise leave unbounded entries as the solver's "no bound" sentinel rather than literal ±Inf.
  • Or skip forwarding bounds entirely unless the chosen solver actually requires/consumes them.

Version info

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions