Skip to content

Commit 82402f8

Browse files
baggepinnenclaude
andauthored
add LPV estimation method (#197)
* add LPV estimation method * use simplot instead of pred * fix: wrap LPV example simulation loops in let-blocks Documenter evaluates @example blocks at module top-level, where the soft-scope rule makes `x = ... * x + ...` inside a for-loop create a new uninitialized local that shadows the outer `x = zeros(2)`. Wrapping each simulation in a let-block gives the loop hard scope and unblocks the docs build. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * draft multi-dataset support for lpv_pem; mark LPV as experimental * Refactor the loss into _lpv_dataset_predloss so both single- and multi-dataset paths share the inner LTV timestepping loop. * New lpv_pem(ds, λs, nx; ...) and lpv_warmstart(ds, λs, nx; ...) methods that jointly fit θ and K across a vector of experiments while keeping a per-experiment initial state x0::Matrix of shape (nx, length(ds)). * Single-dataset methods now delegate to the multi-dataset implementation and unwrap x0 to a Vector — backward-compatible. * Validate matching Ts/nu/ny across datasets (same as arx, era). * Add test_lpv "LPV PEM multi-dataset" verifying joint fit, x0 shape, per-dataset prediction MSE, and joint-beats-single comparison. * Mark LPVStateSpace, lpv_pem, lpv_warmstart, and the docs example with !!! warning "Experimental" admonitions matching the existing style used for nonlinear_pem in src/pem.jl:633. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: keep lpv_pem docstring adjacent to the function A narrative comment between the docstring closing `"""` and the `function lpv_pem(...)` definition broke the docstring binding so Documenter could not resolve [`lpv_pem`](@ref) anywhere. Drop the comment. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * perf improvements --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b1564cd commit 82402f8

6 files changed

Lines changed: 991 additions & 0 deletions

File tree

‎docs/make.jl‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ makedocs(
4848
"Hair dryer" => "examples/hair_dryer.md",
4949
"VARX model" => "examples/varx.md",
5050
"Nonlinear belt drive" => "examples/hammerstein_wiener.md",
51+
"LPV identification" => "examples/lpv.md",
5152
"Fit parameters of ModelingToolkit model" => "examples/modelingtoolkit.md",
5253
],
5354
"API" => "api.md",

‎docs/src/examples/lpv.md‎

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
# Linear Parameter-Varying (LPV) identification
2+
3+
!!! warning "Experimental"
4+
LPV identification in this package is considered experimental and may
5+
change in the future without respecting semantic versioning.
6+
7+
This example shows how to identify a Linear Parameter-Varying (LPV)
8+
state-space model using [`lpv_pem`](@ref). The model has matrices that depend
9+
on a *known, measured* scalar scheduling variable ``\lambda(t)``:
10+
11+
```math
12+
\begin{aligned}
13+
A(\lambda) &= \sum_{k} \theta^A_k\, \varphi_k(\lambda),\\
14+
B(\lambda) &= \sum_{k} \theta^B_k\, \varphi_k(\lambda),\\
15+
C(\lambda) &= \sum_{k} \theta^C_k\, \varphi_k(\lambda),\\
16+
D(\lambda) &= \sum_{k} \theta^D_k\, \varphi_k(\lambda),
17+
\end{aligned}
18+
```
19+
20+
where ``\{\varphi_k\}`` is a user-supplied basis in ``\lambda``. ``\lambda`` may
21+
or may not coincide with a state variable of the system; the only requirement is that it
22+
is measured at every time step. There is a single shared state-space realization
23+
whose entries vary smoothly with ``\lambda``.
24+
25+
## Generating data
26+
27+
We simulate a known affine-in-``\lambda`` system,
28+
``A(\lambda) = A_0 + \lambda A_1``, ``B(\lambda) = B_0 + \lambda B_1``,
29+
driven by white-noise input. The scheduling variable is a slow sinusoid.
30+
31+
```@example lpv
32+
using ControlSystemIdentification, ControlSystemsBase, Plots, Random, LinearAlgebra, Statistics
33+
Random.seed!(0)
34+
35+
Ts = 0.05
36+
T = 2000
37+
38+
A0 = [0.85 0.10; -0.05 0.80]
39+
A1 = [0.05 0.00; 0.10 -0.05]
40+
B0 = [0.10; 0.20;;]
41+
B1 = [0.00; 0.05;;]
42+
C = [1.0 0.0]
43+
44+
A(λ) = A0 .+ λ .* A1
45+
B(λ) = B0 .+ λ .* B1
46+
47+
λ = 0.5 .* sin.(0.01 .* (1:T))
48+
u = randn(1, T)
49+
50+
y = let
51+
x = zeros(2)
52+
y = zeros(1, T)
53+
for t in 1:T
54+
y[:, t] = C * x
55+
x = A(λ[t]) * x + B(λ[t]) * u[:, t]
56+
end
57+
y
58+
end
59+
y .+= 0.01 .* randn(size(y))
60+
d = iddata(y, u, Ts)
61+
62+
plot(
63+
plot(timevec(d), λ; xlabel="t [s]", ylabel="λ(t)", lab="scheduling"),
64+
plot(d);
65+
layout = (2, 1), size=(700, 500),
66+
)
67+
```
68+
69+
## Fitting an LPV model
70+
71+
We choose an affine basis in ``\lambda``, ``\varphi_1(\lambda) = 1``,
72+
``\varphi_2(\lambda) = \lambda``, and estimate the parameters with [`lpv_pem`](@ref).
73+
`lpv_pem` warm-starts from a sliding-window subspace fit (see
74+
[`lpv_warmstart`](@ref)) when no initial guess is given.
75+
76+
```@example lpv
77+
basis = [λ -> 1.0, λ -> λ]
78+
res = lpv_pem(d, λ, 2; basis, show_trace = false, iterations = 200)
79+
sys_lpv, x0h, _ = res
80+
```
81+
82+
The returned `sys_lpv` is an [`LPVStateSpace`](@ref). It is callable: passing a
83+
scheduling value freezes the matrices and returns a plain `StateSpace`.
84+
85+
```@example lpv
86+
sys_lpv(0.0)
87+
```
88+
89+
## Validation
90+
91+
We compare simulation performance against ground truth on the full trajectory and
92+
against a single LTI fit by [`subspaceid`](@ref). As with `predplot` for the
93+
LPV case, passing the schedule `λ` as the third positional argument to
94+
[`simplot`](@ref) is enough for it to route through the LPV one-step
95+
predictor. Each trace is annotated with its NRMSE fit percentage.
96+
97+
```@example lpv
98+
sys_lti = subspaceid(d, 2)
99+
100+
simplot(sys_lpv, d, λ; sysname = "LPV")
101+
simplot!(sys_lti, d; sysname = "LTI", ploty = false)
102+
```
103+
104+
On varying-``\lambda`` data the LPV model is strictly better than any single
105+
LTI fit, because the LTI model cannot match the changing dynamics.
106+
107+
## A practical example: semi-active suspension
108+
109+
A common real-world use of LPV identification is **semi-active vibration
110+
isolation**: a mass-spring-damper where the damping coefficient is set by a
111+
controllable voltage. Magneto-rheological shock absorbers on a vehicle
112+
suspension and active engine mounts are typical instances. The damper voltage
113+
is sampled at every time step, so it is a known scheduling variable that
114+
enters the system matrix directly.
115+
116+
The continuous-time 1-DOF model is
117+
118+
```math
119+
m\ddot{x} + c(\lambda)\dot{x} + k x = u,\qquad
120+
c(\lambda) = c_0 + c_1\lambda,\qquad y = x,
121+
```
122+
123+
with state ``[x,\ \dot{x}]^\top``. In state-space form the only λ-dependence
124+
sits in the ``(2,2)`` entry of ``A``, which makes this a textbook
125+
affine-in-λ LPV system. With the parameters used below the damping ratio sweeps
126+
from ``\zeta \approx 0.05`` (sharp resonance) to ``\zeta \approx 0.3``
127+
(well-damped) as ``\lambda`` is varied, easily distinguishable in the data.
128+
129+
### Simulating a damper-sweep experiment
130+
131+
We simulate a 30 s experiment where the operator slowly sweeps the damper
132+
voltage while exciting the mass with broadband noise. The damper voltage is
133+
*measured*, and is used as the scheduling variable for identification.
134+
135+
```@example lpv2
136+
using ControlSystemIdentification, ControlSystemsBase, Plots, Random, LinearAlgebra, Statistics
137+
Random.seed!(0)
138+
139+
# Physical parameters
140+
m, k = 1.0, 100.0 # mass [kg], stiffness [N/m]
141+
c0, c1 = 1.0, 5.0 # baseline + per-volt damping
142+
143+
Ts = 0.01
144+
T_total = 30.0
145+
N = Int(T_total / Ts)
146+
t = range(0, step = Ts, length = N)
147+
148+
# Damper voltage trajectory (the scheduling variable)
149+
λ = 0.5 .+ 0.4 .* sin.(2π .* 0.05 .* t) # roughly [0.1, 0.9]
150+
151+
# Continuous-time model as a function of λ
152+
Ac(λ) = [0.0 1.0; -k/m -(c0 + c1*λ)/m]
153+
Bc = [0.0; 1/m;;]
154+
Cc = [1.0 0.0]
155+
Dc = zeros(1, 1)
156+
157+
u = reshape(randn(N), 1, N) # broadband excitation force
158+
159+
# Simulate by re-discretizing the LTV model at every sample (ZOH on u)
160+
y_clean = let
161+
x = zeros(2)
162+
y_clean = zeros(1, N)
163+
for n in 1:N
164+
y_clean[:, n] = Cc * x
165+
sys_n = c2d(ss(Ac(λ[n]), Bc, Cc, Dc), Ts)
166+
x = sys_n.A * x + sys_n.B * u[:, n]
167+
end
168+
y_clean
169+
end
170+
y = y_clean .+ 1e-3 .* randn(size(y_clean))
171+
d = iddata(y, u, Ts)
172+
173+
plot(
174+
plot(t, λ; ylabel = "λ(t) [V]", lab = "damper voltage"),
175+
plot(t, vec(y); ylabel = "y(t) [m]", lab = "measured position");
176+
layout = (2, 1), xlabel = "t [s]", size = (700, 480),
177+
)
178+
```
179+
180+
### Identifying the LPV model
181+
182+
With an affine basis ``\{1,\lambda\}`` we recover the operating-point
183+
dependence directly. The warm-start (subspace ID + modal-form
184+
coefficient regression) is what makes this work despite never having a
185+
constant-``\lambda`` segment of data:
186+
187+
```@example lpv2
188+
basis_susp = [λ -> 1.0, λ -> λ]
189+
res2 = lpv_pem(d, λ, 2; basis = basis_susp,
190+
K0 = 1e-6 .* ones(2, 1),
191+
show_trace = false, iterations = 300)
192+
sys_susp, x0_susp, _ = res2
193+
```
194+
195+
### Validation: LPV vs single LTI fit
196+
197+
A single LTI fit has to compromise between the under- and over-damped regimes
198+
the experiment sweeps through, while the LPV model does not. We use
199+
[`simplot`](@ref) to overlay simulation against measurement and annotate each
200+
trace with its NRMSE fit percentage. Passing the schedule `λ` as the third
201+
positional argument to `simplot` is enough for the recipe to route it through
202+
the LPV simulator.
203+
204+
```@example lpv2
205+
sys_lti2 = subspaceid(d, 2)
206+
207+
simplot(sys_susp, d, λ; sysname = "LPV")
208+
simplot!(sys_lti2, d; sysname = "LTI", ploty=false)
209+
```
210+
211+
### Frequency response at frozen operating points
212+
213+
Because [`LPVStateSpace`](@ref) is callable, freezing the identified model at a
214+
particular ``\lambda`` returns an ordinary `StateSpace`. This is exactly the
215+
artifact a controller designer would feed into a gain-scheduled controller
216+
synthesis on top of the identified plant.
217+
218+
```@example lpv2
219+
truth(λ) = c2d(ss(Ac(λ), Bc, Cc, Dc), Ts)
220+
w = exp10.(range(-0.5, log10(π / Ts); length = 300))
221+
222+
p = bodeplot(truth(0.1), w; plotphase = false, lab = "truth λ=0.1")
223+
bodeplot!(p, sys_susp(0.1), w; plotphase = false, lab = "LPV λ=0.1", ls = :dash)
224+
bodeplot!(p, truth(0.9), w; plotphase = false, lab = "truth λ=0.9")
225+
bodeplot!(p, sys_susp(0.9), w; plotphase = false, lab = "LPV λ=0.9", ls = :dash)
226+
p
227+
```
228+
229+
The resonance peak collapses by more than 15 dB between ``\lambda=0.1`` and
230+
``\lambda=0.9``, and the LPV fit tracks it at both endpoints.
231+
232+
## Notes
233+
234+
- The basis can be any user-supplied set of functions of ``\lambda``: polynomial,
235+
radial-basis-function, piecewise-linear, etc. For mild dependence (as in the
236+
suspension example) an affine basis suffices; stronger nonlinearity in
237+
``\lambda`` is well-served by a polynomial or RBF basis.
238+
- Multi-dimensional scheduling is not yet documented; the basis API technically
239+
accepts vector ``\lambda`` if the user writes a multivariate basis function,
240+
but this path has not been thoroughly tested.
241+
- The Kalman gain ``K`` is currently held constant in ``\lambda``.

‎src/ControlSystemIdentification.jl‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export iddata,
4747

4848
export AbstractPredictionStateSpace, PredictionStateSpace, N4SIDStateSpace,
4949
pem, newpem, structured_pem, prediction_error, prediction_error_filter, predictiondata, predict, simulate, noise_model, estimate_x0
50+
export LPVStateSpace, lpv_pem, lpv_warmstart
5051
export n4sid, subspaceid, era, okid, find_similarity_transform, schur_stab
5152
export getARXregressor,
5253
getARregressor,
@@ -87,6 +88,7 @@ include("subspace2.jl")
8788
include("spectrogram.jl")
8889
include("frequency_weights.jl")
8990
include("basis_functions.jl")
91+
include("lpv.jl")
9092
include("plotting.jl")
9193
include("input_signals.jl")
9294

0 commit comments

Comments
 (0)