Skip to content

Add linearize_hybrid for partition-wise linearization of clocked systems - #5173

Draft
baggepinnen wants to merge 8 commits into
masterfrom
hybrid-linearization
Draft

baggepinnen wants to merge 8 commits into
masterfrom
hybrid-linearization

Conversation

@baggepinnen

@baggepinnen baggepinnen commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

Closes #5166 (second approach: separate linearization of all clock partitions).

Summary

linearize accounts for the continuous equations of a model only, so a model with a synchronous (clocked) controller linearizes to the open-loop plant. This PR adds linearize_hybrid(sys, inputs, outputs; op, t, autodiff, ...), which splits the model into its clock partitions with the clock inference already used by mtkcompile, compiles every partition separately with the standard structural simplification, and differentiates the generated functions of each partition numerically (ForwardDiff through DifferentiationInterface, with an automatic AutoFiniteDiff retry). No symbolic differentiation is involved, so models calling arbitrary Julia functions are supported.

The result is a HybridLinearization holding one ClockPartitionLinearization per partition (continuous partition first) and a list of connections. Each partition carries the state-space matrices, the state, input and output variable lists, the clock and the sample interval. The inputs and outputs of a partition consist of the user-specified signals belonging to that partition (first, in the user's order; nu_user, ny_user) followed by the signals crossing clock boundaries into and out of the partition. The connections (; from, output, to, input) are the index pairs required by the advanced interface of ControlSystemsBase.feedback or the signal names for RobustAndOptimalControl.connect. clock_boundary(hl, from, to) collects the connections from one partition to another as the index vectors outputs and inputs for the advanced interface of ControlSystemsBase.feedback.

For a discrete partition, the state consists of the values at the previous tick and the matrices describe the update at a tick, x(k) = A x(k-1) + B u(k), y(k) = C x(k-1) + D u(k), whose transfer function is C(zI - A)⁻¹B + D. This is the form produced by the discrete-system compilation of ModelingToolkit (history variables xₜ₋₁ as unknowns, observed equations shifted back to the current tick).

Implementation notes

  • User-specified inputs are bound to new time-independent parameters through added equations u ~ u_input before clock inference. This keeps the input a variable of its partition and avoids the back-shifting of time-dependent parameters that the discrete reassembly performs.
  • Boundary terms (Sample(...), Hold(...), SynchToolkit.Latest(...), SampleTime()) are turned into parameters the way mtkcompile! does for the continuous partition of a hybrid system, and then replaced by plain parameters named after the signal and the operator (u_hold, y_sample, lat₊u_latest, sample_time), so that the compilation and code generation of a discrete partition need not treat operators defined outside ModelingToolkit as atomic symbols. The variable a boundary term is applied to, after stripping nested Sample/Hold, identifies the source partition. Boundary terms that no equation of their partition reads (the Hold(w) of a nested clock change Sample(clk)(Hold(w))) are compiled but not exposed. The operating-point value of the SampleTime() parameter is the sample interval of the partition's clock.
  • ModelingToolkitTearing.substitute_sample_time is not used: its sampletime helper matches every AbstractClock in its first @match arm and therefore never returns a sample interval (worth fixing in ModelingToolkitTearing separately).
  • linearization_function is split into the compilation step and _linearization_function_compiled, which operates on a compiled system and is reused for the continuous partition (initialization, DAE handling and LinearizationOpPoint support included). Behaviour is unchanged.
  • get_sensitivity, get_comp_sensitivity and get_looptransfer accept hybrid = true and return a HybridLinearization; linearize_hybrid accepts analysis points as inputs and outputs. The transformation part of get_linear_analysis_function is factored into linear_analysis_transform.
  • Operating points may be given as a dictionary or as a LinearizationOpPoint(sol, t); timeseries (callback-saved discrete) parameters are evaluated at t. The values of boundary signals that are not in op are evaluated from the operating point of the partition they originate from (continuous partition first), so an operating point only needs the state variables of the partitions; history variables take the value of the variable they are the history of. Remaining missing values default to zero with a warning.
  • Each partition system keeps only the parameters it uses (plus its boundary and input parameters, the parameters of its initialization equations, and their binding dependencies), the initial values of its unknowns and parameters, and neither the events nor the assertions of the model.
  • The initialization equations of the model that refer to symbols of the continuous partition only, and that determine at least one unknown or missing-bound parameter whose value the operating point does not fix, are passed to the initialization problem of the continuous partition. Parameters bound to missing and constant states (der(x) = 0, compiled to a parameter) that the model determines through initialization equations therefore take their values from them, and an operating-point value for such a symbol takes precedence. For a discrete partition, initialization equations of the form x(k-1) ~ expr or x ~ expr provide the history values of x. Parameters bound to missing that nothing determines take their guess, or zero with the missing-value warning.
  • Events, assertions, state machines and non-periodic clocks are not accounted for and produce a warning.
  • A partition whose unknowns, inputs or outputs are Boolean-valued (the state of a relay with hysteresis, for example) produces an error naming the variables, since linearization is not defined with respect to them. Non-finite entries in the matrices of a partition, which arise when an equation is not differentiable at the operating point, are reported by a warning.
  • From a LinearizationOpPoint, the values of the unknowns and parameters of each partition are taken; the values the solution holds for observed variables are not used, since they would repeat the constraints the unknowns already satisfy and made the initialization of the continuous partition overdetermined. Values given explicitly in the dictionary of the LinearizationOpPoint are treated like a dictionary operating point.
  • An operating point that fixes a signal the model determines from the state makes the initialization of the continuous partition overdetermined; it is then solved in the least-squares sense and the state can move away from the requested values. A warning names the differential unknowns whose requested values were not retained, and the x0 field of the partition holds the values used.
  • The input and boundary parameters of a partition are registered as the inputs of its compiled system (_mtkcompile! gains the keyword argument input_parameters). The inline solution of linear subsystems (inline_linear_sccs, used by MultibodyComponents) derives the element type of its buffers from the unknowns and the first registered input, so without the registration the buffers stayed Float64 when only the parameters carried dual numbers and the input Jacobians of a multibody plant fell back to finite differences. The initialization system accepts inputs that are time-independent parameters.

Prerequisites in ModelingToolkitTearing

Related pull requests

Three fixes found while developing this feature are submitted separately, each with a regression test that fails on master; this branch carries copies of them until they are merged, after which it is rebased and the ModelingToolkitBase floor is raised to a release that contains them:

Tests

  • test/linearize_hybrid.jl (InterfaceII): continuous-only model equal to linearize; single-clock loop assembled with feedback and compared with the closed-loop recursion; discrete-only model; second-order difference equation and an array-valued discrete state space with array parameters; all-linear multi-rate model with a nested clock change; operating points as a dictionary and from a solution; AutoFiniteDiff and the fallback for a registered function that rejects dual numbers, including a discrete partition without inputs; warnings for events; the error for Boolean variables, the input-derivative diagnostic, the warning for non-finite entries and the warning for requested state values that the initialization does not retain; analysis points on continuous and discrete connections including get_sensitivity(...; hybrid = true).
  • test/downstream/linearize_hybrid_components.jl (Downstream, uses DiscreteComponents, BlockComponents and SynchToolkit): DiscretePIDStandard loop whose assembled sampled-data closed loop matches the SynchToolkit simulation; DiscreteStateSpace SISO and MIMO with array inputs and outputs compared with ss(A, B, C, D, Ts); MovingAverageFilter; a UnitDelay followed by a gain in a loop; a multi-rate loop with Latest; operating point from a SynchToolkit solution of a nonlinear loop, without initialization warnings; analysis points.

The downstream test file is not registered in the test groups, since DiscreteComponents and SynchToolkit are registered in DyadRegistry only; its header describes the environment it runs in. Its DiscreteStateSpace cases shift unscalarized array variables and were run with JuliaComputing/StateSelection.jl#161; the in-repo array tests pass with the released ModelingToolkitTearing.

Documentation

New section "Hybrid systems with clock partitions" in docs/src/basics/Linearization.md, including the assembly of the sampled-data loop from the boundary index sets, its relation to starprod/lft, and checks of the content of the signals entering a sampler above its Nyquist frequency, for the continuous partition and, through the composite of the continuous and the faster partitions, for signals entering a slower clock partition; a note in the linear analysis tutorial.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QBgBrXDidWG1G2KF64gAte

@baggepinnen
baggepinnen marked this pull request as draft September 20, 2026 09:28
@github-actions

github-actions Bot commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

Benchmark Results (Julia vlts)

Time benchmarks
master 9011757... master / 9011757...
ODEProblem 3.23 ± 0.09 ms 3.18 ± 0.095 ms 1.02 ± 0.042
init 0.0547 ± 0.014 ms 0.0539 ± 0.013 ms 1.01 ± 0.35
large_parameter_init/ODEProblem 8.7 ± 0.55 ms 8.61 ± 0.57 ms 1.01 ± 0.092
large_parameter_init/init 0.0563 ± 0.013 ms 0.0559 ± 0.014 ms 1.01 ± 0.34
mtkcompile 5.62 ± 0.28 ms 5.57 ± 0.27 ms 1.01 ± 0.071
sparse_analytical_jacobian/ODEProblem 10.4 ± 0.58 ms 10.5 ± 0.59 ms 0.997 ± 0.079
sparse_analytical_jacobian/f_iip 0.04 ± 0.01 μs 0.04 ± 0.01 μs 1 ± 0.35
sparse_analytical_jacobian/f_oop 0.235 ± 0.0038 ms 0.234 ± 0.0034 ms 1.01 ± 0.022
time_to_load 4.63 ± 0.044 s 4.57 ± 0.03 s 1.01 ± 0.012
Memory benchmarks
master 9011757... master / 9011757...
ODEProblem 0.0346 M allocs: 1.82 MB 0.0346 M allocs: 1.82 MB 1
init 0.508 k allocs: 0.0846 MB 0.508 k allocs: 0.0846 MB 1
large_parameter_init/ODEProblem 0.147 M allocs: 5.54 MB 0.147 M allocs: 5.54 MB 1
large_parameter_init/init 0.64 k allocs: 0.179 MB 0.64 k allocs: 0.179 MB 1
mtkcompile 0.0594 M allocs: 3.37 MB 0.0594 M allocs: 3.37 MB 1
sparse_analytical_jacobian/ODEProblem 0.106 M allocs: 5.33 MB 0.106 M allocs: 5.33 MB 1
sparse_analytical_jacobian/f_iip 0 allocs: 0 B 0 allocs: 0 B
sparse_analytical_jacobian/f_oop 0.634 k allocs: 19.6 kB 0.634 k allocs: 19.6 kB 1
time_to_load 0.153 k allocs: 14.6 kB 0.153 k allocs: 14.6 kB 1

@github-actions

github-actions Bot commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

Benchmark Results (Julia v1)

Time benchmarks
master 9011757... master / 9011757...
ODEProblem 3.67 ± 0.082 ms 3.69 ± 0.066 ms 0.995 ± 0.029
init 0.0496 ± 0.021 ms 0.0496 ± 0.02 ms 0.998 ± 0.58
large_parameter_init/ODEProblem 9.96 ± 0.5 ms 10 ± 0.45 ms 0.993 ± 0.067
large_parameter_init/init 0.0482 ± 0.007 ms 0.0489 ± 0.0048 ms 0.986 ± 0.17
mtkcompile 6.42 ± 0.88 ms 6.5 ± 0.85 ms 0.987 ± 0.19
sparse_analytical_jacobian/ODEProblem 12.4 ± 1.5 ms 12.4 ± 1.5 ms 0.994 ± 0.17
sparse_analytical_jacobian/f_iip 0.05 ± 0 μs 0.05 ± 0 μs 1 ± 0
sparse_analytical_jacobian/f_oop 0.0831 ± 0.013 ms 0.0832 ± 0.012 ms 0.999 ± 0.21
time_to_load 4.01 ± 0.014 s 3.99 ± 0.0078 s 1.01 ± 0.0041
Memory benchmarks
master 9011757... master / 9011757...
ODEProblem 0.034 M allocs: 1.59 MB 0.034 M allocs: 1.61 MB 0.99
init 0.475 k allocs: 0.0602 MB 0.475 k allocs: 0.0602 MB 1
large_parameter_init/ODEProblem 0.143 M allocs: 4.86 MB 0.143 M allocs: 4.86 MB 1
large_parameter_init/init 0.757 k allocs: 0.166 MB 0.757 k allocs: 0.166 MB 1
mtkcompile 0.0558 M allocs: 2.64 MB 0.0558 M allocs: 2.63 MB 1.01
sparse_analytical_jacobian/ODEProblem 0.102 M allocs: 4.24 MB 0.102 M allocs: 4.24 MB 1
sparse_analytical_jacobian/f_iip 0 allocs: 0 B 0 allocs: 0 B
sparse_analytical_jacobian/f_oop 0.848 k allocs: 27 kB 0.848 k allocs: 27 kB 1
time_to_load 0.201 k allocs: 12.1 kB 0.201 k allocs: 12.1 kB 1

@baggepinnen

baggepinnen commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor Author

I tried this branch out in three settings: its own test file, a set of small models exercising
constructs the tests do not cover, and the heat-pump MPC loop of JuliaComputing/EnergyPlusDyad,
which has a 60 s controller partition containing a quadratic program, a 600 s envelope partition
with an array-valued discrete state, and a continuous plant. Environment: Julia 1.13.0 for the
in-repo tests and 1.12.7 for the large model, ModelingToolkitBase from lib/,
ModelingToolkitTearing 1.20.6, StateSelection 1.11.1.

On the small models the partitioning, the boundary bookkeeping and the connection indices come out
right, and the discrete convention (state at the previous tick, transfer function
C(zI - A)⁻¹B + D) composes with ControlSystemsBase.feedback as documented. One property worth
recording because it is easy to lose: no copy is taken of the clock-inference state or of the
partitions. Symbolic expressions are hashconsed, and a deepcopy of a TearingState yields
variables the structural bookkeeping no longer recognises, after which the continuous partition is
reported structurally singular. Copying the additional_observed buffer that split_system shares
between partitions, and calling scalarize_tearing_state_eqs! on every partition and
make_eqs_zero_equals! on the continuous one, is what lets a large hybrid model compile at all.

Findings below, roughly by severity.

1. A partition inherits the assertions of the whole model

generate_rhs folds assertions(sys) into the generated right-hand side. _compile_partition!
clears initialization_eqs and the events and filters initial_conditions and guesses down to
the symbols the partition knows, but assertions is left as it stands in the model, so a partition
generates code naming a variable of another partition. Both directions fail.

The continuous partition inheriting an assertion on a clocked variable:

using ModelingToolkit
using ModelingToolkit: t_nounits as t, D_nounits as D
dt = 0.1
@variables x(t) y(t) u(t) yd(t) ud(t)
eqs = [yd ~ Sample(dt)(y), ud ~ -2yd, u ~ Hold(ud), D(x) ~ -x + u, y ~ x]
@named sys = System(eqs, t; assertions = Dict(ud < 10.0 => "ud out of range"))
linearize_hybrid(sys, [], [x]; op = Dict(x => 0.0, ud => 0.0, y => 0.0))
# UndefVarError: `ud` not defined in `ModelingToolkitBase`

and a clocked partition inheriting an assertion on a continuous variable, with ud ~ 0.5ud(k - 1) - 2yd so that the partition has a state variable of its own and its update function is non-empty:

k = ShiftIndex(Clock(dt))
eqs = [yd ~ Sample(dt)(y), ud ~ 0.5ud(k - 1) - 2yd, u ~ Hold(ud), D(x) ~ -x + u, y ~ x]
@named sys = System(eqs, t; assertions = Dict(x < 10.0 => "x out of range"))
linearize_hybrid(sys, [], [x]; op = Dict(x => 0.0, ud => 0.0, y => 0.0), warn_missing_op = false)
# UndefVarError: `x` not defined in `ModelingToolkit`

A clocked partition with no state variable of its own hides the problem, since the assertion is
folded in only when rhss is non-empty; the assertion is still carried in partitions[i].sys and
can be read back from there.

This is what the heat-pump model trips on, see section 3b.

3. The heat-pump loop

HeatPumpMPCLoop with the EnergyPlus FMU replaced by a linear model identified from it, so the
loop is entirely symbolic and carries no event: a 60 s controller partition whose control law is
the solution of a quadratic program, a 600 s envelope partition with an array-valued discrete
state, and the continuous plant. Called as
linearize_hybrid(model, [], []; op, autodiff = AutoFiniteDiff()). Three failures in sequence,
each reached after working around the previous one. The model is internal, but (a) and (c) concern
categories of construct rather than anything specific to it.

(a) MethodError: Cannot convert an object of type Missing to an object of type Real, from
symconvert(::Type{Vector{Real}}, ::Type{Float64}, x::Missing) in MTKParameters, called from
_linearize_discrete_partition. Fifty parameters of this model are bound to missing
(plant₊cond₊circuit₊fluidVolumes, plant₊evap₊circuit₊crossAreas and so on, most of them
vector-valued). They belong to the plant and appear in no equation of the clocked partition, but
they are in its parameter list, and _partition_operating_point assigns a value only to boundary
parameters, to input parameters and, for a discrete partition, to the unknowns.

(b) Giving all fifty a value in op gets past that, and the next failure is
UndefVarError: plant₊building₊weaDat₊cheTemDryBul₊TIn not defined in ModelingToolkit, raised from
the DiscreteFunction of a clocked partition. That is finding 1 on a real model: cheTemDryBul is
a range-check block whose assertion names a continuous variable while its bounds are parameters the
clocked partition carries.

(c) Filtering the assertions down to the symbols of the partition locally, both clocked
partitions get through and the continuous partition then fails while its initialization problem is
built:

ERROR: The equations of a system must involve the unknowns/observables. The following equations
were found to have no unknowns/observables:
0 ~ Initial(plant₊zon₊fmuZon₊QLat_flow(t)) - plant₊zon₊fmuZon₊yd_hold_
0 ~ Initial(var"sr⸺4₊u"(t)) - r_zero₊k
0 ~ Initial(var"sr⸺2₊u"(t)) - sh_setpoint₊k
0 ~ Initial(plant₊zon₊fmuZon₊TRad(t)) - plant₊zon₊fmuZon₊yd_hold__

from check_no_parameter_equations inside InitializationProblem. Passing initialize = false
does not avoid it. The left-hand sides are initial conditions of signals that the partitioning has
turned into boundary parameters or exogenous expressions. This appears to be the same area as
finding 4, fatal here rather than a warning.

4. The initialization system of the continuous partition is overdetermined

Linearizing the continuous partition of a hybrid model emits

Warning: Initialization system is overdetermined. 1 equations for 0 unknowns.

It fired five times in one run of test/linearize_hybrid.jl, once per model with a continuous
partition that has a boundary input, and it is what makes the @test_nowarn at
test/linearize_hybrid.jl:280 fail. I reproduce locally exactly the single failure CI reports
(InterfaceII: 1455 pass, 1 fail, on both Julia versions). For the model of that testset the
compiled continuous partition is

unknowns   = [x(t)]
parameters = [kp, ud_hold]
equations  = [Differential(t)(x(t)) ~ ud_hold - x(t)]
observed   = [y(t) ~ x(t)]

so the surplus equation appears once the operating point has fixed both x and ud_hold. The
find_all_parameter_equations and InitializationProblem changes in this PR are in the same area,
and the description mentions the Hold(x) ~ Initial(Hold(x)) classification; I could not tell from
the outside whether the two are connected.

5. QA

Five qualified accesses to non-public ModelingToolkitBase names, all from the new file, fail the QA
job: getunshifted, process_SciMLProblem, scalarized_vars, split_indexed_var, unwrap_vars.
The :ap_var reexport failure in the same job comes from master, not from this PR. The
downgrade-mtkbase failure also looks unrelated: Method overwriting is not permitted during Module precompilation, from a downgraded Moshi.

6. Smaller observations

  • _with_autodiff_fallback catches every exception, so a structural failure, a missing
    operating-point value or a MethodError is retried under AutoFiniteDiff and then reported as an
    AD failure. On the heat-pump model, where one partition takes minutes, that doubles the wait and
    the warning names the wrong cause. It did not trigger in my runs only because I passed
    autodiff = AutoFiniteDiff(), which equals the default fallback and so skips the retry.
  • linearize_hybrid runs expand_connections(sys) and TearingState, whereas __mtkcompile runs
    extract_top_level_statemachines, expand_connections(sys, Val(true)), discover_maybe_zeros
    and apply_limited_lowering beforehand. A model using limited operators or state machines
    therefore takes a different path here than under mtkcompile. I did not construct a model that
    shows a difference, so I do not know whether it matters in practice.
  • The show methods print "2 states" and "1 state" for the dimension of the state.
  • I can confirm the observation about ModelingToolkitTearing.sampletime: its first match arm
    x::SciMLBase.AbstractClock => nothing also matches PeriodicClock, so the arm
    SciMLBase.PeriodicClock(dt) => dt below it is unreachable and the function always returns
    nothing.
  • The array-valued testsets in test/linearize_hybrid.jl passed for me on ModelingToolkitTearing
    1.20.6 with StateSelection 1.11.1, so the note about needing Shift unscalarized array variables with their scalarized elements JuliaComputing/StateSelection.jl#161
    may no longer apply to the in-repo tests. I did not run the downstream file, so its
    DiscreteStateSpace cases are untested here.

@baggepinnen

Copy link
Copy Markdown
Contributor Author

Thank you for the thorough review. All findings are addressed in the latest push; details per item below.

1 and 3b, assertions. _compile_partition! now clears the assertions of every partition system, and linearize_hybrid warns once when the model has assertions (they are not accounted for, like events). Both reproducers from the review are tests in test/linearize_hybrid.jl ("Model constructs that partitions must not inherit").

3a, parameters of other partitions. Each partition system now keeps only the parameters that occur in its equations, observed equations, the initial values and guesses of its unknowns, its boundary and input parameters, and, transitively, the bindings and initial values of those. A parameter of the plant bound to missing is therefore no longer part of a clocked partition (test "Parameters of other partitions"). For parameters bound to missing that a discrete partition does use, a value is taken from op, otherwise zero with the missing-value warning, since a discrete partition has no initialization problem.

3c and 4, initialization constraints. Three changes:

  • A partition keeps the initial values of its unknowns and parameters only. Initial values of observed variables, such as the default of a signal that the partitioning turned into an alias of a boundary parameter, no longer enter the initialization problem.
  • Operating-point values for observed variables whose value is an expression of parameters only (again, aliases of boundary parameters) are dropped from the partition operating point; they cannot determine an unknown.
  • The values of signals crossing a clock boundary are no longer expected in op. When op does not provide them, they are evaluated from the operating point of the partition they originate from (continuous partition first, with the remaining boundary values of that partition set to zero for this evaluation). op = Dict(x => 0.0) is enough for the model of the test at line 280, and the overdetermination warning is gone; the tests now assert @test_nowarn for these cases. The reproducer for the nonlinear case (ud ~ -kp*yd^2, D(x) ~ -x + u^2) checks that the held value at the operating point is the one determined by the plant state.

On the question whether the find_all_parameter_equations change is connected: it is a necessary condition, not the cause. Hold(x) ~ Initial(Hold(x)) was classified as a parameter-only equation because the check recursed into the operator term, which removed the equation from the initialization system and left Hold(x) undetermined. The redundant equation in finding 4 came from y => 0.0 in the operating point next to x => 0.0 with y ~ x, which the tests no longer pass and which linearize would warn about as well.

5, QA. The five qualified accesses are added to NONPUBLIC_QUALIFIED_ACCESSES in test/qa/qa.jl, next to the other ModelingToolkitBase internals reached from ModelingToolkit.

6.

  • The fallback now retries only for MethodErrors and errors whose message mentions Dual or ForwardDiff; other errors are rethrown as they are (tested).
  • linearize_hybrid runs extract_top_level_statemachines (warning when state machines are present), discover_maybe_zeros and apply_limited_lowering before building the tearing state, like __mtkcompile; only the equation source information used for error messages is not carried.
  • show prints the state dimension.
  • The note about StateSelection.jl#161 is corrected: the in-repo array tests pass with ModelingToolkitTearing 1.20.6, as you found; only the DiscreteStateSpace cases of the downstream file were run with the branch.

CI. The downstream test file is no longer registered in the test groups and its DyadRegistry-only dependencies are removed from test/downstream/Project.toml; the file stays in the repository with a header on how to run it. Of the remaining failures, the documentation build, the SciMLBase downstream job and the Enzyme-related downstream jobs fail on master as well, and the downgrade failure is the stochastic poissonians test.

@baggepinnen

Copy link
Copy Markdown
Contributor Author

Retried everything on 10af7f8, same environment as before (Julia 1.13.0 for the in-repo tests,
1.12.7 for the heat-pump loop, ModelingToolkitBase from lib/, ModelingToolkitTearing 1.20.6,
StateSelection 1.11.1).

What is fixed

test/linearize_hybrid.jl passes in full, 148 assertions, and the log is clean: not one warning,
where the previous run emitted five overdetermined-initialization warnings and failed the
@test_nowarn. CI agrees, InterfaceII and Downstream are green and the five explicit-import
failures are gone; QA still fails, but only on isempty([:ap_var]), which comes from master.

On the heat-pump loop, run with no workarounds at all, the three failures I reported are gone: no
MethodError from a missing-bound parameter reaching MTKParameters, no
UndefVarError: plant₊building₊weaDat₊cheTemDryBul₊TIn from the inherited assertion, and no
check_no_parameter_equations error from 0 ~ Initial(v) - <boundary parameter> equations. The
operating point resolves completely, with no missing-value warning, and resolving boundary values
from the source partition means the script no longer has to supply the shifted spellings
(adapter.xd(kz - 1)[i], mpc.observer.xpred(k - 1)[i] and so on) that it needed before. That is a
sizeable simplification for a model of this size.

What still fails

One failure remains, in the continuous partition only, after both clocked partitions are through.
With the plain operating point:

Warning: The initialization system is structurally singular. ... The problematic variables are
[plant₊cond₊circuit₊flowModel₊dp_nominal, plant₊cond₊circuit₊flowModel₊dp_exp, ...]
Warning: Initialization system is underdetermined. 57 equations for 118 unknowns.
ERROR: Could not evaluate value of parameter plant₊cond₊circuit₊heatTransfer₊As. Missing values
for variables in expression plant₊cond₊circuit₊heatTransfer₊As.

raised from MTKParameters inside InitializationProblem ←
maybe_build_initialization_problem ← _linearization_function_compiled. As is itself one of
the fifty parameters of this model bound to missing; its binding refers to nothing else.

Giving all fifty a value in op moves the failure one step on, to

ERROR: Could not evaluate value of parameter var"plant₊building₊weaDat₊decAng₊staTim#0"(t).

at the same place, this time a time-dependent parameter.

What I ruled out, in case it saves someone the same detour:

  • Keeping the model's initialization equations in the partition rather than clearing them changes
    neither failure. The model has 190 of them and they do include
    plant₊building₊weaDat₊decAng₊staTim(t) ~ plant₊building₊weaDat₊decAng₊modTimAux(t), so the
    second failure looked like it should have been explained by the clearing, and it is not.
  • It does not reduce. Five small hybrid models with a parameter bound to missing all succeed: with
    a value in op; determined by an initialization equation; used directly rather than through a
    dependent binding; determined by an equation of the model; and the same bindings linearized with
    linearize on the equivalent model without a clock.

I also have no first-hand comparison against the ordinary compilation path on this particular model:
mtkcompile with SynchToolkit.compile_lustre rejects it with Untranslatable/unhandled term encountered: EnergyPlusDyad.CombiTable1DsFunction(...), from the table lookup that carries the
exogenous term of the identified envelope. So the surrogate model linearizes but has never been
simulated, and I cannot say from this model whether the same operating point is reachable through
ODEProblem.

Since the model is internal and does not reduce, I am happy to run further probes on it if that
helps narrow it down.

@baggepinnen

Copy link
Copy Markdown
Contributor Author

Thank you for the second round. The remaining failure is addressed in the latest push.

Cause. The partition systems cleared the initialization equations of the model, and _linearization_function_compiled built the initialization problem with algebraic_only = true, which excludes initialization equations altogether. This is why keeping the equations on the partition system changed nothing in your probe. A parameter bound to missing that the model determines through an initial equation, such as As, and a constant state such as staTim (der(staTim) = 0 compiles to the parameter staTim#0 with the equation staTim ~ modTimAux as its only determination) were therefore left without an equation. In the small models, the underdetermined initialization problem returns a guessed value and the matrices are silently wrong (A = 0.97 instead of -π in the reproducer that is now a test); in the heat-pump loop, the structurally singular problem drops the undetermined symbols to parameters of the initialization system, which is the Could not evaluate value of parameter error you saw.

Fix. _compile_partition! now keeps the initialization equations of the model whose symbols are all known to the partition (boundary terms substituted by their parameters, equations with operators of other partitions dropped) and keeps the parameters they refer to. Before the continuous partition is linearized, the equations that determine at least one unknown or missing-bound parameter whose value the operating point does not fix are passed to ODEProblem through a new initialization_eqs keyword of _linearization_function_compiled, with algebraic_only = false; observed variables are expanded to the unknowns and parameters they depend on for this test, so staTim ~ modTimAux is recognized as determining staTim#0. An operating-point value for the symbol takes precedence and the equation is then dropped, which avoids the overdetermination when all fifty parameters are given in op. For a discrete partition, which has no initialization problem, equations x(k-1) ~ expr and x ~ expr provide the history values. missing-bound parameters that nothing determines take their guess, or zero with the missing-value warning, in every partition.

Tests in test/linearize_hybrid.jl ("Initialization equations of the model") cover the missing-bound parameter (with and without a value in op, with a guess, and with nothing), the constant state, and the history values of a discrete partition.

Related fix. linearize(sys, [], outputs) failed with a ForwardDiff Invalid Tag object error because LinearizationFunction differentiated with respect to an empty input vector; it now returns B and D with zero columns (test in test/linearize.jl).

Observation, not addressed here. A parameter that occurs only in an initialization equation is not a parameter of the compiled system: mtkcompile of D(x) ~ -As*x with As => missing and initialization_eqs = [As ~ dia^2*pi/4] yields parameters(ssys) == [As], and the subsequent ODEProblem fails with dia is present in the system but dia is not an unknown from the initialization system. This affects ODEProblem as well and is independent of this PR; in the heat-pump model the parameters of the initial equations also occur in the model equations, so it should not be reached there.

The heat-pump loop was not run here; the fix is verified on the reductions above. If the rerun on your side still fails, the remaining failure is likely unrelated to the partitioning.

Comment thread docs/src/basics/Linearization.md Outdated
Comment thread docs/src/basics/Linearization.md Outdated
Comment thread test/downstream/linearize_hybrid_components.jl Outdated
Comment thread test/downstream/linearize_hybrid_components.jl Outdated
Comment thread test/downstream/linearize_hybrid_components.jl
Comment thread test/linearize_hybrid.jl Outdated
@baggepinnen

Copy link
Copy Markdown
Contributor Author

I ran linearize_hybrid over every example and test model of
JuliaComputing/DiscreteComponents on
227da529c: the 18 Dyad test harnesses and examples under dyad/, the models built in
test/*.jl, and one wrapper per library component with a free input and an explicit output, 106
model instances in total. Environment: Julia 1.13.0, ModelingToolkitBase from lib/,
ModelingToolkitTearing 1.20.6, StateSelection 1.11.1 (the shift-unscalarized-array-variables
branch, JuliaComputing/StateSelection.jl#161), SynchToolkit 0.5.1, DiscreteComponents 0.5.0.

Five model instances are excluded because the ordinary path fails on them as well
(mtkcompile raises the same error, or the model cannot be constructed); they are defects of
DiscreteComponents, not of this pull request. Of the remaining 101, 67 linearize and 34 fail,
the failures falling into the four groups below; finding 5 is a warning that affects 18 of the
67 that succeed. Everything that linearizes was checked numerically and is correct, see
"What is right" below.

The first two groups also break this pull request's own downstream file. Running
test/downstream/linearize_hybrid_components.jl here: Discrete PI controller loop passes 9/9,
Operating point from a solution 3/3 and Analysis points 6/6, while DiscreteStateSpace (both
SISO and MIMO), MovingAverageFilter and Multi-rate loop error. _evaluate_outputs is
unchanged between 10af7f8 and 227da529c, so this is not introduced by the last three commits.


1. The history of an array-valued discrete state reaches the generated code unscalarized

14 model instances. An observed equation that refers to an array-valued clocked variable as a
whole, rather than element by element, is shifted back to the current tick and then references
the array history variable, which is not an argument of the generated function; the unknowns are
its scalar elements.

using ModelingToolkit
using ModelingToolkit: t_nounits as t, D_nounits as D
dt = 0.1
k = ShiftIndex(Clock(dt))
@variables uc(t) u(t) (x(t))[1:2] y(t)
eqs = [uc ~ sin(t), u ~ Sample(dt)(uc),
       x[1](k) ~ u(k), x[2](k) ~ x[1](k - 1), y ~ sum(x)]
@named sys = System(eqs, t; initialization_eqs = [x[1](k - 1) ~ 0.0])
linearize_hybrid(sys, [], [y]; warn_missing_op = false)
# ERROR: UndefVarError: `xₜ₋₁` not defined in `ModelingToolkit`

Writing the same output as y ~ x[1] + x[2] succeeds and gives A = [0.0;;], C = [1.0;;].
The failure is independent of the AD backend: autodiff = AutoFiniteDiff() fails identically,
which places it in the code generation of _evaluate_outputs, not in the differentiation. It is
raised from the RuntimeGeneratedFunction built by
build_explicit_observed_function at src/linearization_hybrid.jl:891.

In the package this affects every component that keeps a shift register or a coefficient product
in an array: MovingAverageFilter (comp₊tapsₜ₋₁), FIRFilter (comp₊cuₜ₋₁),
DiscreteTransferFunction (comp₊axₜ₋₁, comp₊bxₜ₋₁) and MedianFilter
(comp₊windowₜ₋₂), and therefore the MovingAverageFilter and Multi-rate loop testsets of the
downstream file.

2. An array-valued output of a discrete partition is not found in the partition system

13 model instances, all of DiscreteStateSpace, including both DiscreteStateSpace testsets of
the downstream file. Requesting either an element of the array output or the whole array fails.

A = [0.5 0.1; -0.2 0.8]
B = reshape([1.0, 0.5], 2, 1)
C = reshape([1.0, 0.0], 1, 2)
@variables uc(t) u(t) (x(t))[1:2] (y(t))[1:1]
eqs = [uc ~ sin(t), u ~ Sample(dt)(uc), x(k) ~ A * x(k - 1) + B * [u(k)], y ~ C * x]
@named sys = System(eqs, t; initialization_eqs = [x[1](k - 1) ~ 0.0, x[2](k - 1) ~ 0.0])
linearize_hybrid(sys, [], [y[1]]; warn_missing_op = false)
# ERROR: ArgumentError: Symbol (y(t))[1] is not present in the system.
linearize_hybrid(sys, [], [y]; warn_missing_op = false)
# ERROR: ArgumentError: Symbol (y(t))[1] is not present in the system.

Raised from CheckInvalidAndTrackNamespaced inside build_explicit_observed_function, again
through _evaluate_outputs. Both spellings of the output that a caller has available, the
element and the whole array, produce the same message, so neither matches what the compiled
partition holds.

3. A one-sample delay whose output is read by a second equation

Examples.TestUnitDelay and Examples.AccumulatorDemo. The discrete partition of a model that
contains a plain UnitDelay, y = u@(clk-1), fails as soon as the delayed signal is consumed by
another equation; with the delay output unused the same model linearizes.

@variables uc(t) u(t) y(t) z(t)
eqs = [uc ~ sin(t), u ~ Sample(dt)(uc), y ~ u(k - 1), z ~ y]
@named sys = System(eqs, t; initialization_eqs = [u(k - 1) ~ 0.0])
mtkcompile(sys; additional_passes = [SynchToolkit.compile_lustre])  # compiles and simulates
linearize_hybrid(sys, [], []; warn_missing_op = false)
# ERROR: ArgumentError: Equations (1), unknowns (2), and initial conditions (2) are of
#        different lengths.

Raised from check_eqs_u0 in process_SciMLProblem, called from _discrete_partition_data at
src/linearization_hybrid.jl:934: the compiled partition keeps two unknowns while only one
equation survives. Dropping z ~ y gives A = [0.0;;], C = [1.0;;], which is the correct
one-sample delay. Adding the initialization equation for the history is not what triggers it;
the model fails with and without it.

UnitDelay is the most elementary block of the library, and connecting its output to anything is
the normal use, so this is the finding I would weigh highest after the array ones.

4. A boolean-valued clocked variable fails with an opaque error

5 model instances: Examples.SimpleRelayControl, the OnOffDelay models of
test/test_primitives.jl, the DiscreteBinaryController loop of test/test_controllers.jl, and
the corresponding single-component wrappers. A relay with hysteresis or an on/off delay keeps a
boolean state and switches on it; the generated partition function receives that state as a
Float64.

@variables uc(t) u(t) y(t)
@variables s(t)::Bool
eqs = [uc ~ sin(t), u ~ Sample(dt)(uc),
       s(k) ~ ifelse(s(k - 1), u(k) > -0.5, u(k) > 0.5),
       y(k) ~ ifelse(s(k), 1.0, -1.0)]
@named sys = System(eqs, t; initialization_eqs = [s(k - 1) ~ false])
mtkcompile(sys; additional_passes = [SynchToolkit.compile_lustre])  # compiles and simulates
linearize_hybrid(sys, [], [y]; warn_missing_op = false)
# ERROR: TypeError: non-boolean (Float64) used in boolean context

The same with autodiff = AutoFiniteDiff() and with fallback_autodiff = nothing. A linearization
of a boolean state is not meaningful and I would not expect one; what I am reporting is that the
failure is a TypeError from inside the generated code rather than a statement that the partition
has a variable of boolean type. The existing warn_unsupported machinery seems the natural place
for it, next to events, assertions and state machines.

5. An operating point taken from a solution reintroduces the overdetermined initialization

18 model instances, every hybrid closed loop in the package. This is finding 4 of my first review
returning for LinearizationOpPoint; the dictionary operating point, which the tests use, is
clean. On the model of the new documentation section:

@variables x(t)=0.0 y(t) u(t) yd(t) ud(t)
@parameters kp = 2.0
eqs = [yd ~ Sample(dt)(y), ud ~ -kp * yd, u ~ Hold(ud), D(x) ~ -x + u, y ~ x]
@named sys = System(eqs, t)
ssys = mtkcompile(sys; additional_passes = [SynchToolkit.compile_lustre])
prob = ODEProblem(ssys, [], (0.0, 1.0))
sol = solve(prob, Tsit5())

linearize_hybrid(sys, [], [y]; op = Dict(x => 0.0), warn_missing_op = false)          # quiet
linearize_hybrid(sys, [], [y]; op = LinearizationOpPoint(sol, 0.5), warn_missing_op = false)
# Warning: Initialization system is overdetermined. 1 equations for 0 unknowns.

Both give A = [-1.0;;], so the result is right and only the initialization problem is
redundant. A solution assigns a value to every variable it stores, including the observed
y ~ x and the boundary signals, whereas the reasoning behind the fix in 10af7f8 was that such
values are dropped. Since taking the operating point from a simulation is the usual way to reach a
nontrivial one, I would expect LinearizationOpPoint to be filtered the same way a dictionary is.


What is right

Where a partition is produced, it is correct. I checked this numerically rather than structurally.

For each open-loop component the wrapper u → Sampler → component was simulated with SynchToolkit
at two different step heights and the outputs differenced, which cancels the initial condition and
any constant term, and the difference compared with lsim of the discrete partition from zero
state. Ten components agree to machine precision: UnitDelay, UnitDifference, NDelay,
DiscreteIntegrator in all three discretizations, ExponentialFilter, DiscreteDerivative,
AlphaBetaFilter and AlphaBetaGammaFilter, the largest relative deviation being 2.9e-16. The
continuous partition of each of these is the expected unit feedthrough from the free input to the
sampler.

For four closed loops around a FirstOrder plant, with DiscretePIDStandard and
DiscretePIDParallel in P, PI and PD configurations, the sampled-data loop was assembled from
c2d of the continuous partition and the discrete partition with ControlSystemsBase.feedback
using the index sets from clock_boundary, and its step response compared with the SynchToolkit
simulation of the same loop at the sampling instants. All four agree to a relative deviation
below 4e-13. The clock_boundary interface did exactly what the documentation says, with no
manual index bookkeeping.

The multi-rate constructs are handled: Latest between a 1 ms and an 8 ms clock
(test/test_primitives.jl), two PeriodicClock instances of equal rate in one model
(test/test_noise.jl), and the SampleTime() of SampleTimeSource all partition correctly.
Models containing a quantizer, seeded_rand/seeded_randn, clamp, or the sign/sqrt/ifelse
of TrackingDifferentiator linearize without error and return finite matrices, including
Examples.TestSlidingModeControl and Examples.SimplePID, which combine noise, quantization and a
second-order plant.

Smaller observations

  • SuperTwistingSMC returns D = [-Inf;;] with no diagnostic (A = [1.0;;], B = [-0.0;;],
    C = [1.0;;]). Its control law is -sqrt(k*abs(s))*sign(s), whose derivative at the default
    operating point s = 0 is infinite, so this is the correct derivative of a function that is
    not differentiable there; a non-finite entry in a returned state-space model is easy to carry
    unnoticed into a design though, and a check would be cheap.
  • Quantization returns D = [0.0;;] and an empty state, which is the correct derivative of
    round away from a step and silently uninformative. I mention it only because it is the one
    case where the returned model is valid and useless at the same time.
  • The Initialization system is underdetermined. 0 equations for 1 unknowns warning that appears
    when no operating point is given is not specific to linearize_hybrid: linearize on the
    corresponding continuous plant warns the same way, so I did not count it as a finding.
  • The five instances excluded above, for the record, since they mean the sweep does not cover an
    event clock: Examples.TestZeroCrossingClock cannot be constructed
    (UndefVarError: SciMLBase not defined in DiscreteComponents, a missing import in the package),
    Examples.SimpleLQR fails mtkcompile with "Found two inner output connectors", and
    Examples.TestClockPropagator and Examples.Integrator.IntegratorFEwInitialTest fail
    mtkcompile with "Found a clock partition that must be discrete ... but does not have any
    associated clock". I will follow these up on the DiscreteComponents side.

@baggepinnen

Copy link
Copy Markdown
Contributor Author

I ran this branch over the models of JuliaComputing/QuanserComponents.jl, a Furuta-pendulum package whose models have a few properties the existing coverage does not: a multibody continuous plant compiled through MultibodyComponents.multibody, programs that are purely discrete and ccall into C for their I/O, an acados MPC whose step is an opaque ccall, and a two-clock model whose crossings are DiscreteComponents.Latest.

Environment: Julia 1.13.0, ModelingToolkit 11.45.0 at 227da52 with ModelingToolkitBase from lib/, ModelingToolkitTearing 1.20.6, StateSelection 1.11.1, SynchToolkit 0.5.1, SynchJulia 0.8.4, DiscreteComponents 0.4.0, MultibodyComponents 0.2.4, BlockComponents 4.6.1, ControlSystemsBase 1.21.0. Every model was compiled with the options the package itself uses, additional_passes = [SynchToolkit.compile_lustre], MultibodyComponents.linsys... and optimize = [DyadCompilerPasses.LDIV_RULE].

What works

Continuous-only models are identical to linearize. The multibody QubePendulum in a World, FurutaPredictionModel, FrictionAndBackEMF and Energy all give a single partition, no boundary signals, and matrices equal to linearize's at the same operating point to 1.4e-14 at an equilibrium (subject to finding 3 below away from one).

A sampled-data loop around the multibody plant is right. FurutaSwingup itself is blocked by finding 1, so I built the same loop with SwingupCatch (the swing-up plus LQR controller) in place of SwingupWithHoming: plant, two SampleWithADEffects, a 5 ms PeriodicClock, a ZeroOrderHold. It splits into a 4-state continuous partition and a 4-state 5 ms partition with five boundary connections, and:

  • the continuous partition equals ControlSystemsMTK.named_ss(model, [u_plant], outs; loop_openings = [u_plant, shoulder_y, elbow_y]) at the same upright operating point to 6.3e-10 relative in A and 2.0e-12 in B;

  • the discrete partition, driven by the boundary signals taken from a SynchToolkit simulation of the loop, reproduces the simulated controller output to a relative error of at most 2e-11 over 41 ticks;

  • the sampled-data loop assembled from the two partitions with feedback over the clock_boundary index sets reproduces the nonlinear simulation, and the error falls away with the perturbation, which is the property that matters:

    initial elbow deviation max abs. error, arm relative max abs. error, pendulum relative
    2e-2 8.9e-4 2.0e-2 3.0e-4 1.5e-2
    5e-3 1.6e-5 1.4e-3 5.1e-6 1.0e-3
    1.25e-3 2.4e-7 8.8e-5 8.0e-8 6.4e-5
    3.125e-4 3.8e-9 5.5e-6 1.3e-9 4.0e-6

    (1 s of closed loop at Ts = 5 ms, sampler noise off.)

The multirate structure of FurutaMPCMultirate is handled. The MPC models themselves are blocked (see below), but the same two-clock arrangement without the MPC — DiscreteComponents.AlphaBetaGammaFilter at 1 ms, two DiscreteComponents.Latest crossings, a static controller at 8 ms, a ZeroOrderHold back onto the plant — gives three partitions with the right clocks, a 3-state fast partition, and a fast-to-slow boundary carried by SynchToolkit.Latest terms:

partition 1: Ts = nothing, nx = 1   inputs Hold()(zoh₊u)                 outputs plant₊y, smp₊u
partition 2: Ts = 0.008,   nx = 0   inputs Latest()(lat_pos₊u, …), Latest()(lat_rate₊u, …)
partition 3: Ts = 0.001,   nx = 3   inputs Sample(…)(smp₊u)              outputs lat_pos₊u, lat_rate₊u
cont -> fast (outputs = [2], inputs = [1]); fast -> slow ([1, 2] -> [1, 2]); slow -> cont ([1] -> [1])

The findings below are ordered by how much they block.

1. A partition in which a boundary signal occurs only under a shift

linearize_hybrid throws

ArgumentError: Equations (1), unknowns (2), and initial conditions (2) are of different lengths.
  check_eqs_u0 at abstractsystem.jl:3337
  __process_SciMLProblem at problem_utils.jl:2439
  _discrete_partition_data at linearization_hybrid.jl:924
  linearize_hybrid at linearization_hybrid.jl:1038

whenever a discrete partition reads a boundary signal only at the previous tick. The discriminating case is one term: y ~ Shift(t, -1)(u) fails, y ~ Shift(t, -1)(u) + 1e-9 * u succeeds. DiscreteComponents.UnitDelay is exactly the first form (y = u@(clk-1) with initial u@(clk-1) = initial_condition), while DiscreteDerivative ((u - u@(clk-1))/Ts) and ExponentialFilter are the second and are fine.

This blocks FurutaSwingup, the package's main model, which carries a UnitDelay inside GoHome, and equally FurutaHardware, the same controller as a standalone program: both fail with Equations (10), unknowns (13), and initial conditions (13). The same loop with SwingupCatch in place of SwingupWithHoming, which removes GoHome, linearizes correctly (the validation above).

using ModelingToolkit, DiscreteComponents, SynchToolkit, OrdinaryDiffEqTsit5
using ModelingToolkit: t_nounits as t, Shift, toggle_namespacing
using BlockComponents.Continuous: FirstOrder
using BlockComponents.Math: Gain

const dt = 0.1

# `DiscreteComponents.UnitDelay` written out: the input is read at the previous tick and
# never at the current one.
@component function Delay(; name, initial_condition = 0.0, also_current = false)
    @variables u(t) [input = true] y(t) [output = true]
    @parameters ic = initial_condition
    rhs = also_current ? Shift(t, -1)(u) + 1.0e-9 * u : Shift(t, -1)(u)
    return System([y ~ rhs], t, [u, y], [ic]; name,
                  initialization_eqs = [Shift(t, -1)(u) ~ ic])
end

function delay_loop(; name, block)
    @named plant = FirstOrder(k = 1, T = 1)
    @named smp = Sampler()
    @named clock = PeriodicClock(; dt)
    @named zoh = ZeroOrderHold(initial_condition = 0)
    @named gain = Gain(k = -0.5)
    c = block()
    eqs = [connect(plant.y, smp.u)
           connect(smp.y, c.u, clock.y)
           connect(c.y, gain.u)
           connect(gain.y, zoh.u)
           connect(zoh.y, plant.u)]
    return System(eqs, t, [], []; name, systems = [plant, smp, clock, zoh, gain, c])
end

# The loop compiles and simulates.
@named msim = delay_loop(block = () -> UnitDelay(; name = :c, initial_condition = 0))
ssim = mtkcompile(msim; additional_passes = [SynchToolkit.compile_lustre])
solve(ODEProblem(ssim, [ssim.plant.x => 1.0], (0.0, 1.0)), Tsit5())(1.0; idxs = ssim.plant.y)
# 0.2077879112376569

function lin(block)
    @named m = delay_loop(; block)
    nns = toggle_namespacing(m, false)
    linearize_hybrid(m, [], [nns.plant.y]; op = Dict(nns.plant.x => 0.0),
                     warn_missing_op = false)
end

lin(() -> UnitDelay(; name = :c, initial_condition = 0))   # ArgumentError, as above
lin(() -> Delay(; name = :c))                              # the same ArgumentError
lin(() -> Delay(; name = :c, also_current = true))         # OK
lin(() -> DiscreteDerivative(; name = :c))                 # OK
lin(() -> ExponentialFilter(; name = :c))                  # OK

On the package model directly:

using QuanserComponents, MultibodyComponents, SynchToolkit
import DyadCompilerPasses
@named model = QuanserComponents.FurutaSwingup()
nns = ModelingToolkit.toggle_namespacing(model, false)
linearize_hybrid(model, :u_plant,
                 [nns.qubependulum.shoulder_angle, nns.qubependulum.elbow_angle];
                 op = Dict(nns.qubependulum.elbow_joint.phi => pi,
                           nns.qubependulum.shoulder_joint.phi => 0.0,
                           nns.qubependulum.elbow_joint.w => 0.0,
                           nns.qubependulum.shoulder_joint.w => 0.0,
                           nns.qubependulum.voltage => 0.0),
                 additional_passes = [SynchToolkit.compile_lustre],
                 MultibodyComponents.linsys...,
                 optimize = [DyadCompilerPasses.LDIV_RULE])
# ArgumentError: Equations (10), unknowns (13), and initial conditions (13) are of different lengths.

2. A discrete partition with no inputs fails under AutoFiniteDiff

_linearize_discrete_partition computes B and D with DI.jacobian(..., autodiff, input_vals) unconditionally (linearization_hybrid.jl:969 and 971). AutoForwardDiff tolerates an empty input_vals; AutoFiniteDiff does not:

ArgumentError: range must be non-empty
  finite_difference_jacobian! at jacobians.jl:512
  jacobian at jacobian.jl:107
  _linearize_discrete_partition at linearization_hybrid.jl:948
  _with_autodiff_fallback at linearization_hybrid.jl:877

This is the discrete counterpart of the linearize(sys, [], outputs) fix listed under "Other fixes" in the PR description; the continuous partition is guarded, the discrete one is not.

using ModelingToolkit
using ModelingToolkit: t_nounits as t, ShiftIndex, Clock, AutoFiniteDiff
k = ShiftIndex(Clock(0.1))
@variables xd(t)
@named m = System([xd(k) ~ 0.5 * xd(k - 1)], t)

linearize_hybrid(m, [], [xd]; op = Dict(xd => 1.0), warn_missing_op = false)   # OK
linearize_hybrid(m, [], [xd]; op = Dict(xd => 1.0), autodiff = AutoFiniteDiff(),
                 warn_missing_op = false)
# ArgumentError: range must be non-empty

It is not only reached by asking for AutoFiniteDiff. The hardware programs of the package call into C from inside the tick, so AutoForwardDiff fails on them and the fallback puts every such partition on AutoFiniteDiff; a program with no external input then cannot be linearized at all. FurutaFriction (a velocity staircase with a DiscretePIDStandard loop, 8 discrete state variables) and FurutaIdentification (a trajectory replay with a safety supervisor, 2 state variables) both fail this way. With the input jacobians skipped when nu == 0 in a local build, both linearize and report the expected partitions.

3. AutoForwardDiff never reaches the continuous partition of a multibody model

For every multibody model in the package, linearize_hybrid falls back to AutoFiniteDiff on the continuous partition, while linearize on the same model, with the same operating point and the same compile options, differentiates with AutoForwardDiff without trouble. The result is not wrong, but it carries the accuracy of a forward difference, about 3e-8 relative, where linearize is exact. The warning is emitted, so it is visible, but it fires on every model of this kind.

using ModelingToolkit, QuanserComponents, MultibodyComponents, SynchToolkit
using ModelingToolkit: t_nounits as t, AutoFiniteDiff
import DyadCompilerPasses

@named world = MultibodyComponents.World(render = false)
@named qp = QuanserComponents.QubePendulum(idparams = QuanserComponents.identified)
@named model = System(Equation[], t; systems = [world, qp])
nns = ModelingToolkit.toggle_namespacing(model, false)
ins, outs = [nns.qp.voltage], [nns.qp.shoulder_angle, nns.qp.elbow_angle]
op = Dict(nns.qp.shoulder_joint.phi => 0.0, nns.qp.elbow_joint.phi => 3.0,
          nns.qp.shoulder_joint.w => 0.0, nns.qp.elbow_joint.w => 0.5, nns.qp.voltage => 0.0)
ckw = (; additional_passes = [SynchToolkit.compile_lustre], MultibodyComponents.linsys...,
         optimize = [DyadCompilerPasses.LDIV_RULE])

mats, _ = linearize(model, ins, outs; op, ckw...)
mats.A[4, 2]                                                    # 221.64409659240079

linearize_hybrid(model, ins, outs; op, fallback_autodiff = nothing, ckw...)
# MethodError: no method matching Float64(::ForwardDiff.Dual{ForwardDiff.Tag{
#   ModelingToolkit.PFFun{ODEFunction{true, SciMLBase.AutoDespecialize,
#   FunctionWrappersWrappers.FunctionWrappersWrapper{…}}, …}}, Float64, 1})

hl  = linearize_hybrid(model, ins, outs; op, ckw...)                     # warns, falls back
hlf = linearize_hybrid(model, ins, outs; op, autodiff = AutoFiniteDiff(), ckw...)
hl.partitions[1].A[4, 2]                                        # 221.64410305023193
hl.partitions[1].A == hlf.partitions[1].A                       # true, bit for bit
maximum(abs, hl.partitions[1].A - mats.A) / maximum(abs, mats.A) # 2.91e-8

The failing jacobian is the input jacobian: the tag is PFFun{ODEFunction{true, AutoDespecialize, FunctionWrappersWrapper}}, so the dual reaches the function wrappers _linearization_wrap_odeproblem_f built, which accept only Float64 and Dual{OrdinaryDiffEqTag}. Since linearization_function and _linearization_function_compiled build the problem the same way, the difference has to lie in the partition system the hybrid path passes in, most plausibly in the inputs having been turned into parameters by _bind_inputs_to_parameters before compilation. On non-multibody models (a scalar ODE, BlockComponents.Continuous.FirstOrder) AutoForwardDiff succeeds, so it is not universal.

4. BoundsError in place of the input-derivative diagnostic

A model whose linearization needs allow_input_derivatives reports it through inputs(prob.f.prob.f.sys)[der_inds] (linearization.jl:843). In a hybrid partition the inputs are parameters, inputs(sys) is empty, and constructing the error message throws:

# same `model`, `ins`, `outs` and `op` as in finding 3, without the multibody compile
# options, which is what makes this plant need input derivatives
linearize(model, ins, outs; op)
# Input derivatives appeared in expressions (-g_z\g_u != 0), the following inputs appeared differentiated: ...
linearize_hybrid(model, ins, outs; op)
# BoundsError: attempt to access 0-element Vector{SymbolicUtils.BasicSymbolicImpl…{SymReal}} at index [[1]]

5. The MPC models: the unscalarized array shift

FurutaMPCSwingup, FurutaMPCMultirateSwingup, FurutaMPCHardware and FurutaMPCMultirateHardware all fail identically, in _evaluate_outputs:

UndefVarError: `control_system₊mpc₊xₜ₋₁` not defined in `ModelingToolkit`
  RuntimeGeneratedFunction at RuntimeGeneratedFunctions.jl:230
  GeneratedFunctionWrapper at codegen_utils.jl:1058
  _evaluate_outputs at linearization_hybrid.jl:886
  linearize_hybrid at linearization_hybrid.jl:1038

MPCComponents.ACADOSMPC carries its clocked state as an unscalarized array, so this is the case the PR description already flags as needing JuliaComputing/StateSelection.jl#161, which is still open. DiscreteComponents.DiscreteStateSpace with nx = 2 puts the same class within reach without MPCComponents; on the released StateSelection 1.11.1 it fails a little earlier, in build_explicit_observed_function:

using ModelingToolkit, DiscreteComponents
using ModelingToolkit: t_nounits as t, toggle_namespacing
A = [0.5 0.1; 0.0 0.4]; B = reshape([1.0, 0.0], 2, 1)
C = reshape([1.0, 0.0], 1, 2); Dm = reshape([0.0], 1, 1)
@named smp = Sampler()
@named clock = PeriodicClock(dt = 0.1)
@named sys = DiscreteStateSpace(; nx = 2, nu = 1, ny = 1, A, B, C, D = Dm)
@named m = System([connect(smp.y, sys.u[1], clock.y)], t, [], []; systems = [smp, clock, sys])
nns = toggle_namespacing(m, false)
linearize_hybrid(m, [nns.smp.u], [nns.sys.y[1]]; warn_missing_op = false)
# ArgumentError: Symbol (sys₊y(t))[1] is not present in the system
#   build_explicit_observed_function at codegen.jl:1851
#   _evaluate_outputs at linearization_hybrid.jl:888

I also built a second environment with StateSelection and ModelingToolkitTearing developed from the head of that branch (b5d706b) and retried the MPC models there. linearize_hybrid on FurutaMPCSwingup then gets past the point where it failed before, but does not come back: I killed it after 66 minutes (at the 41-minute mark it had consumed 32 minutes of CPU and held 3 GB resident), where the same call fails in six seconds on the released StateSelection. So I cannot say whether the MPC models linearize with #161 applied, only that on this model the array-shift scalarization makes the partition compilation intractable. The prediction model is a multibody QubePendulum over a horizon of 60 shooting intervals, so the scalarized array shift is not small; if it is useful I can reduce it to something between DiscreteStateSpace and the full MPC.

Two operating points that are easy to get wrong

Neither is a defect, but both cost me a while and a line of documentation would pay for itself.

An operating point that over-determines a partition is resolved silently. design_lqr in this package pins elbow_sampler.u => 0 and shoulder_sampler.u => 0 alongside elbow_joint.phi => pi, which it may because it opens the elbow_y/shoulder_y analysis points, so those two are free inputs. Passing the same dictionary to linearize_hybrid, where the loop is closed and the two are aliases of the plant angles, contradicts elbow_joint.phi => pi. The initialization of the continuous partition is then overdetermined, is solved by least squares, and the linearization is taken about x0 = [0, 0, 0, 0], the hanging equilibrium, rather than the upright one. The only indication is the generic Initialization system is overdetermined. 4 equations for 2 unknowns warning; the A matrix that comes back is that of a different equilibrium, and the loop assembled from it is unstable where the real loop is stable. Comparing partition.x0 with the requested operating point is what caught it. A sentence in the op documentation stating that an operating point which fixes a signal the closed model determines is resolved by least squares, and that x0 is worth reading back, would help.

linearize's operating point is not transferable. Related and simpler: an operating point written for linearize with loop_openings generally does not fit linearize_hybrid on the same model, because the loop openings change which signals are free.

Summary by model

Model Result
QubePendulum in a World equals linearize (finding 3 on the AD backend)
FurutaPredictionModel equals linearize (finding 3)
FrictionAndBackEMF, Energy equals linearize
SwingupCatch loop (FurutaSwingup less GoHome) correct, validated against the simulation
FurutaSwingup finding 1
FurutaHardware finding 1
FurutaFriction finding 2
FurutaIdentification finding 2
multirate loop with AlphaBetaGammaFilter and Latest correct three-partition split
FurutaMPCSwingup, FurutaMPCMultirateSwingup finding 5
FurutaMPCHardware, FurutaMPCMultirateHardware finding 5

@baggepinnen

Copy link
Copy Markdown
Contributor Author

Thank you for the sweep. The five findings fall into three groups.

1 and 2: array-valued history and array-valued outputs

Both reproduce only without JuliaComputing/StateSelection.jl#161. With the registry
StateSelection 1.11.1 the two reduced models fail exactly as reported (xₜ₋₁ not defined,
(y(t))[1] is not present in the system), and with the branch of #161 developed they
linearize (A = [0.0;;], C = [1.0;;] for the first, A = [0.8 -0.2; 0.1 0.5],
C = [0.1 0.5] for the second). The DiscreteStateSpace, MovingAverageFilter and
Multi-rate loop testsets of the downstream file pass in the same environment. The sweep
environment resolved ModelingToolkitTearing from the registry, which explains the 27 failures
in these two groups; the pull request description lists #161 as a prerequisite for models that
shift array variables as a whole.

3: a one-sample delay whose output is consumed

This is a bug in the alias elimination of discrete systems and is independent of
linearize_hybrid:

@parameters p
@variables u(t) y(t) z(t)
@named sys = System([u ~ p, y ~ u(k - 1), z ~ y], t)
ss = mtkcompile(sys)
equations(ss)   # Shift(t, 1)(y(t)) ~ yₜ₊₁(t)
observed(ss)    # Differential(t, 1)(y(t)) ~ p, z(t) ~ y(t), uₜ₋₁(t) ~ y(t)

The alias z ~ y puts u (whose shifted form u(k + 1) is a variable of the system) in the
same alias group as y(k + 1) and z(k + 1), and eliminate_perfect_aliases! has to give
the alias target a shifted form. It obtains it from StateSelection.var_derivative!, whose
method for TearingState in ModelingToolkitTearing always builds a Differential. The system
then carries Differential(t)(y), which the discrete reassembly does not recognize, and the
partition ends up with two unknowns and one equation, hence the length mismatch in
check_eqs_u0. With z ~ 2y the equation is not a perfect alias and the compile is correct,
which is why the case with the delayed signal unused works.

The fix is in ModelingToolkitTearing: var_derivative! builds Shift(iv, 1)(v) when the
structure is discrete. With it, the model compiles to the single unknown y with
Shift(t, 1)(y) ~ yₜ₊₁, yₜ₊₁ ~ p, z ~ y, uₜ₋₁ ~ y, and linearize_hybrid of the
reduced model gives A = [0.0;;], B = [1.0;;], C = [1.0;;], D = [0.0;;] for the output
z. The downstream file gains a testset with UnitDelay followed by a gain in a loop around
a first-order plant, whose discrete partition is -0.5 z⁻¹. I have the patch for
StateSelection (source change plus a regression test in the ModelingToolkitTearing tests)
ready and will open the pull request once you confirm; MTK's own discrete_system,
implicit_discrete_system and clock tests pass with it.

4: Boolean-valued variables

linearize_hybrid now checks the unknowns, inputs and outputs of every compiled partition
and raises an error naming the Boolean variables before anything is generated:

The clock partition on PeriodicClock(0.1, 0.0) has the Boolean variables sₜ₋₁(t).
Linearization is not defined with respect to Boolean variables.

This is an error rather than a warning under warn_unsupported because the partition cannot
be linearized with the variable present.

5: operating point from a solution

The values of a LinearizationOpPoint are now filtered by partition: the unknowns and
parameters of each compiled partition are looked up in the solution, and the values the
solution holds for observed variables are not used, since they repeat the constraints the
unknowns already satisfy. Values given explicitly in the op dictionary of the
LinearizationOpPoint are treated as before, so an observed variable can still be used to
specify the operating point by hand. I could not reproduce the overdetermined warning on the
model of the documentation section in my environment (the warning did not appear before the
change either), so the downstream testset now asserts with @test_nowarn that the solution
operating point produces no warning.

Smaller observations

A non-finite entry in any of A, B, C, D of a partition is now reported by a warning
that names the partition. The Quantization case returns a valid model and is left as is.

@baggepinnen

Copy link
Copy Markdown
Contributor Author

Thank you for the QuanserComponents sweep. The findings are addressed in the latest push, with
one of them fixed upstream.

1. A boundary signal used only under a shift

This is the alias-elimination bug of the DiscreteComponents comment (finding 3 there), now
fixed in JuliaComputing/StateSelection.jl#163. The discriminating case is not the shift but
the alias that the connection of the delay output creates: gain.u ~ delay.y with
delay.y ~ u(k - 1) puts u, whose shifted form is a variable of the partition, in an alias
group with delay.y(k + 1) and gain.u(k + 1), and var_derivative! built the shifted form of
the alias target as a Differential. With 1e-9 * u added the equation is no longer a perfect
alias, which is why that form and DiscreteDerivative and ExponentialFilter work. With #163
your Delay loop and FurutaSwingup linearize; on my machine FurutaSwingup at the upright
operating point with the package compile options gives a 4-state continuous partition and a
10-state 5 ms partition with five boundary connections.

2. A discrete partition with no inputs under AutoFiniteDiff

Fixed: B and D of a discrete partition without inputs are now built with zero columns and
no Jacobian is evaluated, as for the continuous partition. Covered by a test with both
backends.

3. AutoForwardDiff on the continuous partition of a multibody model

Your suspicion was right, and the mechanism is the following. The inline solution of linear
subsystems (inline_linear_sccs, part of MultibodyComponents.linsys) allocates its A and
b buffers from a DiffCache whose element type is taken from a reference value built from
the unknowns and first(inputs(sys)). In the standard path inputs(sys) holds the input
variables turned into parameters, so the reference carries the dual type when the input
Jacobian is evaluated. In the hybrid path the inputs are the parameters introduced by
_bind_inputs_to_parameters and inputs(sys) was empty, so the reference was Float64 while
the parameters carried duals, and fill_arr! on the Float64 buffer threw the MethodError.

_compile_partition! now registers the input and boundary parameters as the inputs of the
partition system (_mtkcompile! gains the keyword argument input_parameters), and the
initialization system accepts inputs that are time-independent parameters. On the QubePendulum
in a World with the package compile options, linearize_hybrid with
fallback_autodiff = nothing now succeeds and its A and B are identical to those of
linearize.

A more robust choice of the reference in ModelingToolkitTearing (any tunable parameter rather
than the first input) would also cover parameter Jacobians of such models outside
linearization; I left that alone here.

4. BoundsError in place of the input-derivative diagnostic

Fixed: LinearizationFunction stores its input symbols and the diagnostic names them from
there. For the plant without the multibody compile options the message now reads
"the following inputs appeared differentiated: [qp₊voltage_input]".

5. The MPC models

These need JuliaComputing/StateSelection.jl#161, as you note. The long compile you observed
with #161 applied is the scalarization of the whole-array shift of the MPC state (a multibody
prediction model over 60 shooting intervals), which happens in the partition compilation, not in
the linearization; a reduced model between DiscreteStateSpace and the full MPC would be
useful to profile it, but that belongs to #161 rather than to this PR.

The two operating points

Both are documented in the operating-point paragraph of the hybrid section: an operating point
that fixes a signal the closed model determines from the state is resolved in the
least-squares sense, and an operating point written for linearize with loop_openings does
not in general carry over. In addition, linearize_hybrid now compares x0 with the
requested values of the differential unknowns after the initialization of the continuous
partition and warns, naming the unknowns and both values, when they are not retained. On your
design_lqr dictionary this warning would have named elbow_joint.phi: requested π, obtained 0.0.

Redundant with the DiscreteComponents comment: finding 1 (same bug) and finding 5 (same
prerequisite).

@baggepinnen

Copy link
Copy Markdown
Contributor Author

Retried the whole QuanserComponents sweep on 3acd78a. Same environment as before, in two variants: one with the released StateSelection 1.11.1, one with JuliaComputing/StateSelection.jl#163 at 70685b78a9 and its ModelingToolkitTearing developed in. Everything is confirmed fixed, and the models that were blocked now linearize and give the right answers.

Findings 1 to 4

1, the boundary signal used only under a shift. Confirmed as an upstream bug and confirmed fixed by #163. The same script both ways:

case StateSelection 1.11.1 with #163
DiscreteComponents.UnitDelay (y ~ u(k-1)) ArgumentError: Equations (1), unknowns (2) OK
hand-written y ~ Shift(t,-1)(u) the same ArgumentError OK
y ~ Shift(t,-1)(u) + 1e-9*u OK OK
DiscreteDerivative, ExponentialFilter OK OK

With #163, FurutaSwingup linearizes and the split is the one you report: a 4-state continuous partition, a 10-state 5 ms partition and five boundary connections. The 10 discrete state variables are the two SwingupCatch velocity estimators, the GoHome estimator and its super-twisting integrator, and the three counters of the homing logic.

2, the discrete partition without inputs. Fixed. xd(k) ~ 0.5xd(k-1) linearizes with both backends now. FurutaHardware (10 states), FurutaFriction (8) and FurutaIdentification (2) all linearize; all three still take the AutoFiniteDiff fallback, correctly, since their programs call into C from inside the tick.

3, AutoForwardDiff on the continuous partition of a multibody model. Fixed, and the diagnosis of the DiffCache reference value matches what I saw. On the QubePendulum in a World at a non-equilibrium operating point, linearize_hybrid with fallback_autodiff = nothing now succeeds and

max|A_hyb - A_lin| = 0.0   max|B_hyb - B_lin| = 0.0
max|C_hyb - C_lin| = 0.0   max|D_hyb - D_lin| = 0.0
A identical bit for bit: true   B: true

where it previously fell back and carried 2.9e-8. The same holds one level up: the continuous partition of FurutaSwingup at the upright operating point is now bit-for-bit equal to named_ss(model, [u_plant], outs; loop_openings = [u_plant, shoulder_y, elbow_y]), against 6.3e-10 before.

4, the input-derivative diagnostic. Fixed; the message names the bound parameter:

Input derivatives appeared in expressions (-g_z\g_u != 0), the following inputs appeared
differentiated: [qp₊voltage_input]. Call `linearize` with keyword argument
allow_input_derivatives = true ...

One nit: raised from linearize_hybrid, the advice still says "Call linearize with keyword argument".

5, the MPC models. Unchanged and out of scope, as agreed: UndefVarError: control_system₊mpc₊xₜ₋₁ for all four, and DiscreteStateSpace with nx = 2 still stops at ArgumentError: Symbol (sys₊y(t))[1] is not present in the system. I will take the reduced model for profiling #161 to that PR.

The operating points. The new check does exactly what was needed. On design_lqr's dictionary:

Warning: The initialization of the continuous partition did not retain the requested values of
qubependulum₊elbow_joint₊phi(t): requested π, obtained 0.0. This happens when the operating
point also fixes signals the model determines from these unknowns. ...

That names the symbol and both values, and is what I had to reconstruct by hand from x0. The missing-value warning is equally useful on this model: it names in_runtime_delay₊y, gohome₊count_delay₊y and count_down₊u1, which are precisely the three homing-logic counters an operating point for the upright would not think to set.

New checks the fixes made possible

With FurutaSwingup linearizing, I could exercise two paths that were out of reach before.

Analysis points on the real model. linearize_hybrid(model, :u_plant, outs) gives nu_user = 1 on the continuous partition, and get_sensitivity(model, :u_plant; hybrid = true) returns a two-partition HybridLinearization.

Operating point from a solution of the swing-up run. Taking the 10 s swing-up of test/runtests.jl, which ends balancing at the top, and linearizing at LinearizationOpPoint(sol, 9.0):

  • cont.x0 equals sol(9.0) at the four plant unknowns exactly, [-0.005170221036755836, -3.139601977729154, -0.02340429273134204, -0.001861023949958888];
  • the continuous partition has an unstable pole at +13.64 rad/s, the inverted pendulum;
  • the sampled-data loop assembled from the two partitions has its plant-relevant poles at 0.988, 0.988, 0.958, 0.761, 0.761, 0.5, 0.5, all inside the unit circle, which is what the simulation is doing at that instant.

Two poles of that loop sit at exactly |z| = 1. They are not a defect and are worth knowing about when reading the pole set of such a model: their eigenvectors are supported on gohome₊count_delay₊y and gohome₊smc₊xiₜ₋₁ alone, the dwell counter and the super-twisting integrator of the homing branch, which the mode switch gates off at this operating point. Both are unobservable from the outputs, ‖Cv‖/‖v‖ = 0.0. A model with a gated integrator produces marginally stable decoupled modes in every partition linearization, which is correct.

The rest of the sweep

Unchanged and still right, now exactly rather than nearly:

  • the continuous-only models (QubePendulum in a World, FurutaPredictionModel with its nonlinear energy output, FrictionAndBackEMF, Energy) equal linearize with max|dA| = max|dB| = max|dC| = max|dD| = 0.0, where the first two were 1.4e-14 and 5.7e-14 before;
  • the sampled-data loop assembled from the two partitions of the SwingupCatch loop still reproduces the nonlinear simulation with the error vanishing with the perturbation (2.0e-2 relative at an initial deviation of 2e-2, down to 5.5e-6 at 3.1e-4);
  • the multirate arrangement of FurutaMPCMultirate without the MPC — AlphaBetaGammaFilter at 1 ms, two Latest crossings, a controller at 8 ms — still splits into three partitions with the right clocks and boundary index sets.

Summary by model on 3acd78a with #163:

Model Result
QubePendulum in a World, FurutaPredictionModel, FrictionAndBackEMF, Energy identical to linearize
FurutaSwingup correct; continuous partition identical to named_ss, analysis points and solution operating points work
SwingupCatch loop correct, validated against the simulation
FurutaHardware, FurutaFriction, FurutaIdentification correct
multirate loop with AlphaBetaGammaFilter and Latest correct
the four MPC models blocked on #161

@baggepinnen

Copy link
Copy Markdown
Contributor Author

Rerun of the full sweep on 7f1c2aac. Findings 1, 2, 4 and 5 are resolved, finding 3 remains and
your proposed fix for it is confirmed below.

First, my error on findings 1 and 2. Your diagnosis is exactly right and the cause is worth
recording, because it is easy to repeat: ModelingToolkitTearing is a subpackage of the
StateSelection monorepo, lib/ModelingToolkitTearing, and the change of
JuliaComputing/StateSelection.jl#161 is in that subpackage rather than in the top-level one. I had
Pkg.developed only ~/.julia/dev/StateSelection, so Pkg.status reported
StateSelection v1.11.1 ~/.julia/dev/StateSelection next to ModelingToolkitTearing v1.20.6 from
the registry, and the branch looked active while the part that matters was not. Developing
ss/lib/ModelingToolkitTearing as well makes both reduced models linearize, giving A = [0.0;;],
C = [1.0;;] and A = [0.8 -0.2; 0.1 0.5], C = [0.1 0.5], the values you quote. The 27 failures
of those two groups were mine, not the pull request's.

Corrected environment, used for everything below: Julia 1.13.0, ModelingToolkit at 7f1c2aac and
ModelingToolkitBase from its lib/, StateSelection and ModelingToolkitTearing both from
b5d706b of the shift-unscalarized-array-variables branch, SynchToolkit 0.5.1,
DiscreteComponents 0.5.0. Same 106 model instances as before.

The sweep on 7f1c2aac

94 of 101 linearize, against 67 in the mis-resolved environment of my previous comment. Of the
seven remaining failures, five are deliberate:

  • five are the Boolean models, each now rejected before code generation with the message naming
    the variables, for example
    The clock partition on SciMLBase.PeriodicClock(0.1, 0.0) has the Boolean variables d_on₊sₜ₋₁(t), d_onoff₊sₜ₋₁(t), d_off₊sₜ₋₁(t).
    That is finding 4 addressed, and an error rather than a warning is the right choice;
  • two are Examples.TestUnitDelay and Examples.AccumulatorDemo, finding 3.

The overdetermined-initialization warning of finding 5 is gone from all 18 models that emitted it;
a LinearizationOpPoint operating point is now as quiet as a dictionary one. The non-finite
warning fires exactly once in the sweep, on SuperTwistingSMC, which is the intended case. The
only other warning left is Initialization system is underdetermined. 0 equations for 1 unknowns
on the 14 closed loops when no operating point is given, which linearize emits the same way on
the corresponding continuous plant, so I am still not counting it.

The numerical checks are unchanged and now cover the array components as well: 13 open-loop
components reproduce the SynchToolkit simulation exactly, worst relative deviation 3.0e-16,
MovingAverageFilter, FIRFilter and DiscreteTransferFunction among them, and the four
sampled-data loops assembled with clock_boundary and feedback agree with the simulation to
below 4e-13.

The downstream file at 7f1c2aac gives Discrete PI controller loop 9/9, DiscreteStateSpace
10/10, MovingAverageFilter 3/3, Multi-rate loop 11/11, Operating point from a solution 4/4
and Analysis points 6/6. Only the new Unit delay whose output is consumed errors, with the
check_eqs_u0 length mismatch, which aborts the file; the counts above come from a run with that
testset removed.

Confirmation of the var_derivative! fix

I implemented the change from your description in my snapshot of
lib/ModelingToolkitTearing/src/stateselection_interface.jl, without looking at your patch, so
that the confirmation is independent:

    iv = MTKBase.get_iv(sys)
    D = StateSelection.is_only_discrete(s) ? Shift(iv, 1) : Differential(iv)
    push!(ts.fullvars, D(ts.fullvars[v]))

With it:

  • the reduced model of finding 3 compiles to the single unknown y(t) and
    linearize_hybrid returns A = [0.0;;], B = [1.0;;] for the discrete partition, the correct
    one-sample delay, matching what you state;
  • the whole downstream file passes, 7 testsets and 46 assertions, including
    Unit delay whose output is consumed;
  • the sweep goes from 94 to 96 of 101, the two UnitDelay harnesses joining the rest, and the
    only remaining failures are the five Boolean rejections. No model that linearized before stops
    doing so;
  • all 17 numerical comparisons are bit-identical to the run without the patch, so nothing that was
    already correct moved;
  • MTK's own test/clock.jl passes on the patched tearing (3 testsets, 11 assertions).

So yes, please open the StateSelection pull request. From here the change looks contained: the
discrete branch only replaces the operator used to name an alias target, and every continuous and
hybrid model in the sweep is unaffected.

One consequence worth stating plainly: until that pull request is merged and released, the
downstream file of this pull request cannot pass as it stands, since its new testset depends on
the fix. That is a sequencing question rather than a defect.

Remaining, for completeness

Nothing else in the package now fails for a reason attributable to this pull request. The five
instances excluded from the counts are still the DiscreteComponents defects listed in my previous
comment (Examples.TestZeroCrossingClock cannot be constructed, Examples.SimpleLQR,
Examples.TestClockPropagator and Examples.Integrator.IntegratorFEwInitialTest do not survive
mtkcompile); I am following those up on the DiscreteComponents side and they do not block
anything here.

@ChrisRackauckas

Copy link
Copy Markdown
Member

🤖 Automated review from an AI agent running as @ChrisRackauckas — not written or reviewed by Chris. It is posted so that you can act on it. Chris or a maintainer may disagree.

Verdict: changes needed before merge (reviewed at 3acd78a).

Adds linearize_hybrid, which linearizes each clock partition of a hybrid model separately and returns them with their boundary connections (plus clock_boundary, hybrid = true on the sensitivity functions, several ModelingToolkitBase fixes and a linearize no-inputs fix). It is still a draft and it adds two CI failures: its own new test fails in InterfaceII (reproduced locally), and the docs build fails because the new public names have no canonical docs entry.

Risk assessment

  • Risk: medium
  • Blast radius: linearize/linearization_function (now split into _linearization_function_compiled), LinearizationFunction (new inputs field), get_sensitivity/get_comp_sensitivity/get_looptransfer (new kwarg), _mtkcompile! (new input_parameters kwarg). ModelingToolkitBase behaviour changes affect every MTKBase user: distribute_shift_into_operator(::Operator) now defaults to false for all operators, find_all_parameter_equations treats operator terms that are parameters/unknowns as atomic, generate_initializesystem_timevarying accepts Sym inputs, InitializationProblem completes with allow_parameter_eqs = true. The ~1400-line src/linearization_hybrid.jl is new code that only affects callers of the new API.
  • Evidence:
    • CI on head 3acd78a compared with master (runs at 0459027, 2026-09-25):
      • New failures: tests / InterfaceII (julia 1) and (julia lts): 1540 pass, 1 fail, in the PR's own test/linearize_hybrid.jl:289. InterfaceII passes on master (Tests run https://github.com/SciML/ModelingToolkit.jl/actions/runs/36188481402 fails only QA and Optimization). build (Documentation) fails with :missing_docs for linearize_hybrid (both methods), HybridLinearization, ClockPartitionLinearization and clock_boundary, plus one linkcheck 403 on docs.sciml.ai/PDEBase (external, likely transient). Documentation passes on master (run 36188480908).
      • Also failing on master: tests / QA (julia 1): unapproved reexport ap_var (master fails the same way, plus canonical_constraints and constraint_residual). sublibrary-ci / lib/ModelingToolkitBase [QA]: JET, "234 possible errors found", the same count on master. NeuralPDE.jl/NNPDE2, SciMLBase.jl/SymbolicIndexingInterface and SciMLSensitivity.jl/Core8 fail on master's IntegrationTest run 36188480913 as well.
      • Runic and typos pass on the head.
    • Local runs (Julia 1.13.0; ModelingToolkitBase 1.75.0 from lib/, ModelingToolkitTearing 1.20.6, StateSelection 1.11.1, SymbolicUtils 4.48.0, Symbolics 7.41.1):
      • GROUP=InterfaceII julia +1.13 --project -e 'using Pkg; Pkg.test()' on the head gives 1540 passed, 1 failed, 7 broken. This matches CI exactly. The failing test is Hybrid Linearization Tests / Operating point / Requested values of the state: @test_nowarn linearize_hybrid(syso, [uo], [yo]; op = Dict(xo => 1.0, yo => 2.0, uo => 0.0)), which emits Initialization system is overdetermined. 1 equations for 0 unknowns ... least squares from src/initialization.jl:22. All the other hybrid testsets pass, and so do the new tests in linearize.jl.
      • I extracted the bug-fix testset "Linearization without inputs" and ran it on both sides. On the head it passes 12/12. On the base (fe32815) it errors with Invalid Tag object: Expected ForwardDiff.Tag{ModelingToolkit.PFFun{...}} at src/linearization.jl:1103. So the test fails before the fix and passes after it.
      • typos over all 16 changed files: no findings.
      • I did not run the downstream file test/downstream/linearize_hybrid_components.jl. It needs DiscreteComponents and SynchToolkit (DyadRegistry only) and the open Shift unscalarized array variables with their scalarized elements JuliaComputing/StateSelection.jl#161. It is not registered in any test group, so CI never runs it.
  • Merge: needs changes (the failing new test, the missing canonical docs entries, and the draft state with its unreleased StateSelection prerequisites; see Findings)

Findings

  1. test/linearize_hybrid.jl:289: the new test fails in CI on both Julia 1 and lts, and I reproduced the failure locally. The test and the PR description contradict each other:

    • The test comment says an operating point that "fixes the output consistently retains the state", with no warning.
    • The PR body says such an operating point "makes the initialization of the continuous partition overdetermined; it is then solved in the least-squares sense".
    • The code does emit the overdetermined-initialization warning.

    Either drop operating-point entries for observed variables that are consistent with the state before building the initialization problem (so the test holds), or change the test to expect the warning. A test that fails on the author's own feature cannot merge. The author's comment reporting "148 assertions, not one warning" was for 10af7f8, not for this head.

  2. docs/src/basics/Linearization.md:387-397: the five new public names only appear in a @docs; canonical = false block, so Documenter fails with :missing_docs. Add them to the canonical @docs block in docs/src/API/problems.md, next to linearize and ModelingToolkit.LinearizationOpPoint at lines 147-148.

  3. The PR is still a draft, and its full functionality depends on unreleased upstream work:

    The Project.toml compat still allows ModelingToolkitTearing 1.19.2 and StateSelection 1.10.5. The downstream test file cannot pass until ODESystem auto-parse is duplicating values #163 is released, and its DiscreteStateSpace cases also need Fix Wfact #161. After those releases, raise the compat floors.

  4. The MTK side (src/linearization_hybrid.jl, _linearization_function_compiled) relies on the ModelingToolkitBase changes in this PR: Sym inputs in initializesystem.jl, atomic operator terms in find_all_parameter_equations, and allow_parameter_eqs in InitializationProblem. The MTK floor ModelingToolkitBase = "1.75" would let a registered MTK resolve to an MTKBase without these changes. Releasing this needs an MTKBase version bump and a raised MTK floor, in this PR or in the repo's usual separate release commit. The PR branch is also behind master, which has since bumped MTKBase to 1.76.0.

  5. lib/ModelingToolkitBase/src/discretedomain.jl:325: distribute_shift_into_operator(::Operator) = false changes the default for every operator type, not only SynchToolkit's Latest. Any third-party operator that relied on shifts being distributed into it now behaves differently. This is a behaviour change to a shared sublibrary that ships inside a large feature PR. It belongs in its own small PR with a regression test in lib/ModelingToolkitBase/test (the PR adds none there). The same applies to the find_all_parameter_equations and InitializationProblem fixes.

  6. Scope: this is one PR with +2785/-33 lines. Besides the new feature, it contains three independent MTKBase fixes, the standalone linearize(sys, [], outputs) ForwardDiff tag fix with its test, and the input-derivative error-message fix (which indexed inputs(sys)). By Chris's small-focused-PRs rule, the bug fixes (items 4-5 and the LinearizationFunction fixes) should land first and separately. That would make the feature PR reviewable on its own.

  7. test/qa/qa.jl:308-316: five more non-public ModelingToolkitBase names are added to NONPUBLIC_QUALIFIED_ACCESSES (getunshifted, process_SciMLProblem, scalarized_vars, split_indexed_var, unwrap_vars). The new file also depends heavily on ModelingToolkitTearing internals (ClockInference, infer_clocks!, split_system, scalarize_tearing_state_eqs!), which were already on the allowlist. These are sibling packages in the same family, and the repo already does this, so it is not a blocker. Still, the new public API now rests on non-public MTKTearing internals, which constrains future MTKTearing changes. Consider marking those names @public upstream.

  8. Review state: no maintainer other than the author has reviewed it. The inline review threads, and the long "review" and "response" comments, are all by baggepinnen (an AI-assisted self-review loop). Chris has not commented.

  9. Checks that pass:

    • No skipped, loosened or deleted tests. The 7 broken tests in InterfaceII are pre-existing.
    • No new runtime dependencies. ControlSystemsBase is added only to test/downstream/Project.toml.
    • Runic passes, and new public names have docstrings.
    • Comment density in the new file is about 5%.
    • Adding exports only is a minor-version change, which is correct for new API.

Push a fix and the PR is reviewed again automatically at the new head.


🤖 Posted by an AI agent — harness: Claude Code · model: claude-opus-5-5[1m] (fleet master); review by Devin CLI (Mac) / fusion-claude-opus-5-5-high-sidekick-swe-2-medium
Conversation: local Claude Code session 3cd6500a-1f81-46b5-ac0b-c466e15b6a53 on Chris's Mac (session ID, no URL)

@baggepinnen

Copy link
Copy Markdown
Contributor Author

This is a draft pr for a reason, can you please stop the bot spam?

@ChrisRackauckas

Copy link
Copy Markdown
Member

A review was ran on every PR of the month, it's no big deal and just one posting? Every PR needs a risk assessment and your posts didn't have one.

baggepinnen and others added 8 commits September 29, 2026 10:12
…stems

`linearize` accounts for the continuous equations only, so a model with a
synchronous controller linearizes to the open-loop plant. `linearize_hybrid`
splits the model into its clock partitions with the clock inference used by
`mtkcompile`, compiles every partition with the standard structural
simplification and differentiates the generated functions of each partition
numerically (ForwardDiff through DifferentiationInterface, with an automatic
`AutoFiniteDiff` retry). The result holds one linear system per partition with
its state, input and output variables and sample interval, and the connections
between partitions across clock boundaries, so that the sampled-data loop can
be assembled with `ControlSystemsBase.feedback` or
`RobustAndOptimalControl.connect`.

User inputs are bound to time-independent parameters before clock inference.
Boundary terms (`Sample`, `Hold`, clock changes and `SampleTime`) become plain
parameters of their partition. `get_sensitivity`, `get_comp_sensitivity` and
`get_looptransfer` accept `hybrid = true`, and `linearize_hybrid` accepts
analysis points. `linearization_function` is split into the compilation step
and `_linearization_function_compiled` without behaviour change.

ModelingToolkitBase: `find_all_parameter_equations` treats operator terms that
are unknowns or parameters of the system as atomic symbols, the incomplete
initialization branch of `InitializationProblem` allows the parameter-only
equations it appends, and shifts are no longer distributed into operators other
than `Shift` and `Differential`.

Closes #5166.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBgBrXDidWG1G2KF64gAte
Partition systems no longer inherit the assertions of the model, keep only
the parameters they use together with their boundary and input parameters,
and keep the initial values of their unknowns and parameters only. Values of
signals crossing a clock boundary that the operating point does not provide
are evaluated from the partition they originate from, so an operating point
only needs the state variables; operating-point values of observed variables
that are expressions of parameters only are not turned into initialization
constraints. The finite-difference fallback is limited to errors of automatic
differentiation, the preprocessing mirrors `mtkcompile`, and `show` reports
the state dimension.

The downstream test file is not registered in the test groups, since its
dependencies are registered in DyadRegistry only, and the non-public
ModelingToolkitBase names reached from the new file are listed in the QA
allowlist.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBgBrXDidWG1G2KF64gAte
The partition systems cleared the initialization equations of the model, and the
initialization problem of the continuous partition was built with `algebraic_only = true`,
which excludes initialization equations. Parameters bound to `missing` and constant states
(`der(x) = 0`, compiled to a parameter) that the model determines through initialization
equations were therefore left undetermined: the underdetermined initialization problem
returned guessed values, or failed with `Could not evaluate value of parameter` when the
structurally singular problem dropped the undetermined symbols to parameters.

`_compile_partition!` now keeps the initialization equations whose symbols are all known to
the partition, and keeps the parameters they refer to. Before the continuous partition is
linearized, the equations that determine at least one unknown or `missing`-bound parameter
whose value the operating point does not fix are passed to the initialization problem
through a new `initialization_eqs` keyword of `_linearization_function_compiled`; observed
variables are expanded to the symbols they depend on for this selection. An operating-point
value takes precedence over such an equation. For a discrete partition, equations of the form
`x(k-1) ~ expr` and `x ~ expr` provide the history values. Parameters bound to `missing` that
nothing determines take their guess, or zero with the missing-value warning.

`LinearizationFunction` no longer differentiates with respect to an empty input vector, so
`linearize(sys, [], outputs)` works and returns `B` and `D` with zero columns.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBgBrXDidWG1G2KF64gAte
`clock_boundary(hl, from, to)` collects the connections from one partition of a
`HybridLinearization` to another as the index vectors `outputs` (into the outputs of the
partition the signals leave) and `inputs` (into the inputs of the partition they enter),
which are the index sets required by the advanced interface of `ControlSystemsBase.feedback`.
The documentation and the tests assemble the sampled-data loops from these index sets instead
of hard-coded indices or filtered connection lists, and the documentation states the relation
to `starprod` and `lft`.

The documentation also describes how to check whether the signals entering a sampler carry
content above the Nyquist frequency of the sampler: the gain from all inputs of the
continuous partition, the external inputs and the held signals, to the sampled outputs is
evaluated above the Nyquist frequency and compared with the gain below it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBgBrXDidWG1G2KF64gAte
For a signal leaving a discrete partition for a partition on a slower clock, the gain of the
fast partition alone in the band between the two Nyquist frequencies is a sufficient but
conservative check, since it ignores the attenuation of the continuous partition. The
documentation now describes the check on the composite of the continuous partition,
discretized at the fast rate, and the fast partition, from all inputs of the composite,
including the held outputs of the slow partition, to the boundary signal, with a multi-rate
example.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBgBrXDidWG1G2KF64gAte
…tries in `linearize_hybrid`

A partition whose unknowns, inputs or outputs are Boolean-valued now produces an error
naming the variables instead of a `TypeError` raised from the generated code. An operating
point given as a `LinearizationOpPoint` provides the values of the unknowns and parameters
of each partition only; the values a solution holds for observed variables repeat the
constraints the unknowns satisfy and made the initialization of the continuous partition
overdetermined. Non-finite entries in the matrices of a partition, which arise when an
equation is not differentiable at the operating point, are reported by a warning.

The downstream tests gain a loop with a `UnitDelay` whose output is consumed. Compiling its
discrete partition requires `StateSelection.var_derivative!` to build the shifted form of an
alias target as a `Shift` for discrete systems, a fix in ModelingToolkitTearing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBgBrXDidWG1G2KF64gAte
…t-free discrete partitions

The inline solution of linear subsystems derives the element type of its buffers from the
unknowns and the first registered input of the system. The input and boundary parameters of
a clock partition were not registered, so the buffers stayed `Float64` when only the
parameters carried dual numbers and the Jacobians with respect to the inputs of a multibody
plant fell back to finite differences. `_mtkcompile!` gains the keyword argument
`input_parameters`, parameters that represent inputs and are registered as such, and the
initialization system accepts inputs that are time-independent parameters.

`LinearizationFunction` stores its input symbols, so that the input-derivative diagnostic
names them also when the inputs are parameters rather than variables marked as inputs.
Discrete partitions without inputs no longer evaluate Jacobians with respect to an empty
input vector, which finite differences reject. A warning reports when the initialization of
the continuous partition does not retain the requested value of a differential unknown, which
happens when the operating point also fixes signals the model determines from the state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBgBrXDidWG1G2KF64gAte
…entries and drop two unneeded changes

An operating point that fixes the output of the continuous partition alongside its state makes
the initialization overdetermined, which ModelingToolkit reports; the test expected no warning,
which the daemon-based test runs could not verify since they forward stdout only. The new public
names are added to the canonical `@docs` block of the API reference, which the documentation
build requires.

The changes to `find_all_parameter_equations` and to the incomplete-initialization branch of
`InitializationProblem` are removed: they were needed when the boundary terms of a partition
were parameters of the partition system, and are not since those terms are replaced by plain
parameters. The hybrid and downstream tests pass without them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBgBrXDidWG1G2KF64gAte
@baggepinnen

Copy link
Copy Markdown
Contributor Author

Addressed at the new head, which is rebased onto master.

  1. Failing test. The test was wrong; the PR description was right. An operating point that fixes the output alongside the state makes the initialization overdetermined and ModelingToolkit reports it, also when the value is consistent. The test now expects the warning and checks that the consistent value retains the state, and a state-only operating point is checked to produce no warning. The earlier local runs went through a daemon that forwards stdout only, which is why @test_nowarn could not fail there; the @test_nowarn on the solution operating point in the downstream file was re-checked through the logger and holds.

  2. Docs. The five names are added to the canonical @docs block in docs/src/API/problems.md.

  3. Prerequisites. Unchanged: Shift unscalarized array variables with their scalarized elements JuliaComputing/StateSelection.jl#161 is open and ODESystem auto-parse is duplicating values #163 is merged but unreleased. The PR stays a draft until both are released, and the ModelingToolkitTearing and StateSelection floors are raised then.

  4. and 5. Scope of the sublibrary changes. The bug fixes are split into their own pull requests, each with a regression test that fails on master:

    The changes to find_all_parameter_equations and to the incomplete-initialization branch of InitializationProblem are dropped: they were needed when the boundary terms of a partition were parameters of the partition system, which is no longer the case since those terms are replaced by plain parameters, and the hybrid and downstream tests pass without them. This branch carries copies of the three remaining fixes until the pull requests are merged, after which it is rebased and the copies disappear; the ModelingToolkitBase release and the raised floor belong to that step.

  5. Size. After the split and the removal, the ModelingToolkitBase part of this PR consists of the two one-hunk changes above.

  6. Non-public accesses. Left as is, as noted; marking the ModelingToolkitTearing names @public is a separate change in that repository.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QBgBrXDidWG1G2KF64gAte

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.

Linearizing a model that contains discrete callbacks (synchronous / clocked subsystems)

2 participants