From ab0f3d0c77360990cda79b1e021a14915abd635b Mon Sep 17 00:00:00 2001 From: VaiTon <eyadlorenzo@gmail.com> Date: Thu, 11 Jan 2024 17:07:35 +0100 Subject: [PATCH] Allow bode and bodeplot to use a range of frequencies and a freq to calculate the freq. vector --- lib/ControlSystemsBase/src/freqresp.jl | 28 +- lib/ControlSystemsBase/src/plotting.jl | 5 + lib/ControlSystemsBase/src/utilities.jl | 52 ++- lib/ControlSystemsBase/test/test_freqresp.jl | 367 ++++++++++--------- 4 files changed, 255 insertions(+), 197 deletions(-) diff --git a/lib/ControlSystemsBase/src/freqresp.jl b/lib/ControlSystemsBase/src/freqresp.jl index ce8148e26..a578afd59 100644 --- a/lib/ControlSystemsBase/src/freqresp.jl +++ b/lib/ControlSystemsBase/src/freqresp.jl @@ -296,13 +296,27 @@ function (sys::TransferFunction)(z_or_omegas::AbstractVector, map_to_unit_circle end """ - mag, phase, w = bode(sys[, w]; unwrap=true) + mag, phase, w = bode(sys; unwrap=true) + mag, phase, w = bode(sys, w; unwrap=true) + mag, phase, w = bode(sys, (w1, w2); freq=100, unwrap=true) -Compute the magnitude and phase parts of the frequency response of system `sys` -at frequencies `w`. See also [`bodeplot`](@ref) +Compute the magnitude and phase parts of the frequency response of system `sys`. -`mag` and `phase` has size `(ny, nu, length(w))`. -If `unwrap` is true (default), the function `unwrap!` will be applied to the phase angles. This procedure is costly and can be avoided if the unwrapping is not required. +If the frequency vector is not provided, it is automatically generated based on +the poles and zeros of the system. + +If a frequency vector is provided, the magnitude and phase are computed at the +frequencies in the vector. + +If a frequency range is provided, the magnitude and phase are computed at `freq` +logarithmically spaced points per decade between `w1` and `w2`. + +If `unwrap` is true (default), the function `unwrap!` will be applied to the phase angles. +This procedure is costly and can be avoided if the unwrapping is not required. + +`mag` and `phase` have size `(ny, nu, length(w))`. + +To plot a Bode diagram, see [`bodeplot`](@ref). For higher performance, see the function [`bodemag!`](@ref) that computes the magnitude only. """ @@ -313,8 +327,10 @@ For higher performance, see the function [`bodemag!`](@ref) that computes the ma @. angles = rad2deg(angles) return abs.(resp), angles, w end -@autovec (1, 2) bode(sys::LTISystem) = bode(sys, _default_freq_vector(sys, Val{:bode}())) +@autovec (1, 2) bode(sys::LTISystem, w::Tuple{<:Real,<:Real}, freq=100; unwrap=true) = + bode(sys, _tuple_to_vec(w, freq), unwrap=unwrap) +@autovec (1, 2) bode(sys::LTISystem) = bode(sys, _default_freq_vector(sys, Val{:bode}())) # Performance difference between bode and bodemag for tf. Note how expensive the phase unwrapping is. # using ControlSystemsBase # G = tf(ssrand(2,2,5)) diff --git a/lib/ControlSystemsBase/src/plotting.jl b/lib/ControlSystemsBase/src/plotting.jl index 667567266..5deacf65d 100644 --- a/lib/ControlSystemsBase/src/plotting.jl +++ b/lib/ControlSystemsBase/src/plotting.jl @@ -231,6 +231,11 @@ _processfreqplot(plottype, system::LTISystem, args...) = _processfreqplot(plottype, [system], args...) # Catch when system is not vector, with and without frequency input +_processfreqplot(plottype, systems::AbstractVector{<:LTISystem}, + t::Tuple{Real,Real}, freq::Real) = + _processfreqplot(plottype, systems, _tuple_to_vec(t, freq)) + + # Catch correct form function _processfreqplot(plottype, systems::AbstractVector{<:LTISystem}, w = _default_freq_vector(systems, plottype)) diff --git a/lib/ControlSystemsBase/src/utilities.jl b/lib/ControlSystemsBase/src/utilities.jl index b9166a344..238fe42ed 100644 --- a/lib/ControlSystemsBase/src/utilities.jl +++ b/lib/ControlSystemsBase/src/utilities.jl @@ -1,15 +1,15 @@ # Are only really needed for cases when we accept general LTISystem # we should either use them consistently, with a good definition, or remove them basetype(sys::LTISystem) = basetype(typeof(sys)) -basetype(::Type{T}) where T <: LTISystem = T.name.wrapper -numeric_type(::Type{SisoRational{T}}) where T = T -numeric_type(::Type{<:SisoZpk{T}}) where T = T +basetype(::Type{T}) where {T<:LTISystem} = T.name.wrapper +numeric_type(::Type{SisoRational{T}}) where {T} = T +numeric_type(::Type{<:SisoZpk{T}}) where {T} = T numeric_type(sys::SisoTf) = numeric_type(typeof(sys)) numeric_type(::Type{TransferFunction{TE,S}}) where {TE,S} = numeric_type(S) numeric_type(::Type{<:StateSpace{TE,T}}) where {TE,T} = T numeric_type(::Type{<:HeteroStateSpace{TE,AT}}) where {TE,AT} = eltype(AT) -numeric_type(::Type{<:LFTSystem{<:Any, T}}) where {T} = T +numeric_type(::Type{<:LFTSystem{<:Any,T}}) where {T} = T numeric_type(sys::AbstractStateSpace) = eltype(sys.A) numeric_type(sys::LTISystem) = numeric_type(typeof(sys)) @@ -18,12 +18,12 @@ to_matrix(T, A::AbstractVector) = Matrix{T}(reshape(A, length(A), 1)) to_matrix(T, A::AbstractMatrix) = convert(Matrix{T}, A) # Fallback to_matrix(T, A::Number) = fill(T(A), 1, 1) # Handle Adjoint Matrices -to_matrix(T, A::Adjoint{R, MT}) where {R<:Number, MT<:AbstractMatrix} = to_matrix(T, MT(A)) +to_matrix(T, A::Adjoint{R,MT}) where {R<:Number,MT<:AbstractMatrix} = to_matrix(T, MT(A)) to_abstract_matrix(A::AbstractMatrix) = A function to_abstract_matrix(A::AbstractArray) try - return convert(AbstractMatrix,A) + return convert(AbstractMatrix, A) catch @warn "Could not convert $(typeof(A)) to `AbstractMatrix`. A HeteroStateSpace must consist of AbstractMatrix." rethrow() @@ -35,7 +35,7 @@ to_abstract_matrix(A::Number) = fill(A, 1, 1) # Do no sorting of eigenvalues eigvalsnosort(args...; kwargs...) = eigvals(args...; sortby=nothing, kwargs...) -eigvalsnosort(A::StaticArraysCore.StaticMatrix; kwargs...) = eigvalsnosort(Matrix(A); kwargs...) +eigvalsnosort(A::StaticArraysCore.StaticMatrix; kwargs...) = eigvalsnosort(Matrix(A); kwargs...) roots(args...; kwargs...) = Polynomials.roots(args...; sortby=nothing, kwargs...) issemiposdef(A) = ishermitian(A) && minimum(real.(eigvals(A))) >= 0 @@ -44,7 +44,7 @@ issemiposdef(A::UniformScaling) = real(A.λ) >= 0 """ f = printpolyfun(var) `fun` Prints polynomial in descending order, with variable `var` """ -printpolyfun(var) = (io, p, mimetype = MIME"text/plain"()) -> Polynomials.printpoly(io, Polynomial(p.coeffs, var), mimetype, descending_powers=true, mulsymbol="") +printpolyfun(var) = (io, p, mimetype=MIME"text/plain"()) -> Polynomials.printpoly(io, Polynomial(p.coeffs, var), mimetype, descending_powers=true, mulsymbol="") # NOTE: Tolerances for checking real-ness removed, shouldn't happen from LAPACK? # TODO: This doesn't play too well with dual numbers.. @@ -53,14 +53,14 @@ printpolyfun(var) = (io, p, mimetype = MIME"text/plain"()) -> Polynomials.printp # This function rely on that the every complex roots is followed by its exact conjugate, # and that the first complex root in each pair has positive imaginary part. This format is always # returned by LAPACK routines for eigenvalues. -function roots2real_poly_factors(roots::Vector{cT}) where cT <: Number +function roots2real_poly_factors(roots::Vector{cT}) where {cT<:Number} T = real(cT) poly_factors = Vector{Polynomial{T,:x}}() - for k=1:length(roots) + for k = 1:length(roots) r = roots[k] if isreal(r) - push!(poly_factors,Polynomial{T,:x}([-real(r),1])) + push!(poly_factors, Polynomial{T,:x}([-real(r), 1])) else if imag(r) < 0 # This roots was handled in the previous iteration # TODO: Fix better error handling continue @@ -70,7 +70,7 @@ function roots2real_poly_factors(roots::Vector{cT}) where cT <: Number throw(ArgumentError("Found pole without matching conjugate.")) end - push!(poly_factors,Polynomial{T,:x}([real(r)^2+imag(r)^2, -2*real(r), 1])) + push!(poly_factors, Polynomial{T,:x}([real(r)^2 + imag(r)^2, -2 * real(r), 1])) # k += 1 # Skip one iteration in the loop end end @@ -78,7 +78,7 @@ function roots2real_poly_factors(roots::Vector{cT}) where cT <: Number return poly_factors end # This function should handle both Complex as well as symbolic types -function roots2poly_factors(roots::Vector{T}) where T <: Number +function roots2poly_factors(roots::Vector{T}) where {T<:Number} return Polynomial{T,:x}[Polynomial{T,:x}([-r, 1]) for r in roots] end @@ -96,7 +96,7 @@ function _fix_conjugate_pairs!(v::AbstractVector{<:Complex}) # Do nothing else if isapprox(v[k], conj(v[k+1]), rtol=1e-15) - z = (v[k] + conj(v[k+1]))/2 + z = (v[k] + conj(v[k+1])) / 2 v[k] = z v[k+1] = conj(z) k += 1 @@ -114,14 +114,14 @@ poly2vec(p::Polynomial) = p.coeffs[1:end] function unwrap!(M::Array, dim=1) - alldims(i) = ntuple(n->n==dim ? i : (1:size(M,n)), ndims(M)) + alldims(i) = ntuple(n -> n == dim ? i : (1:size(M, n)), ndims(M)) π2 = eltype(M)(2π) for i = 2:size(M, dim) #This is a copy of slicedim from the JuliaLang but enables us to write to it #The code (with dim=1) is equivalent to # d = M[i,:,:,...,:] - M[i-1,:,...,:] # M[i,:,:,...,:] -= floor((d+π) / (2π)) * 2π - d = M[alldims(i)...] - M[alldims(i-1)...] + d = M[alldims(i)...] - M[alldims(i - 1)...] M[alldims(i)...] -= @. floor((d + π) / π2) * π2 end return M @@ -196,7 +196,7 @@ macro autovec(indices, f) end end result = $(esc(fname))($(argnames...); - $(map(x->Expr(:(=), esc(x), esc(x)), kwargnames)...)) + $(map(x -> Expr(:(=), esc(x), esc(x)), kwargnames)...)) return $return_exp end end @@ -205,3 +205,21 @@ end function extractvarname(a) typeof(a) == Symbol ? a : extractvarname(a.args[1]) end + +""" + _tuple_to_range(t, freq) + +Helper function to convert a tuple of frequencies to a range of frequencies. +The vector is created by taking `freq` points logarithmically spaced every decade. + +# Example + + _tuple_to_range((1e-1, 1e5), 100) + +will create a range from 1e-1 to 1e5 with 100 points logarithmically spaced every decade. +""" +function _tuple_to_vec(t::Tuple{Real,Real}, freq::L) where {L<:Real} + w = log10.(t) + n_points = round(Int, freq * (w[2] - w[1])) + exp10.(LinRange(w[1], w[2], n_points)) +end \ No newline at end of file diff --git a/lib/ControlSystemsBase/test/test_freqresp.jl b/lib/ControlSystemsBase/test/test_freqresp.jl index 89a385e75..37075b59f 100644 --- a/lib/ControlSystemsBase/test/test_freqresp.jl +++ b/lib/ControlSystemsBase/test/test_freqresp.jl @@ -1,178 +1,197 @@ @testset "test_freqresp" begin -## EVALFR ## -H = [tf(0) tf([3, 0],[1, 1, 10]) ; tf([1, 1],[1, 5]) tf([2],[1, 6])] -G = ss([-5 0 0 0; 0 -1 -2.5 0; 0 4 0 0; 0 0 0 -6], [2 0; 0 1; 0 0; 0 2], - [0 3 0 0; -2 0 0 1], [0 0; 1 0]) - - - -@test evalfr(H, -6) == [0.0 -0.45; 5.0 Inf] -@test evalfr(H, -5) == [0.0 -0.5; Inf 2.0] -@test evalfr(H, -1) == [0.0 -0.3; 0.0 0.4] -@test evalfr(H, 0) ≈ [0.0 0.0; 0.2 1/3] -@inferred evalfr(H, 0) - -@test (@test_logs (:warn, "Got exception SingularException(4), returning Inf") evalfr(G, -6)) == [Inf Inf; Inf Inf] -@test (@test_logs (:warn, "Got exception SingularException(1), returning Inf") evalfr(G, -5)) == [Inf Inf; Inf Inf] -@test evalfr(G, -1) ≈ [0.0 -0.3; 0.0 0.4] -@test evalfr(G, 0) ≈ [0.0 0.0; 0.2 1/3] - -## Constant system -w = exp10.(range(-1, stop=1, length=50)) - -sys1 = ss(1) -sys1s = HeteroStateSpace(sparse([1])) -G1 = tf(1) -H1 = zpk(1) -resp1 = ones(ComplexF64, 1, 1, length(w)) - -@test evalfr(sys1, im*w[1]) == fill(resp1[1], 1, 1) -@test evalfr(G1, im*w[1]) == fill(resp1[1], 1, 1) -@test evalfr(H1, im*w[1]) == fill(resp1[1], 1, 1) - -@test freqresp(sys1, w) == resp1 -@test freqresp(sys1s, w) == resp1 -@test freqresp(G1, w) == resp1 -@test freqresp(H1, w) == resp1 -@inferred freqresp(sys1, w) -@inferred freqresp(G1, w) -@inferred freqresp(H1, w) - -for G in [2, 2I, 2I(2), randn(2,2)] - @test dcgain(G) == G - !(G isa UniformScaling) && @test freqresp(G, [1,2]) == cat(G,G, dims=3) # I does not have size - @test freqresp(G, 1) == G - @test evalfr(G, 1im) == G -end - -## First order system - -sys2 = ss(-1, 1, 1, 1) -sys2s = HeteroStateSpace(sparse([-1;;]),sparse([1;;]),sparse([1;;]),sparse([1;;])) # test that freqresp works for matrix types that don't support hessenberg -G2 = tf([1, 2], [1,1]) -H2 = zpk([-2], [-1.0], 1.0) -resp2 = reshape((im*w .+ 2)./(im*w .+ 1), 1, 1, length(w)) - -@test evalfr(sys2, im*w[1]) ≈ fill(resp2[1], 1, 1) -@test evalfr(G2, im*w[1]) == fill(resp2[1], 1, 1) -@test evalfr(H2, im*w[1]) == fill(resp2[1], 1, 1) - -@test freqresp(sys2, w) ≈ resp2 rtol=1e-15 -@test freqresp(sys2s, w) ≈ resp2 rtol=1e-15 -@test freqresp(G2, w) == resp2 -@test freqresp(H2, w) == resp2 - -@inferred freqresp(sys2, w) -@inferred freqresp(sys2s, w) -@inferred freqresp(G2, w) -@inferred freqresp(H2, w) -@test (@allocated freqresp(sys2, w)) < 1.2*56672 # allow 20% increase due to compiler variations -@test (@allocated freqresp(G2, w)) < 1.2*976 # allow 20% increase due to compiler variations - -## Complex-coefficient system -sys3 = ss(-1+im, 1, (1-im), (1-im)) -G3 = (1-im)*tf([1,2-im], [1,1-im]) -H3 = zpk([-2+im], [-1+im], (1-im)) -resp3 = reshape((1 .- im)*(2 .+ im*(w .- 1))./(1 .+ im*(w .- 1)), 1, 1, length(w)) - -@test evalfr(sys3, im*w[1]) ≈ fill(resp3[1], 1, 1) rtol=1e-16 -@test evalfr(G3, im*w[1]) == fill(resp3[1], 1, 1) -@test evalfr(H3, im*w[1]) == fill(resp3[1], 1, 1) - -@test freqresp(sys3, w) ≈ resp3 rtol=1e-15 -@test freqresp(G3, w) ≈ resp3 rtol=1e-16 -@test freqresp(H3, w) == resp3 - - - - -## Shortcut notation for evalfr ## -F = tf([1],[1,0.5],-1) -omega = 2 -z = 0.5(1+im) -# This test is not correct if we dont have a sample time -# @test_throws F(omega,true)[1] == 1/(exp(-im*2)+0.5) -@test F(z,false)[1] == 1/(z+0.5) -@test_throws ErrorException F(z,true) - -F = [tf([1],[1,0.5],2.0) 3*tf([1],[1,0.5],2.0)] -omegas = [1,2] -z = 0.5(1+im) -@test F(omegas[1],true) ≈ [1 3].*1/(exp(im*2)+0.5) atol=1e-14 -@test F(omegas[2],true) == [1 3].*1/(exp(2*im*2)+0.5) -@test F(omegas,true) ≈ [k/(exp(omega*im*2)+0.5) for o=1:1, k=[1,3], omega=omegas] atol=1e-14 -@test F(omegas,false) ≈ [k/(omega+0.5) for o=1:1, k=[1,3], omega=omegas] atol=1e-14 -@test F(z,false)[1] == 1/(z+0.5) -@test_throws ErrorException F(z,true) - -## Test bode, nyquist and sigma -sys = [tf([1,-1], [1,1,1]) 0; 0 tf([1],[1,1])] -f(s) = [(s-1)./(s.^2+s+1) 0; 0 1 ./(1+s)] -ws = exp10.(range(-2, stop=2, length=50)) -resp = Array{ComplexF64}(undef,2,2,50) -for (i,w) in enumerate(ws) - resp[:,:,i] = f(im*w) -end - -# A little test helper -Base.isapprox(t1::Tuple, t2::Tuple) = t1[1] ≈ t2[1] && t1[2] ≈ t2[2] - -@test bode(sys, ws)[1:2] ≈ (abs.(resp), rad2deg.(angle.(resp))) -workspace = BodemagWorkspace(sys, ws) -@test bode(sys, ws)[1] == bodemag!(workspace, sys, ws) -@test nyquist(sys, ws)[1:2] ≈ (real(resp), imag(resp)) -sigs = Array{Float64}(undef, 2, 50) -for i in eachindex(ws) - sigs[:, i] = svdvals(resp[:,:,i]) -end -@test sigma(sys, ws)[1] ≈ sigs - - -## Test sigma when either ny = 1 or nu = 1 -sys = ssrand(1,2,1) -resp = freqresp(sys, ws) -sigs = Array{Float64}(undef, 1, 50) -for i in eachindex(ws) - sigs[:, i] = svdvals(resp[:,:,i]) -end -@test sigma(sys, ws)[1] ≈ sigs - -sys = ssrand(2,1,1) -resp = freqresp(sys, ws) -sigs = Array{Float64}(undef, 1, 50) -for i in eachindex(ws) - sigs[:, i] = svdvals(resp[:,:,i]) -end -@test sigma(sys, ws)[1] ≈ sigs - - -# test unwrap -P = tf(1, [1, 1, 0]) * tf(1, [1, 1]) -w = exp10.(LinRange(-2, 4, 200)) -mag, ph = bode(P, w) -@test ph[end] ≈ -(180 + 90) rtol = 1e-2 - -# test explicit size and type constructor -ws2 = BodemagWorkspace{Float32}(2,2,length(ws)) -@test size(ws2.mag) == (2,2,length(ws)) -@test size(ws2.R) == (2,2,length(ws)) -@test ws2.mag isa Array{Float32, 3} -@test ws2.R isa Array{Complex{Float32}, 3} - -#Test default freq vector contains at least half a decade more than all features -p, z = 100, 1/1000 -sys2 = [tf([1],[1/p,1]) tf([1/z, 2/z, 1],[1])] -mag, phase, ws2 = bode(sys2) -@test maximum(ws2) >= 2max(p,z) -@test minimum(ws2) <= 0.5min(p,z) - -#Test default freq vector not too small -p, z = 1, 1.2 -sys2 = [tf([1],[1/p,1]) tf([1/z, 2/z, 1],[1])] -mag, mag, ws2 = bode(sys2) -@test maximum(ws2) >= 5max(p,z) -@test minimum(ws2) <= 0.2min(p,z) -@test length(ws2) > 100 + ## EVALFR ## + @testset "evalfr" begin + H = [tf(0) tf([3, 0], [1, 1, 10]); tf([1, 1], [1, 5]) tf([2], [1, 6])] + G = ss([-5 0 0 0; 0 -1 -2.5 0; 0 4 0 0; 0 0 0 -6], [2 0; 0 1; 0 0; 0 2], + [0 3 0 0; -2 0 0 1], [0 0; 1 0]) + + @test evalfr(H, -6) == [0.0 -0.45; 5.0 Inf] + @test evalfr(H, -5) == [0.0 -0.5; Inf 2.0] + @test evalfr(H, -1) == [0.0 -0.3; 0.0 0.4] + @test evalfr(H, 0) ≈ [0.0 0.0; 0.2 1/3] + @inferred evalfr(H, 0) + + @test (@test_logs (:warn, "Got exception SingularException(4), returning Inf") evalfr(G, -6)) == [Inf Inf; Inf Inf] + @test (@test_logs (:warn, "Got exception SingularException(1), returning Inf") evalfr(G, -5)) == [Inf Inf; Inf Inf] + @test evalfr(G, -1) ≈ [0.0 -0.3; 0.0 0.4] + @test evalfr(G, 0) ≈ [0.0 0.0; 0.2 1/3] + end + + w = exp10.(range(-1, stop=1, length=50)) + + @testset "Constant system" begin + + sys1 = ss(1) + sys1s = HeteroStateSpace(sparse([1])) + G1 = tf(1) + H1 = zpk(1) + resp1 = ones(ComplexF64, 1, 1, length(w)) + + @test evalfr(sys1, im * w[1]) == fill(resp1[1], 1, 1) + @test evalfr(G1, im * w[1]) == fill(resp1[1], 1, 1) + @test evalfr(H1, im * w[1]) == fill(resp1[1], 1, 1) + + @test freqresp(sys1, w) == resp1 + @test freqresp(sys1s, w) == resp1 + @test freqresp(G1, w) == resp1 + @test freqresp(H1, w) == resp1 + @inferred freqresp(sys1, w) + @inferred freqresp(G1, w) + @inferred freqresp(H1, w) + + for G in [2, 2I, 2I(2), randn(2, 2)] + @test dcgain(G) == G + !(G isa UniformScaling) && @test freqresp(G, [1, 2]) == cat(G, G, dims=3) # I does not have size + @test freqresp(G, 1) == G + @test evalfr(G, 1im) == G + end + + end + + @testset "First order system" begin + + sys2 = ss(-1, 1, 1, 1) + sys2s = HeteroStateSpace(sparse([-1;;]), sparse([1;;]), sparse([1;;]), sparse([1;;])) # test that freqresp works for matrix types that don't support hessenberg + G2 = tf([1, 2], [1, 1]) + H2 = zpk([-2], [-1.0], 1.0) + resp2 = reshape((im * w .+ 2) ./ (im * w .+ 1), 1, 1, length(w)) + + @test evalfr(sys2, im * w[1]) ≈ fill(resp2[1], 1, 1) + @test evalfr(G2, im * w[1]) == fill(resp2[1], 1, 1) + @test evalfr(H2, im * w[1]) == fill(resp2[1], 1, 1) + + @test freqresp(sys2, w) ≈ resp2 rtol = 1e-15 + @test freqresp(sys2s, w) ≈ resp2 rtol = 1e-15 + @test freqresp(G2, w) == resp2 + @test freqresp(H2, w) == resp2 + + @inferred freqresp(sys2, w) + @inferred freqresp(sys2s, w) + @inferred freqresp(G2, w) + @inferred freqresp(H2, w) + @test (@allocated freqresp(sys2, w)) < 1.2 * 56672 # allow 20% increase due to compiler variations + @test (@allocated freqresp(G2, w)) < 1.2 * 976 # allow 20% increase due to compiler variations + + end + + ## Complex-coefficient system + sys3 = ss(-1 + im, 1, (1 - im), (1 - im)) + G3 = (1 - im) * tf([1, 2 - im], [1, 1 - im]) + H3 = zpk([-2 + im], [-1 + im], (1 - im)) + resp3 = reshape((1 .- im) * (2 .+ im * (w .- 1)) ./ (1 .+ im * (w .- 1)), 1, 1, length(w)) + + @test evalfr(sys3, im * w[1]) ≈ fill(resp3[1], 1, 1) rtol = 1e-16 + @test evalfr(G3, im * w[1]) == fill(resp3[1], 1, 1) + @test evalfr(H3, im * w[1]) == fill(resp3[1], 1, 1) + + @test freqresp(sys3, w) ≈ resp3 rtol = 1e-15 + @test freqresp(G3, w) ≈ resp3 rtol = 1e-16 + @test freqresp(H3, w) == resp3 + + + ## Shortcut notation for evalfr ## + F = tf([1], [1, 0.5], -1) + omega = 2 + z = 0.5(1 + im) + # This test is not correct if we dont have a sample time + # @test_throws F(omega,true)[1] == 1/(exp(-im*2)+0.5) + @test F(z, false)[1] == 1 / (z + 0.5) + @test_throws ErrorException F(z, true) + + F = [tf([1], [1, 0.5], 2.0) 3 * tf([1], [1, 0.5], 2.0)] + omegas = [1, 2] + z = 0.5(1 + im) + @test F(omegas[1], true) ≈ [1 3] .* 1 / (exp(im * 2) + 0.5) atol = 1e-14 + @test F(omegas[2], true) == [1 3] .* 1 / (exp(2 * im * 2) + 0.5) + @test F(omegas, true) ≈ [k / (exp(omega * im * 2) + 0.5) for o = 1:1, k = [1, 3], omega = omegas] atol = 1e-14 + @test F(omegas, false) ≈ [k / (omega + 0.5) for o = 1:1, k = [1, 3], omega = omegas] atol = 1e-14 + @test F(z, false)[1] == 1 / (z + 0.5) + @test_throws ErrorException F(z, true) + + @testset "Test bode, nyquist and sigma" begin + # A little test helper + Base.isapprox(t1::Tuple, t2::Tuple) = t1[1] ≈ t2[1] && t1[2] ≈ t2[2] + + sys = [tf([1, -1], [1, 1, 1]) 0; 0 tf([1], [1, 1])] + f(s) = [(s-1)./(s .^ 2+s+1) 0; 0 1 ./ (1+s)] + + freq = 20 + start_dec = 10^-2 + stop_dec = 10^2 + + npoints = round(Int, (log10(stop_dec) - log10(start_dec)) * freq) + ws = exp10.(LinRange(log10(start_dec), log10(stop_dec), npoints)) + + resp = Array{ComplexF64}(undef, 2, 2, npoints) + for (i, w) in enumerate(ws) + resp[:, :, i] = f(im * w) + end + + @testset "bode" begin + @test bode(sys, (start_dec, stop_dec), freq) ≈ (abs.(resp), rad2deg.(angle.(resp)), ws) + @test bode(sys, ws)[1:2] ≈ (abs.(resp), rad2deg.(angle.(resp))) + + workspace = BodemagWorkspace(sys, ws) + @test bode(sys, ws)[1] == bodemag!(workspace, sys, ws) + end + + @test nyquist(sys, ws)[1:2] ≈ (real(resp), imag(resp)) + + sigs = Array{Float64}(undef, 2, npoints) + for i in eachindex(ws) + sigs[:, i] = svdvals(resp[:, :, i]) + end + @test sigma(sys, ws)[1] ≈ sigs + + + ## Test sigma when either ny = 1 or nu = 1 + sys = ssrand(1, 2, 1) + resp = freqresp(sys, ws) + sigs = Array{Float64}(undef, 1, npoints) + for i in eachindex(ws) + sigs[:, i] = svdvals(resp[:, :, i]) + end + @test sigma(sys, ws)[1] ≈ sigs + + sys = ssrand(2, 1, 1) + resp = freqresp(sys, ws) + sigs = Array{Float64}(undef, 1, npoints) + for i in eachindex(ws) + sigs[:, i] = svdvals(resp[:, :, i]) + end + @test sigma(sys, ws)[1] ≈ sigs + + end + + # test unwrap + @testset "unwrap" begin + P = tf(1, [1, 1, 0]) * tf(1, [1, 1]) + w = exp10.(LinRange(-2, 4, 200)) + mag, ph = bode(P, w) + @test ph[end] ≈ -(180 + 90) rtol = 1e-2 + end + + # test explicit size and type constructor + ws2 = BodemagWorkspace{Float32}(2, 2, length(ws)) + @test size(ws2.mag) == (2, 2, length(ws)) + @test size(ws2.R) == (2, 2, length(ws)) + @test ws2.mag isa Array{Float32,3} + @test ws2.R isa Array{Complex{Float32},3} + + #Test default freq vector contains at least half a decade more than all features + p, z = 100, 1 / 1000 + sys2 = [tf([1], [1 / p, 1]) tf([1 / z, 2 / z, 1], [1])] + mag, phase, ws2 = bode(sys2) + @test maximum(ws2) >= 2max(p, z) + @test minimum(ws2) <= 0.5min(p, z) + + #Test default freq vector not too small + p, z = 1, 1.2 + sys2 = [tf([1], [1 / p, 1]) tf([1 / z, 2 / z, 1], [1])] + mag, mag, ws2 = bode(sys2) + @test maximum(ws2) >= 5max(p, z) + @test minimum(ws2) <= 0.2min(p, z) + @test length(ws2) > 100 end