Skip to content

Replace NLsolve with NonlinearSolve in find_steady_state! - #334

Draft
ufechner7 wants to merge 2 commits into
mainfrom
nonlinearsolve
Draft

ufechner7 wants to merge 2 commits into
mainfrom
nonlinearsolve

Conversation

@ufechner7

Copy link
Copy Markdown
Member

Closes #267. Supersedes #261, which is out of date with main and failed the KPS4 steady-state tests.

Summary

  • find_steady_state! (KPS3 and KPS4) now solves with the TrustRegion solver of NonlinearSolve (radius update scheme NLsolve), through a small wrapper nl_solve in src/utils.jl. It returns the fields of the NLsolve result that find_steady_state! uses, so the retry and continuation logic is unchanged.
  • Scaling is essential. NLsolve's autoscale (Moré column-norm scaling) is what makes it robust here. Without it, TrustRegion, Levenberg–Marquardt and Newton with backtracking each solve some cases and fail others. nl_solve scales the unknowns by the column norms of the Jacobian. NLsolve updates that scaling every iteration; here it is updated when the solver is restarted from its last iterate, up to 3 times.
  • NLsolve and NLSolversBase are removed from Project.toml, test/Project.toml and test/create_sys_image.jl.

Benchmark

system.yaml, KPS4 and KPS3, with and without trim, v_wind 6/10/15/25 m/s, elevation 40/50/60/70/85°, l_tether 50/150/392/1000 m: 320 cases per solver, Julia 1.13.1.

Solver KPS4 solved KPS3 solved Total time
NLsolve (main) 160/160 155/160 50 s
NonlinearSolve, no scaling 158/160 158/160 –
NonlinearSolve, fixed scaling 156/160 155/160 –
NonlinearSolve, scaling updated on restart (this PR) 160/160 155/160 152 s
  • Same cases solved: this PR fails the same 5 KPS3 cases as NLsolve (25 m/s, no trim, 40–60°, 392/1000 m).
  • Slower on hard cases: the median case is 1.8× slower. Hard cases fall through to the stiffness continuation and are 5–18× slower. The worst is KPS4 with trim at 6 m/s, 85° and 50 m: 9.7 s instead of 0.5 s. KPS4 with trim at 6 m/s and 1000 m takes about 5 s instead of 0.9 s.
  • Different solutions at low elevations: without trim, NonlinearSolve lands on the deep-stall solution more often (KPS4: 31 cases instead of 25), so the winch force can differ by several kN.
  • Bug on main, unchanged by this PR: KPS3 with trim=true at 6 m/s, 40° and 1000 m returns a trimmed state at −78° elevation with both solvers.

The slowdown is to be investigated before merging.

Benchmark script
# Compare the steady-state solvers (NLsolve vs NonlinearSolve) on a grid of cases
using KiteModels, KitePodModels, Logging, Test
set_data_path(joinpath(dirname(dirname(pathof(KiteModels))), "data"))

const WINDS  = (6.0, 10.0, 15.0, 25.0)
const ELEVS  = (40.0, 50.0, 60.0, 70.0, 85.0)
const LENGTHS = (50.0, 150.0, 392.0, 1000.0)

function make_model(model, v, el, l)
    set = deepcopy(load_settings("system.yaml"))
    set.v_wind = v; set.elevation = el; set.l_tethers[1] = l; set.l_tether = l
    model == :kps4 ? KPS4(KCU(set)) : KPS3(KCU(set))
end

# :ok, :noconv (warning "did not converge"), :error; plus time and some state metrics
function run_case(model, v, el, l, trim)
    s = make_model(model, v, el, l)
    logger = Test.TestLogger(min_level=Logging.Warn)
    status = :ok
    t = @elapsed try
        with_logger(logger) do
            find_steady_state!(s; trim)
        end
    catch e
        status = :error
    end
    msgs = [string(r.message) for r in logger.logs]
    any(m -> occursin("did not converge", m), msgs) && status == :ok && (status = :noconv)
    stall = any(m -> occursin("deep stall", m), msgs)
    el_res = status == :error ? NaN : rad2deg(calc_elevation(s))
    force  = status == :error ? NaN : winch_force(s)
    (; model, v, el, l, trim, status, stall, t, el_res, force)
end

function bench(models=(:kps4, :kps3))
    res = []
    for model in models, trim in (false, true), v in WINDS, el in ELEVS, l in LENGTHS
        KaimonGate.is_cancelled() && break
        KaimonGate.progress("$model trim=$trim v=$v el=$el l=$l ($(length(res)))")
        push!(res, run_case(model, v, el, l, trim))
    end
    res
end

summary(res) = [(m, tr, count(r -> r.status == :ok, rs), count(r -> r.status != :ok, rs), round(sum(r.t for r in rs) / length(rs), digits=3))
                for m in unique(r.model for r in res), tr in (false, true)
                for rs in [filter(r -> r.model == m && r.trim == tr, res)]]

Usage: res = bench(); summary(res)

Test plan

  • test-steady-state-robustness.jl: 54/54
  • test-steady-state-kps4.jl: 15/15
  • test-kps3.jl: 154/154
  • test-kps4.jl: 223/223
  • test-aqua.jl: 9/9
  • full test suite (CI)

🤖 Generated with Claude Code

ufechner7 and others added 2 commits October 12, 2026 17:15
The steady state of KPS3 and KPS4 is solved with the trust region
solver of NonlinearSolve. Like the autoscaling of NLsolve, the unknowns
are scaled by the column norms of the Jacobian; the scaling is updated
when the solver is restarted from its last iterate. NLsolve and
NLSolversBase are no longer dependencies.

Closes #267

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The default manifests still contained NLsolve and its dependencies
Distances, LineSearches and NLSolversBase; pruning them during
Pkg.resolve() failed on CI. The manifests are resolved again, which
only removes these four packages. nl_solve is added to the manual,
so that makedocs does not fail with missing docs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ufechner7

Copy link
Copy Markdown
Member Author

Reported the slowdown upstream: SciML/NonlinearSolve.jl#1370 (related: SciML/NonlinearSolve.jl#425, autoscaling for TrustRegion).

@ufechner7
ufechner7 marked this pull request as draft October 12, 2026 21:01

This branch has not been deployed

No deployments
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.

Replace NLsolve with NonlinearSolve

1 participant