Trajectory linearization: avoid per-timepoint recompilation; require op for loop-opening parameters#4679
Merged
AayushSabharwal merged 2 commits intoJun 30, 2026
Conversation
When linearizing along a trajectory with `LinearizationOpPoint(sol, tvec)`,
the multi-timepoint path was ~340 ms/point with ~92% of the time spent in the
Julia compiler, recompiling on essentially every iteration. Two causes:
1. `_build_op_from_solution` returns a `Dict{SymbolicT, SymbolicT}`, so every
numeric operating-point value is stored as a symbolic constant. The
op-application loop then took the `symbolic_type(v) != NotSymbolic()` branch
for every entry and called `getu(prob, v)(prob)`. For a constant, `getu`
builds an observed function with the value baked into generated code, so a
fresh `RuntimeGeneratedFunction` is compiled per distinct value -> a
recompile at almost every time point. Fixed by `_resolve_op_value`, which
unwraps symbolic constants to plain numbers/arrays and only routes genuinely
symbolic expressions through `getu`.
2. `__linearize_multiple_op_barrier` called the high-level per-point
`linearize` in a loop, reconstructing the `LinearizationProblem` and
rebuilding all setters every iteration. It now builds the problem and setters
once (the op keys are identical across time points) and only updates values
and mutates `.t` per point, as the `LinearizationProblem` docstring intends.
Measured on a gain-scheduled closed loop (7 states, 21 op entries, 200 points):
~340 ms/pt -> ~3.6 ms/pt (~95x), with results bit-identical to the per-point
scalar path. Added a per-point scalar-vs-vector equivalence check to the
`LinearizationOpPoint` testset.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When `loop_openings` is used, each opened analysis point's output is turned into a parameter (via `Break(ap, true)`). Its operating-point value is not implied by the rest of the system, and in particular is not present in a solution passed via `LinearizationOpPoint`. Previously such a parameter silently took a stale/default value, which is especially wrong when linearizing along a trajectory: the value was frozen at the point used to build the linearization function rather than tracking the trajectory. `handle_loop_openings` now returns the variables it turns into parameters; these are threaded through `linearization_ap_transform` / `get_linear_analysis_function` into a new `loop_opening_params` field on `LinearizationFunction`. `linearize` errors (listing the offending parameters) if any of them is missing from the operating point, instead of using an implicit value. To supply them when the operating point is derived from a solution, `LinearizationOpPoint` gains an optional `op` keyword whose entries are merged into the solution-derived operating point at every time point, e.g. `LinearizationOpPoint(sol, t; op = Dict(opened_signal => 0))`. Added a `loop_openings require an operating point` testset. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
baggepinnen
added a commit
to JuliaControl/ControlSystemsMTK.jl
that referenced
this pull request
Jun 30, 2026
…ion docs `trajectory_ss` now accepts an `op` keyword that is forwarded to `ModelingToolkit.LinearizationOpPoint(sol, t; op)`, supplying operating-point values that are not available from the solution. This is required by the updated MTK linearization, which errors if a loop-opening parameter (the signal turned into a parameter by `loop_openings`) is not assigned a value. The batch-linearization tutorial now holds each opened signal at 0 via `op`, and the text is updated to the new semantics: opening at `u` keeps the scheduling variable connected (controller linearized along the trajectory and fully isolated), opening only at `y` leaves the scheduling feedback in place (controller still coupled to the plant), and opening `y`+`v` pins the scheduling to 0 (a different controller). The previous `controllersv == controllersu` test no longer holds and is replaced by a check that opening at `u` yields a lower-order (isolated) controller than opening at `y`. Requires the corresponding MTK change (SciML/ModelingToolkit.jl#4679). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Targets
as/linearize-op(#4443).While exercising this branch through
ControlSystemsMTK.trajectory_ss(linearizing a gain-scheduled closed loop at ~800 points along a trajectory), the multi-timepoint path was ~340 ms/point with ~92% of the time spent in the Julia compiler, recompiling on essentially every iteration. This PR addresses the two causes (points 1 and 2 from my review comment in #4443).1. Operating-point values are boxed into
SymbolicT, forcing per-value codegen_build_op_from_solutionreturns aDict{SymbolicT, SymbolicT}, so every numeric value is stored as a symbolic constant. The op-application loop then took thesymbolic_type(v) != NotSymbolic()branch for every entry and calledgetu(prob, v)(prob). For a constant,getubuilds an observed function with the value baked into generated code, so a freshRuntimeGeneratedFunctionis compiled per distinct value — i.e. a recompile at almost every time point. (Confirmed empirically: replaying identical operating points cost 268 ms/pt cold but 33.8 ms/pt warm — the cost tracks the values.)Fixed by
_resolve_op_value, which unwraps symbolic constants to plain numbers/arrays and only routes genuinely symbolic expressions (e.g.op = Dict(ρ => A/K)) throughgetu.2. The problem and setters were rebuilt every iteration
__linearize_multiple_op_barriercalled the high-level per-pointlinearizein a loop, reconstructing theLinearizationProblemand rebuilding allsetuaccessors each iteration. It now builds the problem and setters once (the op keys are identical across time points) and per point only updates values via the cached setters and mutates.t, as theLinearizationProblemdocstring already intends.Results
Gain-scheduled closed loop (7 states, 21 op entries, 200 points,
loop_openingsactive):Results are bit-identical to the per-point scalar
LinearizationOpPointpath (max abs diff = 0over sampled indices). The remaining per-point cost is the legitimate initialization solve + Jacobians, not compilation.The single-point
linearizeop-application now shares_resolve_op_value, so the same speedup benefits any caller whose op values are numeric, and the symbolic-op case (Dict(ρ => A/K)) is preserved (verified). Extended theLinearizationOpPointtestset with a per-point scalar-vs-vector equivalence check.This is purely a performance change; it does not touch operating-point semantics. (Separately, the opened-loop-parameter freezing I mentioned in #4443 is unaffected and still wants the "error if not provided" change.)
🤖 Generated with Claude Code
Also: require an operating point for loop-opening parameters (correctness)
Second commit. When
loop_openingsis used, each opened analysis point's output is turned into a parameter (Break(ap, true)) whose operating-point value is not implied by the rest of the system — and is not present in a solution passed viaLinearizationOpPoint. Previously it silently took a stale/default value; along a trajectory it was effectively frozen at the point used to build the linearization function rather than tracking the trajectory.Now
handle_loop_openingsreturns the variables it promotes to parameters; they are threaded into a newloop_opening_paramsfield onLinearizationFunction, andlinearizeerrors (listing them) if any is missing from the operating point. To supply them when the op comes from a solution,LinearizationOpPointgains an optionalopkeyword merged into the per-timepoint op:The op must be keyed by the parameter as it appears in the linearization system (root namespace stripped); the error message lists the exact names. Added a
loop_openings require an operating pointtestset.