Skip to content

Commit e199e36

Browse files
authored
Clean up extended_controller: canonical 2-DOF, direct kwarg, validation (#140)
* Clean up extended_controller: canonical 2-DOF B1, direct kwarg, validation - Delete the unverified single-argument `extended_controller(K)` overload — it had no callers anywhere and only logged `@error` when invoked. - Make `extended_controller(P, L, K)` produce the canonical 2-DOF observer controller. With the old `B1 = 0`, the observer estimate `x̂` was biased when `xᵣ ≠ 0`; the new `B1 = (B − KD)·L` lets the reference enter the observer dynamics so `x̂` tracks `x`. For integrator-containing plants (e.g. the cartpole test), this also makes the closed-loop reference-to- output DC gain identity, so the manually-tuned `dc_gain_compensation` prefilter in `test_lqg.jl` is no longer needed; the related tests now assert `dcgain ≈ 1` directly. - Add a `direct::Bool=false` kwarg that mirrors `observer_controller`'s current-time-correction option for discrete plants. In direct mode the reference path remains feedforward-only (no canonical 2-DOF B1); the docstring records this. - Validate `size(L)` and `size(K)` at entry with a clean `ArgumentError`. - Drop the stale `# should be D21?` self-questioning comment and the misplaced `# l.D21 does not appear here, see comment in kalman` line (the latter only applied to the `LQGProblem` path). - Add a comment near the `feedback` call in the `z`-branch recording the default `pos_feedback=false` convention and the meaning of the `U2` slice. - New tests: discrete-time round-trip (Method 7), `direct=true` round-trip (Method 8), and dimension validation. - Docs: append a "2-DOF tracking with extended_controller" section to `docs/src/lqg_disturbance.md` demonstrating the controller and the DC-gain pre-compensation via `extended_controller(..., z=[...])`. DyadControlSystems was audited and has no `extended_controller` callers, so this is safe to ship without a deprecation path. * improve docs example
1 parent b226e0d commit e199e36

3 files changed

Lines changed: 113 additions & 41 deletions

File tree

docs/src/lqg_disturbance.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,35 @@ plot(res, ylabel = ["y" "u"]); ylims!((-0.05, 0.3), sp = 1)
150150
```
151151

152152
The control signal again settles on `-1`, exactly counteracting the load disturbance. Compared to the disturbance-model design, the LQI formulation lets us tune the strength of the integral action directly through the augmented-state cost block in `Q1`, rather than indirectly through the disturbance-state noise covariance in `R1`.
153+
154+
155+
## 2-DOF tracking with [`extended_controller`](@ref)
156+
157+
The reference-signal response of an LQG controller can be improved without adding integral action by proper reference feedforward. [`extended_controller`](@ref) returns an [`ExtendedStateSpace`](@ref) controller with inputs `[xᵣ; y]` and output `u`. The reference enters the observer dynamics through `B1 = (B − KD)·L`, so the observer estimate `` tracks the true state `x` even when `xᵣ ≠ 0` (in contrast to a pure feedforward path that bypasses the observer).
158+
159+
We re-use the original plant `G` and build a fresh LQG problem (no disturbance model needed here):
160+
161+
```@example LQG_DIST
162+
Q1 = 100I(G.nx)
163+
Q2 = 0.01I(G.nu)
164+
R1 = 0.001I(G.nx)
165+
R2 = I(G.ny)
166+
prob_2dof = LQGProblem(G, Q1, Q2, R1, R2)
167+
168+
# z=[1] returns the closed-loop transfer function from xᵣ to plant output 1 as a
169+
# second return value, useful for computing DC-gain compensation.
170+
Ce, cl_xr_to_y = extended_controller(prob_2dof, z=[1])
171+
```
172+
173+
The closed-loop DC gain from state reference to output is not unity in general — for `xᵣ = x_ss` to hold, the plant would need to contain an integrator. For this stable first-order plant we use the second return value to compute a static pre-compensation:
174+
175+
```@example LQG_DIST
176+
gain_comp = inv(dcgain(cl_xr_to_y)[1])
177+
res = lsim(gain_comp * cl_xr_to_y, (x,t) -> min(t/10, 1), 20)
178+
@test res.y[end] ≈ 1 atol=1e-2
179+
plot(res, ylabel="y")
180+
```
181+
182+
The step response settles on `1`, confirming that the pre-compensated 2-DOF controller tracks unit references at DC. For plants with an integrator (e.g., a cart-position channel from a velocity-controlled actuator) `dcgain(cl_xr_to_y)` is already `1` and no compensation is needed.
183+
184+
Note, without the integral action in the controller, any error in the DC gain of the model would still lead to a steady-state error in the reference-signal response.

src/lqg.jl

Lines changed: 37 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -253,28 +253,10 @@ end
253253

254254

255255
"""
256-
extended_controller(K::AbstractStateSpace)
256+
extended_controller(P::StateSpace, L, K; z = nothing, direct = false)
257+
extended_controller(l::LQGProblem, L = lqr(l), K = kalman(l); z = nothing, direct = false)
257258
258-
Takes a controller and returns an `ExtendedStateSpace` version which has augmented input `[r; y]` and output `y` (`z` output is 0-dim).
259-
"""
260-
function extended_controller(K::AbstractStateSpace)
261-
nx,nu,ny = K.nx, K.nu, K.ny
262-
A,B,C,D = ssdata(K)
263-
@error("This has not been verified")
264-
265-
B1 = zeros(nx, nx) # dynamics not affected by r
266-
B2 = B # input y
267-
D21 = C# K*r
268-
C2 = -C # - K*y
269-
C1 = zeros(0, nx)
270-
ss(A, B1, B2, C1, C2; D21, Ts = K.timeevol)
271-
end
272-
273-
"""
274-
extended_controller(P::StateSpace, L, K; z = nothing)
275-
extended_controller(l::LQGProblem, L = lqr(l), K = kalman(l); z = nothing)
276-
277-
Returns a statespace system representing the controller that is obtained when state-feedback `u = L(xᵣ-x̂)` is combined with a Kalman filter with gain `K` that produces state estimates x̂. The controller is an instance of `ExtendedStateSpace` where `C2 = -L, D21 = L` and `B2 = K`.
259+
Returns a statespace system representing the controller that is obtained when state-feedback `u = L(xᵣ-x̂)` is combined with a Kalman filter with gain `K` that produces state estimates x̂. The controller is an instance of `ExtendedStateSpace` where `C2 = -L, D21 = L` and `B2 = K`. The reference `xᵣ` also enters the observer dynamics through `B1 = (B − KD)·L`, so the observer estimate `x̂` tracks the true state even when `xᵣ ≠ 0`.
278260
279261
The returned system has *inputs* `[xᵣ; y]` and outputs the control signal `u`. If a reference model `R` is used to generate state references `xᵣ`, the controller from `(ry, y) -> u` where `ry - y = e` is given by
280262
```julia
@@ -290,31 +272,52 @@ Ce = extended_controller(l)
290272
system_mapping(Ce) == -C
291273
```
292274
293-
Please note, without the reference pre-filter, the DC gain from references to controlled outputs may not be identity. If a vector of output indices is provided through the keyword argument `z`, the closed-loop system from state reference `xᵣ` to outputs `z` is returned as a second return argument. The inverse of the DC-gain of this closed-loop system may be useful to compensate for the DC-gain of the controller.
275+
If a vector of output indices is provided through the keyword argument `z`, the closed-loop system from state reference `xᵣ` to outputs `z` is returned as a second return argument. The inverse of the DC-gain of this closed-loop system may be useful as an additional reference pre-filter for plants where exact tracking is desired despite mismatch.
276+
277+
The `direct` keyword argument mirrors the corresponding option on [`observer_controller`](@ref) and, for discrete plants, selects the current-time observer correction. It has no effect on continuous-time plants. When `direct = true`, `K` must be the corresponding "direct" Kalman gain (from `kalman(l; direct = true)`) and `D` must be zero. In direct mode the reference enters as a pure feedforward (`B1 = 0`); the canonical 2-DOF form `B1 = (B − KD)·L` is only used in the non-direct branch.
294278
"""
295-
function extended_controller(P::AbstractStateSpace, L::AbstractMatrix, K::AbstractMatrix; z::Union{Nothing, AbstractVector} = nothing)
279+
function extended_controller(P::AbstractStateSpace, L::AbstractMatrix, K::AbstractMatrix; z::Union{Nothing, AbstractVector} = nothing, direct::Bool = false)
296280
A,B,C,D = ssdata(P)
297-
Ac = A - B*L - K*C + K*D*L # 8.26b
298281
(; nx, nu, ny) = P
299-
B1 = zeros(nx, nx) # dynamics not affected by r
300-
# l.D21 does not appear here, see comment in kalman
301-
B2 = K # input y
302-
D21 = L # L*xᵣ # should be D21?
303-
C2 = -L # - L*x̂
304-
C1 = zeros(0, nx)
305-
Ce0 = ss(Ac, B1, B2, C1, C2; D21, Ts = P.timeevol)
282+
size(L) == (nu, nx) || throw(ArgumentError("L must have size (nu, nx) = ($(nu), $(nx)), got $(size(L))"))
283+
size(K) == (nx, ny) || throw(ArgumentError("K must have size (nx, ny) = ($(nx), $(ny)), got $(size(K))"))
284+
if direct
285+
isdiscrete(P) || throw(ArgumentError("direct = true is only meaningful for discrete-time plants"))
286+
iszero(D) || throw(ArgumentError("D must be zero when using direct = true (matches observer_controller)"))
287+
# Mirror the matrices used by observer_controller(...; direct=true): the y-channel block
288+
# (Ac, B2, C2, D22) matches observer_controller exactly up to the sign on (C2, D22), so
289+
# `system_mapping(Ce) == -observer_controller(l; direct=true)` holds.
290+
IKC = I - K*C
291+
ABL = A - B*L
292+
Ac = IKC * ABL
293+
B2 = IKC * ABL * K
294+
# Reference path: feedforward only — see docstring caveat about non-canonical 2-DOF here.
295+
B1 = zeros(nx, nx)
296+
# D22 needs to absorb the (-L*K) feedthrough that observer_controller (direct) carries on Dc.
297+
D22 = -L * K
298+
else
299+
Ac = A - B*L - K*C + K*D*L # 8.26b
300+
B1 = (B - K*D) * L # canonical 2-DOF: observer sees the actual u = L(xᵣ − x̂)
301+
B2 = K
302+
D22 = zeros(nu, ny)
303+
end
304+
D21 = L # L * xᵣ
305+
C2 = -L # − L * x̂
306+
C1 = zeros(0, nx)
307+
Ce0 = ss(Ac, B1, B2, C1, C2; D21, D22, Ts = P.timeevol)
306308
if z === nothing
307309
return Ce0
308310
end
309-
r = 1:nx
311+
# `feedback` defaults to pos_feedback=false; combined with D21 = L and C2 = -L this gives
312+
# u = L(xᵣ − x̂) on the wire. U2 = (1:ny) .+ nx selects the y-slice of the controller input [xᵣ; y].
310313
Ce = ss(Ce0)
311-
cl = feedback(P, Ce, Z1 = z, Z2=[], U2=(1:ny) .+ nx, Y1 = :, W2=r, W1=[])
314+
cl = feedback(P, Ce, Z1 = z, Z2=[], U2=(1:ny) .+ nx, Y1 = :, W2=1:nx, W1=[])
312315
Ce0, cl
313316
end
314317

315318

316319
function extended_controller(l::LQGProblem, L::AbstractMatrix = lqr(l), K::AbstractMatrix = kalman(l); kwargs...)
317-
P = system_mapping(l, identity)
320+
P = system_mapping(l, identity) # identity skips the optional plant transform
318321
extended_controller(P, L, K; kwargs...)
319322
end
320323

test/test_lqg.jl

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -503,8 +503,10 @@ Cfb = observer_controller(lqg)
503503
cl = feedback(system_mapping(lqg), observer_controller(lqg))*RobustAndOptimalControl.ff_controller(lqg, comp_dc = true)
504504
@test dcgain(cl)[1,2] 1 rtol=1e-8
505505

506-
# Method 4: Build compensation into R and compute the closed-loop DC gain, should be 1
507-
R = named_ss(ss(dc_gain_compensation*I(4)), "R") # Reference filter
506+
# Method 4: With the canonical 2-DOF form of extended_controller, no R pre-filter is needed —
507+
# the reference enters the observer dynamics so x̂ tracks x and (for this integrator plant) the
508+
# closed-loop DC gain from state reference to controlled output is identity.
509+
R = named_ss(ss(I(4)), "R") # identity pre-filter
508510
Ce = named_ss(ss(Ce); x = :xC, y = :u, u = [R.y; :y^lqg.ny])
509511

510512
Cry = RobustAndOptimalControl.connect([R, Ce]; u1 = R.y, y1 = R.y, w1 = [R.u; :y^lqg.ny], z1=[:u])
@@ -514,26 +516,61 @@ connections = [
514516
[:x, :phi] .=> [:y1, :y2]
515517
]
516518
cl = RobustAndOptimalControl.connect([lsys, Cry], connections; w1 = R.u, z1 = [:x, :phi])
517-
@test inv(dcgain(cl)[1,2]) 1 rtol=1e-8
519+
@test dcgain(cl)[1,2] 1 rtol=1e-8
518520

519521

520522
# Method 5: close the loop manually with reference as input and position as output
521523

522524
R = named_ss(ss(I(4)), "R") # Reference filter, used for signal names only
523525
Ce = named_ss(ss(extended_controller(lqg)); x = :xC, y = :u, u = [:Ry^4; :y^lqg.ny])
524526
cl = feedback(lsys, Ce, z1 = [:x], z2=[], u2=:y^2, y1 = [:x, :phi], w2=[:Ry2], w1=[])
525-
@test inv(dcgain(cl)[]) dc_gain_compensation rtol=1e-8
527+
@test dcgain(cl)[] 1 rtol=1e-8
526528

527529
cl = feedback(lsys, Ce, z1 = [:x, :phi], z2=[], u2=:y^2, y1 = [:x, :phi], w2=[:Ry2], w1=[])
528-
@test pinv(dcgain(cl)) [dc_gain_compensation 0] atol=1e-8
530+
@test pinv(dcgain(cl)) [1 0] atol=1e-8
529531

530532
# Method 6: use the z argument to extended_controller to compute the closed-loop TF
531533

532534
Ce, cl = extended_controller(lqg, z=[1, 2])
533-
@test pinv(dcgain(cl)[1,2]) dc_gain_compensation atol=1e-8
535+
@test dcgain(cl)[1,2] 1 atol=1e-8
534536

535537
Ce, cl = extended_controller(lqg, z=[1])
536-
@test pinv(dcgain(cl)[1,2]) dc_gain_compensation atol=1e-8
538+
@test dcgain(cl)[1,2] 1 atol=1e-8
539+
540+
541+
# Method 7: discrete-time round-trip — system_mapping(Ce) == -observer_controller(l_d)
542+
let Ts = 0.05
543+
Pd = c2d(P, Ts)
544+
lqg_d = LQGProblem(Pd, Q1, Q2, R1, R2)
545+
Ce_d = extended_controller(lqg_d)
546+
Cfb_d = observer_controller(lqg_d)
547+
@test system_mapping(Ce_d) -ss(Cfb_d)
548+
end
549+
550+
551+
# Method 8: direct=true on a discrete plant — system_mapping(Ce) == -observer_controller(l; direct=true).
552+
# `observer_controller(::LQGProblem, ::AbstractMatrix, ::AbstractMatrix; direct)` is ambiguous with
553+
# the ControlSystemsBase fallback when K is concrete, so we let observer_controller resolve K itself.
554+
let Ts = 0.05
555+
Pd = c2d(P, Ts)
556+
lqg_d = LQGProblem(Pd, Q1, Q2, R1, R2)
557+
Ld = lqr(lqg_d)
558+
Kd_direct = kalman(lqg_d; direct=true)
559+
Ce_dir = extended_controller(lqg_d, Ld, Kd_direct; direct=true)
560+
Cfb_dir = observer_controller(lqg_d; direct=true)
561+
@test system_mapping(Ce_dir) -ss(Cfb_dir)
562+
end
563+
564+
565+
# Argument validation: wrong-sized L or K should ArgumentError
566+
let
567+
P2 = ss(P)
568+
L_ok = lqr(lqg)
569+
K_ok = kalman(lqg)
570+
@test_throws ArgumentError extended_controller(P2, L_ok[:, 1:end-1], K_ok)
571+
@test_throws ArgumentError extended_controller(P2, L_ok, K_ok[1:end-1, :])
572+
end
573+
537574

538575
## Test LQGProblem with NamedStateSpace inside ExtendedStateSpace
539576
# This tests that the index-based ExtendedStateSpace preserves the type of the internal system

0 commit comments

Comments
 (0)