Skip to content

Commit 97a3176

Browse files
committed
Merge branch 'main' into multiple_shooting
2 parents 2bc4513 + cc2a92f commit 97a3176

File tree

10 files changed

+149
-33
lines changed

10 files changed

+149
-33
lines changed

.github/workflows/CI.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ jobs:
2727
arch:
2828
- x64
2929
steps:
30+
- name: Set JULIA_DEBUG environment variable
31+
if: ${{ runner.debug == '1' }}
32+
run: echo "JULIA_DEBUG=ModelPredictiveControl" >> $GITHUB_ENV
3033
- uses: actions/checkout@v2
3134
- uses: julia-actions/setup-julia@v1
3235
with:
@@ -39,4 +42,4 @@ jobs:
3942
- uses: codecov/codecov-action@v4
4043
with:
4144
token: ${{ secrets.CODECOV_TOKEN }}
42-
fail_ci_if_error: false
45+
fail_ci_if_error: false

Project.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name = "ModelPredictiveControl"
22
uuid = "61f9bdb8-6ae4-484a-811f-bbf86720c31c"
33
authors = ["Francis Gagnon"]
4-
version = "1.2.0"
4+
version = "1.3.1"
55

66
[deps]
77
ControlSystemsBase = "aaaaaaaa-a6ca-5380-bf3e-84a91bcd477e"

src/controller/execute.jl

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ julia> round.(getinfo(mpc)[:Ŷ], digits=3)
114114
function getinfo(mpc::PredictiveController{NT}) where NT<:Real
115115
model = mpc.estim.model
116116
nŶe, nUe = (mpc.Hp+1)*model.ny, (mpc.Hp+1)*model.nu
117-
info = Dict{Symbol, Union{JuMP._SolutionSummary, Vector{NT}, NT}}()
117+
info = Dict{Symbol, Any}()
118118
Ŷ0, u0, û0 = similar(mpc.Yop), similar(model.uop), similar(model.uop)
119119
Ŷs = similar(mpc.Yop)
120120
x̂0, x̂0next = similar(mpc.estim.x̂0), similar(mpc.estim.x̂0)
@@ -491,7 +491,8 @@ If supported by `mpc.optim`, it warm-starts the solver at:
491491
where ``\mathbf{Δu}_{k-1}(k+j)`` is the input increment for time ``k+j`` computed at the
492492
last control period ``k-1``. It then calls `JuMP.optimize!(mpc.optim)` and extract the
493493
solution. A failed optimization prints an `@error` log in the REPL and returns the
494-
warm-start value.
494+
warm-start value. A failed optimization also prints [`getinfo`](@ref) results in
495+
the debug log [if activated](https://docs.julialang.org/en/v1/stdlib/Logging/#Example:-Enable-debug-level-messages).
495496
"""
496497
function optim_objective!(mpc::PredictiveController{NT}) where {NT<:Real}
497498
model, optim = mpc.estim.model, mpc.optim
@@ -518,13 +519,19 @@ function optim_objective!(mpc::PredictiveController{NT}) where {NT<:Real}
518519
if !issolved(optim)
519520
status = JuMP.termination_status(optim)
520521
if iserror(optim)
521-
@error("MPC terminated without solution: returning last solution shifted",
522-
status)
522+
@error(
523+
"MPC terminated without solution: estimation in open-loop "*
524+
"(more info in debug log)",
525+
status
526+
)
523527
else
524-
@warn("MPC termination status not OPTIMAL or LOCALLY_SOLVED: keeping "*
525-
"solution anyway", status)
528+
@warn(
529+
"MPC termination status not OPTIMAL or LOCALLY_SOLVED: keeping solution "*
530+
"anyway (more info in debug log)",
531+
status
532+
)
526533
end
527-
@debug JuMP.solution_summary(optim, verbose=true)
534+
@debug info2debugstr(getinfo(mpc))
528535
end
529536
if iserror(optim)
530537
mpc.ΔŨ .= ΔŨ0

src/estimator/luenberger.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ function update_estimate!(estim::Luenberger, y0m, d0, u0)
137137
end
138138

139139
"Throw an error if `setmodel!` is called on `Luenberger` observer w/o the default values."
140-
function setmodel!(estim::Luenberger, model, args...)
140+
function setmodel_estimator!(estim::Luenberger, model, args...)
141141
if estim.model !== model
142142
error("Luenberger does not support setmodel!")
143143
end

src/estimator/mhe/construct.jl

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,11 +123,6 @@ struct MovingHorizonEstimator{
123123
validate_kfcov(nym, nx̂, Q̂, R̂, P̂_0)
124124
lastu0 = zeros(NT, nu)
125125
x̂0 = [zeros(NT, model.nx); zeros(NT, nxs)]
126-
P̂_0 = Hermitian(P̂_0, :L)
127-
Q̂, R̂ = Hermitian(Q̂, :L), Hermitian(R̂, :L)
128-
invP̄ = Hermitian(inv(P̂_0), :L)
129-
invQ̂_He = Hermitian(repeatdiag(inv(Q̂), He), :L)
130-
invR̂_He = Hermitian(repeatdiag(inv(R̂), He), :L)
131126
r = direct ? 0 : 1
132127
E, G, J, B, ex̄, Ex̂, Gx̂, Jx̂, Bx̂ = init_predmat_mhe(
133128
model, He, i_ym, Â, B̂u, Ĉm, B̂d, D̂dm, x̂op, f̂op, r
@@ -146,10 +141,18 @@ struct MovingHorizonEstimator{
146141
nD0 = direct ? nd*(He+1) : nd*He
147142
U0, D0 = zeros(NT, nu*He), zeros(NT, nD0)
148143
= zeros(NT, nx̂*He)
144+
buffer = StateEstimatorBuffer{NT}(nu, nx̂, nym, ny, nd)
145+
P̂_0 = Hermitian(P̂_0, :L)
146+
Q̂, R̂ = Hermitian(Q̂, :L), Hermitian(R̂, :L)
147+
P̂_0 = Hermitian(P̂_0, :L)
148+
invP̄ = inv_cholesky!(buffer.P̂, P̂_0)
149+
invQ̂ = inv_cholesky!(buffer.Q̂, Q̂)
150+
invR̂ = inv_cholesky!(buffer.R̂, R̂)
151+
invQ̂_He = Hermitian(repeatdiag(invQ̂, He), :L)
152+
invR̂_He = Hermitian(repeatdiag(invR̂, He), :L)
149153
x̂0arr_old = zeros(NT, nx̂)
150154
P̂arr_old = copy(P̂_0)
151155
Nk = [0]
152-
buffer = StateEstimatorBuffer{NT}(nu, nx̂, nym, ny, nd)
153156
corrected = [false]
154157
estim = new{NT, SM, JM, CE}(
155158
model, optim, con, covestim,

src/estimator/mhe/execute.jl

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ function init_estimate_cov!(estim::MovingHorizonEstimator, _ , d0, u0)
1616
estim.D0[1:estim.model.nd] .= d0
1717
end
1818
# estim.P̂_0 is in fact P̂(-1|-1) is estim.direct==false, else P̂(-1|0)
19-
estim.invP̄ .= inv(estim.P̂_0)
19+
invert_cov!(estim, estim.P̂_0)
2020
estim.P̂arr_old .= estim.P̂_0
2121
estim.x̂0arr_old .= 0
2222
return nothing
@@ -108,8 +108,7 @@ function getinfo(estim::MovingHorizonEstimator{NT}) where NT<:Real
108108
nu, ny, nd = model.nu, model.ny, model.nd
109109
nx̂, nym, nŵ, nϵ = estim.nx̂, estim.nym, estim.nx̂, estim.
110110
nx̃ =+ nx̂
111-
MyTypes = Union{JuMP._SolutionSummary, Hermitian{NT, Matrix{NT}}, Vector{NT}, NT}
112-
info = Dict{Symbol, MyTypes}()
111+
info = Dict{Symbol, Any}()
113112
V̂, X̂0 = similar(estim.Y0m[1:nym*Nk]), similar(estim.X̂0[1:nx̂*Nk])
114113
û0, ŷ0 = similar(model.uop), similar(model.yop)
115114
V̂, X̂0 = predict!(V̂, X̂0, û0, ŷ0, estim, model, estim.Z̃)
@@ -370,7 +369,8 @@ If supported by `estim.optim`, it warm-starts the solver at:
370369
where ``\mathbf{ŵ}_{k-1}(k-j)`` is the input increment for time ``k-j`` computed at the
371370
last time step ``k-1``. It then calls `JuMP.optimize!(estim.optim)` and extract the
372371
solution. A failed optimization prints an `@error` log in the REPL and returns the
373-
warm-start value.
372+
warm-start value. A failed optimization also prints [`getinfo`](@ref) results in
373+
the debug log [if activated](https://docs.julialang.org/en/v1/stdlib/Logging/#Example:-Enable-debug-level-messages).
374374
"""
375375
function optim_objective!(estim::MovingHorizonEstimator{NT}) where NT<:Real
376376
model, optim = estim.model, estim.optim
@@ -406,13 +406,19 @@ function optim_objective!(estim::MovingHorizonEstimator{NT}) where NT<:Real
406406
if !issolved(optim)
407407
status = JuMP.termination_status(optim)
408408
if iserror(optim)
409-
@error("MHE terminated without solution: estimation in open-loop",
410-
status)
409+
@error(
410+
"MHE terminated without solution: estimation in open-loop "*
411+
"(more info in debug log)",
412+
status
413+
)
411414
else
412-
@warn("MHE termination status not OPTIMAL or LOCALLY_SOLVED: keeping "*
413-
"solution anyway", status)
415+
@warn(
416+
"MHE termination status not OPTIMAL or LOCALLY_SOLVED: keeping solution "*
417+
"anyway (more info in debug log)",
418+
status
419+
)
414420
end
415-
@debug JuMP.solution_summary(optim, verbose=true)
421+
@debug info2debugstr(getinfo(estim))
416422
end
417423
if iserror(optim)
418424
estim.Z̃ .= Z̃_0
@@ -433,9 +439,17 @@ function correct_cov!(estim::MovingHorizonEstimator)
433439
y0marr, d0arr = @views estim.Y0m[1:nym], estim.D0[1:nd]
434440
estim.covestim.x̂0 .= estim.x̂0arr_old
435441
estim.covestim.P̂ .= estim.P̂arr_old
436-
correct_estimate!(estim.covestim, y0marr, d0arr)
437-
estim.P̂arr_old .= estim.covestim.
438-
estim.invP̄ .= inv(estim.P̂arr_old)
442+
try
443+
correct_estimate!(estim.covestim, y0marr, d0arr)
444+
estim.P̂arr_old .= estim.covestim.
445+
invert_cov!(estim, estim.P̂arr_old)
446+
catch err
447+
if err isa PosDefException
448+
@warn("Arrival covariance is not positive definite: keeping the old one")
449+
else
450+
rethrow()
451+
end
452+
end
439453
return nothing
440454
end
441455

@@ -445,9 +459,31 @@ function update_cov!(estim::MovingHorizonEstimator)
445459
u0arr, y0marr, d0arr = @views estim.U0[1:nu], estim.Y0m[1:nym], estim.D0[1:nd]
446460
estim.covestim.x̂0 .= estim.x̂0arr_old
447461
estim.covestim.P̂ .= estim.P̂arr_old
448-
update_estimate!(estim.covestim, y0marr, d0arr, u0arr)
449-
estim.P̂arr_old .= estim.covestim.
450-
estim.invP̄ .= inv(estim.P̂arr_old)
462+
try
463+
update_estimate!(estim.covestim, y0marr, d0arr, u0arr)
464+
estim.P̂arr_old .= estim.covestim.
465+
invert_cov!(estim, estim.P̂arr_old)
466+
catch err
467+
if err isa PosDefException
468+
@warn("Arrival covariance is not positive definite: keeping the old one")
469+
else
470+
rethrow()
471+
end
472+
end
473+
return nothing
474+
end
475+
476+
"Invert the covariance estimate at arrival `P̄`."
477+
function invert_cov!(estim::MovingHorizonEstimator, P̄)
478+
try
479+
estim.invP̄ .= inv_cholesky!(estim.buffer.P̂, P̄)
480+
catch err
481+
if err isa PosDefException
482+
@warn("Arrival covariance is not invertible: keeping the old one")
483+
else
484+
rethrow()
485+
end
486+
end
451487
return nothing
452488
end
453489

src/general.jl

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,21 @@ function iserror(optim::JuMP.GenericModel)
2525
return any(errstatus->isequal(status, errstatus), ERROR_STATUSES)
2626
end
2727

28+
"Convert getinfo dictionary to a debug string (without any truncation)."
29+
function info2debugstr(info)
30+
mystr = "Content of getinfo dictionary:\n"
31+
for (key, value) in info
32+
(key == :sol) && continue
33+
mystr *= " :$key => $value\n"
34+
end
35+
if haskey(info, :sol)
36+
split_sol = split(string(info[:sol]), "\n")
37+
solstr = join((lpad(line, length(line) + 2) for line in split_sol), "\n", "")
38+
mystr *= " :sol => \n"*solstr
39+
end
40+
return mystr
41+
end
42+
2843
"Evaluate the quadratic programming objective function `0.5x'*H*x + q'*x` at `x`."
2944
obj_quadprog(x, H, q) = 0.5*dot(x, H, x) + q'*x # dot(x, H, x) is faster than x'*H*x
3045

@@ -59,4 +74,18 @@ end
5974
to_hermitian(A::AbstractVector) = Hermitian(reshape(A, 1, 1), :L)
6075
to_hermitian(A::AbstractMatrix) = Hermitian(A, :L)
6176
to_hermitian(A::Hermitian) = A
62-
to_hermitian(A) = A
77+
to_hermitian(A) = A
78+
79+
"""
80+
Compute the inverse of a the Hermitian positive definite matrix `A` using `cholesky`.
81+
82+
Builtin `inv` function uses LU factorization which is not the best choice for Hermitian
83+
positive definite matrices. The function will mutate `buffer` to reduce memory allocations.
84+
"""
85+
function inv_cholesky!(buffer::Matrix, A::Hermitian)
86+
Achol = Hermitian(buffer, :L)
87+
Achol .= A
88+
chol_obj = cholesky!(Achol)
89+
invA = Hermitian(inv(chol_obj), :L)
90+
return invA
91+
end

test/runtests.jl

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,19 @@ include("test_state_estim.jl")
1515
include("test_predictive_control.jl")
1616
include("test_plot_sim.jl")
1717

18+
old_debug_level = get(ENV, "JULIA_DEBUG", "")
1819
DocMeta.setdocmeta!(
1920
ModelPredictiveControl,
2021
:DocTestSetup,
21-
:(using ModelPredictiveControl, ControlSystemsBase);
22+
:(
23+
using ModelPredictiveControl, ControlSystemsBase;
24+
ENV["JULIA_DEBUG"] = ""; # temporarily disable @debug logging for the doctests
25+
);
2226
recursive=true,
2327
warn=false
2428
)
2529
doctest(ModelPredictiveControl, testset="DocTest")
30+
ENV["JULIA_DEBUG"] = old_debug_level
2631

2732
end;
2833

test/test_predictive_control.jl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ end
8282
mpc4 = LinMPC(model2)
8383
preparestate!(mpc4, [0])
8484
moveinput!(mpc4, [0]) [0.0]
85+
@test_nowarn ModelPredictiveControl.info2debugstr(info)
8586

8687
@test_throws DimensionMismatch moveinput!(mpc1, [0,0,0])
8788
@test_throws DimensionMismatch moveinput!(mpc1, [0], [0,0])
@@ -407,6 +408,7 @@ end
407408
mpc4 = ExplicitMPC(model2)
408409
preparestate!(mpc4, [0])
409410
moveinput!(mpc4, [0]) [0.0]
411+
@test_nowarn ModelPredictiveControl.info2debugstr(info)
410412
end
411413

412414

@@ -633,6 +635,7 @@ end
633635
nonlinmodel2.h!(y, Float32[0,0], Float32[0], Float32[])
634636
preparestate!(nmpc7, [0], [0])
635637
@test moveinput!(nmpc7, [0], [0]) [0.0]
638+
@test_nowarn ModelPredictiveControl.info2debugstr(info)
636639
end
637640

638641
@testset "NonLinMPC step disturbance rejection" begin

test/test_state_estim.jl

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -973,6 +973,36 @@ end
973973
info = getinfo(mhe5)
974974
@test info[:x̂] x̂ atol=1e-9
975975
@test info[:Ŷ][end-1:end] [50, 30] atol=1e-9
976+
@test_nowarn ModelPredictiveControl.info2debugstr(info)
977+
end
978+
979+
@testset "MovingHorizonEstimator fallbacks for arrival covariance estimation" begin
980+
linmodel = setop!(LinModel(sys,Ts,i_u=[1,2], i_d=[3]), uop=[10,50], yop=[50,30], dop=[5])
981+
f(x,u,d,_) = linmodel.A*x + linmodel.Bu*u + linmodel.Bd*d
982+
h(x,d,_) = linmodel.C*x + linmodel.Dd*d
983+
nonlinmodel = setop!(NonLinModel(f, h, Ts, 2, 4, 2, 1, solver=nothing), uop=[10,50], yop=[50,30], dop=[5])
984+
mhe = MovingHorizonEstimator(nonlinmodel, nint_ym=0, He=1)
985+
preparestate!(mhe, [50, 30], [5])
986+
updatestate!(mhe, [10, 50], [50, 30], [5])
987+
mhe.P̂arr_old[1, 1] = -1e-3 # negative eigenvalue to trigger fallback
988+
P̂arr_old_copy = deepcopy(mhe.P̂arr_old)
989+
invP̄_copy = deepcopy(mhe.invP̄)
990+
@test_logs(
991+
(:warn, "Arrival covariance is not positive definite: keeping the old one"),
992+
preparestate!(mhe, [50, 30], [5])
993+
)
994+
@test mhe.P̂arr_old P̂arr_old_copy
995+
@test mhe.invP̄ invP̄_copy
996+
@test_logs(
997+
(:warn, "Arrival covariance is not positive definite: keeping the old one"),
998+
updatestate!(mhe, [10, 50], [50, 30], [5])
999+
)
1000+
@test mhe.P̂arr_old P̂arr_old_copy
1001+
@test mhe.invP̄ invP̄_copy
1002+
@test_logs(
1003+
(:warn, "Arrival covariance is not invertible: keeping the old one"),
1004+
ModelPredictiveControl.invert_cov!(mhe, Hermitian(zeros(mhe.nx̂, mhe.nx̂),:L))
1005+
)
9761006
end
9771007

9781008
@testset "MovingHorizonEstimator set constraints" begin

0 commit comments

Comments
 (0)