Repository navigation
Linearize each clock partition of a hybrid system separately - #5168
baggepinnen wants to merge 8 commits into
Conversation
`linearize` builds its linearization from a continuous `ODEProblem` and has no representation for a clocked subsystem. On a model whose controller is a synchronous program the result is the plant with the controller's contribution missing, and nothing says so. `linearize_clocked` splits the model the way the compiler does, linearizes each partition in its own time domain, and reports the signals that cross each boundary so that the caller can reassemble the loop. Each partition is returned as a `ClockPartition` carrying its clock, its matrices, and its input and output signals grouped into the requested ones and one group per neighbouring partition. The continuous partition is returned in continuous time and every clocked partition in discrete time at its own sample interval, which keeps each boundary's Nyquist frequency visible rather than hiding it behind a single rate. `linearization_function` gains `already_simplified`, `problem_constructor` and `problem_kwargs` so that the discrete path reuses the existing jacobian machinery with a `DiscreteProblem` in place of the `ODEProblem`, and the assembly of the statespace matrices is factored out of `solve(::LinearizationProblem)` into `_linearization_matrices`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MxXfPwLvBCg2oeW7jm6MfN
A signal crossing a clock boundary reaches a partition as the `Sample` or `Hold` term that carries it, and inside a clocked partition every variable is shifted forward one step. Both are operator expressions, which the compiler does not accept as system inputs and whose spelling the reassembled system does not keep. Rewriting the crossing terms in the tearing state, and renaming the remaining shifted parameters once the partition is compiled, leaves each partition an ordinary system addressed by the names the model uses. A clocked partition without a state variable is a static map whose matrices are the same in either time domain, and `DiscreteProblem` cannot represent a system without unknowns, so it goes through the continuous path; only its reported clock says which rate it runs at. That case also exposed wrong shapes in the jacobians for a system without unknowns: the input count was read as `length(::Int)` and the output jacobian was built with the input count for its row dimension. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MxXfPwLvBCg2oeW7jm6MfN
The tests check the matrices against the difference and differential equations the models were written as, and against the closed loop assembled from the partitions: for a first-order plant under a clocked PI, the plant discretized over one sample interval and closed with the controller reproduces the closed-loop dynamics exactly, which is what makes the boundary bookkeeping worth trusting. Also covered: a model without clocks agreeing with `linearize`, a clocked partition without a state variable, two rates with a partition each, analysis points, an operating point taken from a solution, and the warnings for an event, for a clock that is not periodic and for an operating-point entry that names nothing. A model containing a clock cannot be simulated by ModelingToolkit alone, so none of these compare against a closed-loop simulation; the references are derived by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MxXfPwLvBCg2oeW7jm6MfN
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MxXfPwLvBCg2oeW7jm6MfN
| println(io, " state dimension ", size(p.A, 1), ", ", length(p.inputs), | ||
| " inputs, ", length(p.outputs), " outputs") |
There was a problem hiding this comment.
[Runic] reported by reviewdog 🐶
| println(io, " state dimension ", size(p.A, 1), ", ", length(p.inputs), | |
| " inputs, ", length(p.outputs), " outputs") | |
| println( | |
| io, " state dimension ", size(p.A, 1), ", ", length(p.inputs), | |
| " inputs, ", length(p.outputs), " outputs" | |
| ) |
| println(io, " not linearized") | ||
| end | ||
| for (label, groups, vars) in ( | ||
| ("inputs", p.input_groups, p.inputs), ("outputs", p.output_groups, p.outputs)) |
There was a problem hiding this comment.
[Runic] reported by reviewdog 🐶
| ("inputs", p.input_groups, p.inputs), ("outputs", p.output_groups, p.outputs)) | |
| ("inputs", p.input_groups, p.inputs), ("outputs", p.output_groups, p.outputs), | |
| ) |
| origin = j == 0 ? "requested" : (label == "inputs" ? "from partition $j" : | ||
| "to partition $j") |
There was a problem hiding this comment.
[Runic] reported by reviewdog 🐶
| origin = j == 0 ? "requested" : (label == "inputs" ? "from partition $j" : | |
| "to partition $j") | |
| origin = j == 0 ? "requested" : ( | |
| label == "inputs" ? "from partition $j" : | |
| "to partition $j" | |
| ) |
| linres = prob.f(u0, p, t) | ||
| f_x, f_z, g_x, g_z, f_u, g_u, h_x, h_z, h_u, x, p, t = linres | ||
| matrices = _linearization_matrices( | ||
| linres, inputs(prob.f.prob.f.sys); allow_input_derivatives) |
There was a problem hiding this comment.
[Runic] reported by reviewdog 🐶
| linres, inputs(prob.f.prob.f.sys); allow_input_derivatives) | |
| linres, inputs(prob.f.prob.f.sys); allow_input_derivatives | |
| ) |
| using ModelingToolkit, Test, LinearAlgebra | ||
| using ModelingToolkit: t_nounits as t, D_nounits as D | ||
| using ModelingToolkit: AnalysisPoint, sampletime, is_continuous_partition, is_linearized, | ||
| input_group, output_group |
There was a problem hiding this comment.
[Runic] reported by reviewdog 🐶
| input_group, output_group | |
| input_group, output_group |
| u ~ Hold(ud), | ||
| D(x) ~ -x + u, | ||
| y ~ x, | ||
| ], t) |
There was a problem hiding this comment.
[Runic] reported by reviewdog 🐶
| ], t) | |
| ], t | |
| ) |
| ], t) | ||
| op = Dict(x => 0.0, y => 0.0, u => 0.0, ud => 0.0, kp => 1.0, nothere => 3.0) | ||
| @test_warn ["nothere", "ignored"] linearize_clocked( | ||
| sys, ModelingToolkit.SymbolicT[], [y]; op) |
There was a problem hiding this comment.
[Runic] reported by reviewdog 🐶
| sys, ModelingToolkit.SymbolicT[], [y]; op) | |
| sys, ModelingToolkit.SymbolicT[], [y]; op | |
| ) |
| u ~ Hold(ud), | ||
| D(x) ~ -x + u, | ||
| y ~ x, | ||
| ], t) |
There was a problem hiding this comment.
[Runic] reported by reviewdog 🐶
| ], t) | |
| ], t | |
| ) |
| extra = Dict(y => sol(0.5; idxs = xp), u => 0.0, ud => 0.0, | ||
| ud(k - 1) => 0.0, kp => 2.0) |
There was a problem hiding this comment.
[Runic] reported by reviewdog 🐶
| extra = Dict(y => sol(0.5; idxs = xp), u => 0.0, ud => 0.0, | |
| ud(k - 1) => 0.0, kp => 2.0) | |
| extra = Dict( | |
| y => sol(0.5; idxs = xp), u => 0.0, ud => 0.0, | |
| ud(k - 1) => 0.0, kp => 2.0 | |
| ) |
| ud(k - 1) => 0.0, kp => 2.0) | ||
| partitions = linearize_clocked( | ||
| sys, ModelingToolkit.SymbolicT[], [y]; | ||
| op = Dict(x => sol(0.5; idxs = xp), extra...)) |
There was a problem hiding this comment.
[Runic] reported by reviewdog 🐶
| op = Dict(x => sol(0.5; idxs = xp), extra...)) | |
| op = Dict(x => sol(0.5; idxs = xp), extra...) | |
| ) |
Benchmark Results (Julia vlts)Time benchmarks
Memory benchmarks
|
Benchmark Results (Julia v1)Time benchmarks
Memory benchmarks
|
…uses Three corrections found by pointing the partition-wise linearization at a model whose controller is a model-predictive controller with an array-valued state estimate. An operating-point entry that names a state variable of a partition is now kept on the strength of that alone. The partition a signal belongs to is still consulted, but only for an entry naming an observed variable, which several partitions may observe. The membership test also looks through `getindex`, so that an entry for one element of an array-valued state finds its way. A clocked partition's inputs no longer carry the `missing` binding that `inputs_to_parameters!` leaves for an initialization problem to resolve. Such a partition solves none, and the discrete code generation substitutes bindings into the expressions it builds, so a `missing` reached them and the symbolic type of a product could not be computed. Every input of a clocked partition is required in the operating point instead, and says so if it is absent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MxXfPwLvBCg2oeW7jm6MfN
| SymbolicIndexingInterface.is_variable(psys, key) || | ||
| (allow_observed && belongs(key) && | ||
| SymbolicIndexingInterface.is_observed(psys, key)) |
There was a problem hiding this comment.
[Runic] reported by reviewdog 🐶
| SymbolicIndexingInterface.is_variable(psys, key) || | |
| (allow_observed && belongs(key) && | |
| SymbolicIndexingInterface.is_observed(psys, key)) | |
| SymbolicIndexingInterface.is_variable(psys, key) || | |
| ( | |
| allow_observed && belongs(key) && | |
| SymbolicIndexingInterface.is_observed(psys, key) | |
| ) |
| return v | ||
| end | ||
| end | ||
| end |
There was a problem hiding this comment.
[Runic] reported by reviewdog 🐶
| end | |
| return | |
| end |
|
|
||
| discrete = !(clock isa SciMLBase.ContinuousClock) | ||
| psys, stateless = _compile_partition( | ||
| ts, fullvars, in_vars, out_vars, crossing_in[i], iv, discrete) |
There was a problem hiding this comment.
[Runic] reported by reviewdog 🐶
| ts, fullvars, in_vars, out_vars, crossing_in[i], iv, discrete) | |
| ts, fullvars, in_vars, out_vars, crossing_in[i], iv, discrete | |
| ) |
| return domain !== nothing && partition_of_clock[domain] == i | ||
| end | ||
| partition_op = _partition_op( | ||
| psys, op, belongs, seen_op_keys; allow_observed = !discrete) |
There was a problem hiding this comment.
[Runic] reported by reviewdog 🐶
| psys, op, belongs, seen_op_keys; allow_observed = !discrete) | |
| psys, op, belongs, seen_op_keys; allow_observed = !discrete | |
| ) |
| end | ||
| linres = linfun(u0, parameter_values(linfun), t) | ||
| matrices = _linearization_matrices( | ||
| linres, in_vars; allow_input_derivatives) |
There was a problem hiding this comment.
[Runic] reported by reviewdog 🐶
| linres, in_vars; allow_input_derivatives) | |
| linres, in_vars; allow_input_derivatives | |
| ) |
| # renamed back once the partition is compiled. | ||
| shifted_io = Dict{SymbolicT, SymbolicT}( | ||
| τ => v | ||
| for (v, τ) in Iterators.flatten((zip(in_vars, in_terms), zip(out_vars, out_terms))) |
There was a problem hiding this comment.
[Runic] reported by reviewdog 🐶
| for (v, τ) in Iterators.flatten((zip(in_vars, in_terms), zip(out_vars, out_terms))) | |
| for (v, τ) in Iterators.flatten((zip(in_vars, in_terms), zip(out_vars, out_terms))) |
A clocked array variable whose elements are aliased to sampled inputs and which is used as a whole one tick back lowered to a variable the compiled partition did not declare, so code generation for that partition failed (#5169). JuliaComputing/StateSelection.jl#161 shifts the unscalarized form along with its scalarized elements. Add the regression test that fix makes pass — the two spellings of the same control law must give the same matrices — and raise the ModelingToolkitTearing bound to the release that will carry it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MxXfPwLvBCg2oeW7jm6MfN
| op = Dict(x1 => 0.0, x2 => 0.0, y1 => 0.0, y2 => 0.0, u => 0.0, ud => 0.0, | ||
| xd(k - 1)[1] => 0.0, xd(k - 1)[2] => 0.0, kp => kpval) |
There was a problem hiding this comment.
[Runic] reported by reviewdog 🐶
| op = Dict(x1 => 0.0, x2 => 0.0, y1 => 0.0, y2 => 0.0, u => 0.0, ud => 0.0, | |
| xd(k - 1)[1] => 0.0, xd(k - 1)[2] => 0.0, kp => kpval) | |
| op = Dict( | |
| x1 => 0.0, x2 => 0.0, y1 => 0.0, y2 => 0.0, u => 0.0, ud => 0.0, | |
| xd(k - 1)[1] => 0.0, xd(k - 1)[2] => 0.0, kp => kpval | |
| ) |
A parameter without a value is bound to `missing`, for an initialization problem to
solve for. A clocked partition solves none, and the discrete code generation
substitutes every binding, so a parameter whose binding depends on such a value
reached the generated expressions as a literal `missing` and code generation stopped
with `TypeError: expected DataType, got Type{Union{Missing, Real}}`.
Drop those bindings on a clocked partition. A parameter the partition's equations
reference then takes its value from the operating point; one they do not reference is
dropped outright, a partition carrying the whole model's parameter set so that most of
them belong to another partition's equations.
This is exercised by a heat-pump model whose plant leaves fifty parameters to
initialization, not by the test suite: the same substitution on the continuous
partition runs through `generate_initializesystem`, which the partition-wise path does
not currently handle, so a model small enough to be a test case fails there instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MxXfPwLvBCg2oeW7jm6MfN
`generate_rhs` adds the expression of every assertion to the right-hand side it generates. A partition inherits the whole model's assertions, so one stating a bound on a variable of another partition was generated as a reference to a name the partition does not declare, and the generated function failed at its first call with an `UndefVarError`. On a heat-pump model this was a range check on the weather reader's dry-bulb temperature, whose bounds are parameters the partition carries and whose subject is a variable of the continuous partition. Keep the assertions every variable of which the partition declares and drop the rest. A clocked partition names its own variables by their past values, which no assertion of a model is written in terms of, so it keeps none; an assertion is a statement about a simulated trajectory and takes no part in a linearization either way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MxXfPwLvBCg2oeW7jm6MfN
| """ | ||
| function _unresolvable_bindings(binds::AbstractDict) | ||
| unresolved = Set{SymbolicT}( | ||
| par for (par, val) in binds if SU.symtype(val) === Missing) |
There was a problem hiding this comment.
[Runic] reported by reviewdog 🐶
| par for (par, val) in binds if SU.symtype(val) === Missing) | |
| par for (par, val) in binds if SU.symtype(val) === Missing | |
| ) |
| end | ||
| grew || return unresolved | ||
| end | ||
| end |
There was a problem hiding this comment.
[Runic] reported by reviewdog 🐶
| end | |
| return | |
| end |
| end | ||
| @set! psys.bindings = ROSymmapT(binds) | ||
| @set! psys.outputs = OrderedSet{SymbolicT}( | ||
| SymbolicT[get(subs, o, o) for o in get_outputs(psys)]) |
There was a problem hiding this comment.
[Runic] reported by reviewdog 🐶
| SymbolicT[get(subs, o, o) for o in get_outputs(psys)]) | |
| SymbolicT[get(subs, o, o) for o in get_outputs(psys)] | |
| ) |
| @set! sys.initialization_eqs = Equation[substitute(eq, subs) | ||
| for eq in get_initialization_eqs(sys)] |
There was a problem hiding this comment.
[Runic] reported by reviewdog 🐶
| @set! sys.initialization_eqs = Equation[substitute(eq, subs) | |
| for eq in get_initialization_eqs(sys)] | |
| @set! sys.initialization_eqs = Equation[ | |
| substitute(eq, subs) | |
| for eq in get_initialization_eqs(sys) | |
| ] |
| u ~ Hold(ud), | ||
| D(x) ~ -x + u, | ||
| y ~ x, | ||
| ], t; assertions = Dict(x < 10.0 => "the plant state exceeded its bound")) |
There was a problem hiding this comment.
[Runic] reported by reviewdog 🐶
| ], t; assertions = Dict(x < 10.0 => "the plant state exceeded its bound")) | |
| ], t; assertions = Dict(x < 10.0 => "the plant state exceeded its bound") | |
| ) |
Closes part of #5166.
linearizebuilds its linearization from the continuous problem and has no representation for aclocked subsystem. On a model whose controller is a synchronous program, the result is the plant
with the controller's contribution missing, and nothing says so.
This adds
linearize_clocked, which splits the model the waymtkcompile!does, linearizes eachpartition in its own time domain, and reports the signals that cross each clock boundary so that
the caller can reassemble the loop.
Each partition is returned as a
ClockPartitioncarrying its clock, its matrices, and its inputand output signals grouped into the requested ones and one group per neighbouring partition. The
same variable names a signal on both sides of a boundary, so the partitions can be connected by
name. The continuous partition is returned in continuous time and every clocked partition in
discrete time at its own sample interval.
Why the partitions are returned separately
Discretizing the continuous partition at the clock interval and connecting it to the clocked
partitions is exact for a single periodic clock, since
Holdis a zero-order hold andSampleisideal sampling. Converting the clocked partitions to continuous time instead keeps the resolution
of the fast dynamics, but it is an approximation that rests on the signals crossing each boundary
having little content above that boundary's Nyquist frequency — a property of the nonlinear model,
not of any linearization. Returning the partitions separately, each with its own rate, is what
keeps that distinction available to the caller; assembling the loop is left to the caller, and
there is no new dependency on ControlSystems.
Scope
Periodic clocks. A partition on any other clock is returned without matrices but with its signal
lists, so a model of it obtained by other means can be connected in its place, and a warning names
it. Events take no part in the result and are reported the same way. The
Tc-integration approachalso described in the issue is not attempted here.
Implementation notes
The bulk of the work is naming. A signal crossing a boundary arrives as the
SampleorHoldterm that carries it, and
mark_discreteshifts a clocked partition's own variables forward onestep; both are operator expressions, which
generate_initializesystemrejects as system inputsand whose spelling reassembly does not preserve. The crossing terms are rewritten in the tearing
state before compilation, and what remains spelled shifted is renamed once the partition is
compiled, which leaves each partition an ordinary system addressed by the names the model uses.
linearization_functiongainsalready_simplified,problem_constructorandproblem_kwargs,so that the discrete path reuses the existing jacobian machinery with a
DiscreteProblemin placeof the
ODEProblem. The assembly of the statespace matrices is factored out ofsolve(::LinearizationProblem)into_linearization_matrices.Two fixes fell out of a partition without state variables, which is the shape a clocked
proportional controller takes: the input count in the zero-unknown branch of
LinearizationFunctionwas read aslength(::Int), and the output jacobian there was built withthe input count as its row dimension.
Tests
test/clock_linearization.jl, registered in theInterfaceIIgroup. The matrices are checkedagainst the difference and differential equations the models were written as, and against the
closed loop assembled from the partitions: for a first-order plant under a clocked PI, the plant
discretized over one sample interval and closed with the controller reproduces the closed-loop
dynamics exactly. Also covered: a model without clocks agreeing with
linearize, a clockedpartition without a state variable, two rates with a partition each, analysis points, an operating
point taken from a solution, the three warnings, and a clocked array variable used as a
whole at a previous tick.
A model containing a clock cannot be simulated by ModelingToolkit alone, so none of these compare
against a closed-loop simulation; the references are derived by hand.
Dependency
Compiling a partition whose clocked array variable is used as a whole one tick back produced a
reference to a variable the partition did not declare
(#5169), which is the shape a
model-predictive controller takes when its control law is called with the whole state estimate.
JuliaComputing/StateSelection.jl#161
shifts the unscalarized form along with its scalarized elements. The
ModelingToolkitTearingbound here is raised to
1.20.7, so this pull request needs the release carrying that fix beforeit resolves.
Checklist
contributor guidelines
🤖 Generated with Claude Code
https://claude.ai/code/session_01MxXfPwLvBCg2oeW7jm6MfN