Skip to content

Commit ad2454c

Browse files
committed
use adaptive sampling for the frequency grid in bode and nyquist plots
This should both increase resolution of sharp features and reduce the number of plotted points at the same time
1 parent 2729dd5 commit ad2454c

File tree

3 files changed

+140
-25
lines changed

3 files changed

+140
-25
lines changed

lib/ControlSystemsBase/src/freqresp.jl

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -380,9 +380,14 @@ frequencies `w`. See also [`sigmaplot`](@ref)
380380
end
381381
@autovec (1) sigma(sys::LTISystem) = sigma(sys, _default_freq_vector(sys, Val{:sigma}()))
382382

383-
function _default_freq_vector(systems::Vector{<:LTISystem}, plot)
384-
min_pt_per_dec = 60
385-
min_pt_total = 200
383+
function _default_freq_vector(systems::Vector{<:LTISystem}, plot; adaptive=false)
384+
if adaptive
385+
min_pt_per_dec = 100
386+
min_pt_total = 1000
387+
else
388+
min_pt_per_dec = 60
389+
min_pt_total = 200
390+
end
386391
bounds = map(sys -> _bounds_and_features(sys, plot)[1], systems)
387392
w1 = minimum(minimum, bounds)
388393
w2 = maximum(maximum, bounds)
@@ -394,8 +399,8 @@ function _default_freq_vector(systems::Vector{<:LTISystem}, plot)
394399
end
395400
w
396401
end
397-
_default_freq_vector(sys::LTISystem, plot) = _default_freq_vector(
398-
[sys], plot)
402+
_default_freq_vector(sys::LTISystem, plot; kwargs...) = _default_freq_vector(
403+
[sys], plot; kwargs...)
399404

400405
function _bounds_and_features(sys::LTISystem, plot::Val)
401406
# Get zeros and poles for each channel

lib/ControlSystemsBase/src/plotting.jl

Lines changed: 128 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -227,13 +227,15 @@ Calculate default frequency vector and put system in array of not already array.
227227
for which `_default_freq_vector` is defined.
228228
Check that system dimensions are compatible.
229229
"""
230-
_processfreqplot(plottype, system::LTISystem, args...) =
231-
_processfreqplot(plottype, [system], args...)
230+
_processfreqplot(plottype, system::LTISystem, args...; kwargs...) =
231+
_processfreqplot(plottype, [system], args...; kwargs...)
232232
# Catch when system is not vector, with and without frequency input
233233

234234
# Catch correct form
235-
function _processfreqplot(plottype, systems::AbstractVector{<:LTISystem},
236-
w = _default_freq_vector(systems, plottype))
235+
_processfreqplot(plottype, systems::AbstractVector{<:LTISystem}; adaptive=false) =
236+
_processfreqplot(plottype, systems, _default_freq_vector(systems, plottype; adaptive))
237+
238+
function _processfreqplot(plottype, systems::AbstractVector{<:LTISystem}, w; kwargs...)
237239

238240
if !_same_io_dims(systems...)
239241
error("All systems must have the same input/output dimensions")
@@ -254,6 +256,7 @@ optionally provided. To change the Magnitude scale see [`setPlotScale`](@ref). T
254256
- If `hz=true`, the plot x-axis will be displayed in Hertz, the input frequency vector is still treated as rad/s.
255257
- `balance`: Call [`balance_statespace`](@ref) on the system before plotting.
256258
- `adjust_phase_start`: If true, the phase will be adjusted so that it starts at -90*intexcess degrees, where `intexcess` is the integrator excess of the system.
259+
- `adaptive`: If true, an adaptive frequency grid is used in order to keep the number of plotted points low, while resolving features in the frequency response well. If a manually provided frequency vector is used, this may be downsampled before plotting.
257260
258261
`kwargs` is sent as argument to RecipesBase.plot.
259262
"""
@@ -275,8 +278,10 @@ function _get_plotlabel(s, i, j)
275278
end
276279
end
277280

278-
@recipe function bodeplot(p::Bodeplot; plotphase=true, ylimsphase=(), unwrap=true, hz=false, balance=true, adjust_phase_start=true)
279-
systems, w = _processfreqplot(Val{:bode}(), p.args...)
281+
_span(vec) = -(reverse(extrema(vec))...)
282+
283+
@recipe function bodeplot(p::Bodeplot; plotphase=true, ylimsphase=(), unwrap=true, hz=false, balance=true, adjust_phase_start=true, adaptive=true)
284+
systems, w = _processfreqplot(Val{:bode}(), p.args...; adaptive)
280285
ws = (hz ? 1/(2π) : 1) .* w
281286
ny, nu = size(systems[1])
282287
s2i(i,j) = LinearIndices((nu,(plotphase ? 2 : 1)*ny))[j,i]
@@ -328,7 +333,13 @@ end
328333
label --> lab
329334
end
330335
group --> group_ind
331-
ws, magdata
336+
if adaptive
337+
lmag = _PlotScale == "dB" ? magdata : log.(magdata)
338+
wsi, _, inds = downsample(ws, lmag, _span(lmag)/300)
339+
wsi, magdata[inds]
340+
else
341+
ws, magdata
342+
end
332343
end
333344
plotphase || continue
334345

@@ -349,9 +360,15 @@ end
349360
xguide --> (hz ? "Frequency [Hz]" : "Frequency [rad/s]")
350361
label --> ""
351362
group --> group_ind
352-
ws, unwrap ? ControlSystemsBase.unwrap(phasedata.*(pi/180)).*(180/pi) : phasedata
353-
end
363+
phasedata = unwrap ? ControlSystemsBase.unwrap(phasedata.*(pi/180)).*(180/pi) : phasedata
364+
# NOTE: we should only downsample if the user hasn't provided w themselves
365+
if adaptive
366+
downsample(ws, phasedata, _span(phasedata)/300)[1:2]
367+
else
368+
ws, phasedata
369+
end
354370

371+
end
355372
end
356373
end
357374
end
@@ -404,8 +421,8 @@ optionally provided.
404421
`kwargs` is sent as argument to plot.
405422
"""
406423
nyquistplot
407-
@recipe function nyquistplot(p::Nyquistplot; Ms_circles=Float64[], Mt_circles=Float64[], unit_circle=false, hz=false, critical_point=-1, balance=true)
408-
systems, w = _processfreqplot(Val{:nyquist}(), p.args...)
424+
@recipe function nyquistplot(p::Nyquistplot; Ms_circles=Float64[], Mt_circles=Float64[], unit_circle=false, hz=false, critical_point=-1, balance=true, adaptive=true)
425+
systems, w = _processfreqplot(Val{:nyquist}(), p.args...; adaptive)
409426
ny, nu = size(systems[1])
410427
nw = length(w)
411428
layout --> (ny,nu)
@@ -432,7 +449,14 @@ nyquistplot
432449
label --> lab
433450
end
434451
hover --> [hz ? Printf.@sprintf("f = %.3g", w/2π) : Printf.@sprintf("ω = %.3g", w) for w in w]
435-
(redata, imdata)
452+
if adaptive
453+
indsre = downsample(w, redata, 1/500)[3]
454+
indsim = downsample(w, imdata, 1/500)[3]
455+
inds = sort!(union(indsre, indsim))
456+
redata[inds], imdata[inds]
457+
else
458+
redata, imdata
459+
end
436460
end
437461

438462
if si == length(systems)
@@ -732,8 +756,8 @@ Plot all the amplitude and phase margins of the system(s) `sys`.
732756
`kwargs` is sent as argument to RecipesBase.plot.
733757
"""
734758
marginplot
735-
@recipe function marginplot(p::Marginplot; plotphase=true, hz=false, balance=true, adjust_phase_start=true)
736-
systems, w = _processfreqplot(Val{:bode}(), p.args...)
759+
@recipe function marginplot(p::Marginplot; plotphase=true, hz=false, balance=true, adjust_phase_start=true, adaptive=true)
760+
systems, w = _processfreqplot(Val{:bode}(), p.args...; adaptive)
737761
ny, nu = size(systems[1])
738762
s2i(i,j) = LinearIndices((nu,(plotphase ? 2 : 1)*ny))[j,i]
739763
layout --> ((plotphase ? 2 : 1)*ny, nu)
@@ -790,7 +814,14 @@ marginplot
790814
end
791815
primary := true
792816
seriestype := :bodemag
793-
w, bmag[i, j, :]
817+
m = bmag[i, j, :]
818+
if adaptive
819+
lmag = _PlotScale == "dB" ? m : log.(m)
820+
wsi, _, inds = downsample(w, lmag, _span(lmag)/300)
821+
wsi, m[inds]
822+
else
823+
w, m
824+
end
794825
end
795826

796827
#Plot gain margins
@@ -818,7 +849,11 @@ marginplot
818849
@series begin
819850
primary := true
820851
seriestype := :bodephase
821-
w, phasedata
852+
if adaptive
853+
downsample(w, phasedata, _span(phasedata)/300)[1:2]
854+
else
855+
w, phasedata
856+
end
822857
end
823858
@series begin
824859
primary := false
@@ -917,7 +952,7 @@ Gang-of-Four plot.
917952
`sigma` determines whether a [`sigmaplot`](@ref) is used instead of a [`bodeplot`](@ref) for MIMO `S` and `T`.
918953
`kwargs` are sent as argument to RecipesBase.plot.
919954
"""
920-
function gangoffourplot(P::Union{<:Vector, LTISystem}, C::Vector, args...; minimal=true, Ms_lines = [1.0, 1.25, 1.5], Mt_lines = [], sigma = true, plotphase=false, kwargs...)
955+
function gangoffourplot(P::Union{<:Vector, LTISystem}, C::Vector, args...; minimal=true, Ms_lines = [1.0, 1.25, 1.5], Mt_lines = [], sigma = true, plotphase=false, adaptive=true, kwargs...)
921956
if P isa LTISystem # Don't broadcast over scalar (with size?)
922957
P = [P]
923958
end
@@ -927,16 +962,16 @@ function gangoffourplot(P::Union{<:Vector, LTISystem}, C::Vector, args...; minim
927962

928963
gofs = gangoffour.(P,C)
929964
S,D,N,T = ntuple(i->getindex.(gofs, i), 4)
930-
bp = (args...; kwargs...) -> sigma ? sigmaplot(args...; kwargs...) : bodeplot(args...; plotphase=false, kwargs...)
965+
bp = (args...; kwargs...) -> sigma ? sigmaplot(args...; kwargs...) : bodeplot(args...; plotphase=false, adaptive, kwargs...)
931966
f1 = bp(S, args...; show=false, title="S = 1/(1+PC)", kwargs...)
932967
if !isnothing(Ms_lines) && !isempty(Ms_lines)
933968
Plots.hline!(Ms_lines', l=(:dash, [:green :orange :red :darkred :purple]), sp=1, primary=false, lab=string.(Ms_lines'), ylims=(1e-2,4))
934969
else
935970
Plots.hline!([1.0], l=(:dash, :black), sp=1, ylims=(1e-2,1.8))
936971
end
937-
f2 = bodeplot(D, args...; show=false, title="P/(1+PC)", plotphase=false, kwargs...)
972+
f2 = bodeplot(D, args...; show=false, title="P/(1+PC)", plotphase=false, adaptive, kwargs...)
938973
Plots.hline!(ones(1, ninputs(D[1])*noutputs(D[1])), l=(:black, :dash), primary=false)
939-
f3 = bodeplot(N, args...; show=false, title="C/(1+PC)", plotphase=false, kwargs...)
974+
f3 = bodeplot(N, args...; show=false, title="C/(1+PC)", plotphase=false, adaptive, kwargs...)
940975
f4 = bp(T, args...; show=false, title="T = PC/(1+PC)", ylims=(1e-2,4), kwargs...)
941976
if !isnothing(Mt_lines) && !isempty(Mt_lines)
942977
Plots.hline!(Mt_lines', l=(:dash, [:green :orange :red :darkred :purple]), primary=false, lab=string.(Mt_lines'), ylims=(1e-2,4))
@@ -989,3 +1024,76 @@ rgaplot
9891024
end
9901025
end
9911026
end
1027+
1028+
1029+
## Adaptive sampling
1030+
# Code adapted from https://github.com/iuliancioarca/AdaptiveSampling.jl/blob/master/LICENSE
1031+
1032+
function downsample(t,y,detail_th)
1033+
# Compress signal by removing redundant points.
1034+
# Adjust waveform detail/compression ratio with 'detail_th' (maximum allowed
1035+
# difference between original and approximated points from the signal)
1036+
yln = length(y)
1037+
idx_l = 1
1038+
idx_r = yln
1039+
idx_d_max = 1
1040+
cond_break = true
1041+
d_max = 0.0
1042+
M = zeros(Int,yln) # hash table for relevant indices
1043+
idx2save = zeros(Int,yln+2)
1044+
cnt = 2
1045+
idx2save[1:2] = [1,yln]
1046+
while cond_break
1047+
# get maximum error(difference) and index, between original chunk of signal
1048+
# and linear approximation
1049+
d_max, idx_d_max = get_d_max(idx_l,idx_r,y)
1050+
# save all indices
1051+
M[idx_d_max] = idx_r
1052+
if d_max > detail_th
1053+
# if computed error is greater than maximum allowed error, save
1054+
# next point index and call get_d_max(idx_l,idx_r,y) at next
1055+
# iteration; keep going towards leftmost branches
1056+
cnt = cnt + 1
1057+
idx_r = idx_d_max
1058+
idx2save[cnt] = idx_d_max
1059+
else
1060+
# if computed error is smaller than maximum allowed error, stop, go
1061+
# right(to the next waveform segment) and call get_d_max(idx_l,idx_r,y)
1062+
# at the next iteration
1063+
idx_l = idx_r;
1064+
if idx_l != yln
1065+
idx_r = M[idx_l]
1066+
else
1067+
cond_break = false
1068+
end
1069+
end
1070+
end
1071+
# sort all indexes corresponding to relevent points and generate resampled
1072+
# signal
1073+
idx2save = idx2save[1:cnt]
1074+
idx2save = sort(idx2save)
1075+
t_new = @view t[idx2save]
1076+
y_new = @view y[idx2save]
1077+
return t_new, y_new, idx2save
1078+
end
1079+
function get_d_max(idx_l,idx_r,y)
1080+
# cut segment to be resampled
1081+
yp = view(y,idx_l:idx_r)
1082+
# construct linear approximation
1083+
dr = LinRange(y[idx_l], y[idx_r], length(yp))
1084+
# compute distance(error) and get index of maximum error
1085+
# -> this will be used for further splitting the
1086+
# signal and will be part of the final resampled signal
1087+
d_max = 0.0
1088+
idx_d_max = 1
1089+
err_val = 0.0
1090+
for i = 1:length(yp)
1091+
err_val = abs(yp[i] - dr[i])
1092+
if err_val > d_max
1093+
d_max = err_val
1094+
idx_d_max = i
1095+
end
1096+
end
1097+
idx_d_max = idx_d_max + idx_l - 1
1098+
return d_max, idx_d_max
1099+
end

lib/ControlSystemsBase/test/test_plots.jl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ end
5555

5656
sys = ssrand(3,3,3)
5757
sigmaplot(sys, extrema=true)
58+
bodeplot(sys, adaptive=false)
59+
nyquistplot(sys, adaptive=false)
5860

5961
Gmimo = ssrand(2,2,2,Ts=1)
6062
@test_nowarn plot(step(Gmimo, 10), plotx=true)

0 commit comments

Comments
 (0)