From 3a1403ac237007093eb68aa3a9c662845bc4e5d7 Mon Sep 17 00:00:00 2001 From: Fredrik Bagge Carlson Date: Fri, 10 Jul 2026 19:48:57 +0000 Subject: [PATCH 1/3] Add polar-axis option to Makie nyquistplot Adds a `polar` keyword to `CSMakie.nyquistplot` that draws the Nyquist curve in a `Makie.PolarAxis`, providing native polar grid lines (circles of constant magnitude and rays of constant phase) that adapt to the axis limits. A `rlimits` keyword allows limiting the radial axis, useful for systems with poles on or near the imaginary axis. Requested in discussion JuliaControl/ControlSystems.jl#1064 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KU255FUfQGyZa2cziDYf7C --- docs/src/lib/plotting.md | 2 +- .../ext/ControlSystemsBaseMakieExt.jl | 68 +++++++++++-------- lib/ControlSystemsBase/src/CSMakie.jl | 7 ++ .../test/test_makie_plots.jl | 10 +++ 4 files changed, 58 insertions(+), 29 deletions(-) diff --git a/docs/src/lib/plotting.md b/docs/src/lib/plotting.md index f04a72069..6aaa17586 100644 --- a/docs/src/lib/plotting.md +++ b/docs/src/lib/plotting.md @@ -152,7 +152,7 @@ plot(si) # Visualizes step response characteristics The `CSMakie` module provides Makie implementations of the following plotting functions: - `CSMakie.bodeplot` - Bode magnitude and phase plots -- `CSMakie.nyquistplot` - Nyquist plots with optional M and Mt circles +- `CSMakie.nyquistplot` - Nyquist plots with optional M and Mt circles. Pass `polar=true` to draw the plot in a polar axis with circular grid lines. - `CSMakie.sigmaplot` - Singular value plots - `CSMakie.marginplot` - Gain and phase margin plots - `CSMakie.pzmap` - Pole-zero maps diff --git a/lib/ControlSystemsBase/ext/ControlSystemsBaseMakieExt.jl b/lib/ControlSystemsBase/ext/ControlSystemsBaseMakieExt.jl index cf544c1d8..f8786a5cb 100644 --- a/lib/ControlSystemsBase/ext/ControlSystemsBaseMakieExt.jl +++ b/lib/ControlSystemsBase/ext/ControlSystemsBaseMakieExt.jl @@ -232,36 +232,48 @@ function CSMakie.nyquistplot(args...; kwargs...) fig = Figure() CSMakie.nyquistplot!(fig, args...; kwargs...) end -function CSMakie.nyquistplot!(fig, systems::Union{LTISystem, AbstractVector{<:LTISystem}}, - w=nothing; Ms_circles=Float64[], Mt_circles=Float64[], - unit_circle=false, hz=false, critical_point=-1, - balance=true, adaptive=true, kwargs...) +function CSMakie.nyquistplot!(fig, systems::Union{LTISystem, AbstractVector{<:LTISystem}}, + w=nothing; Ms_circles=Float64[], Mt_circles=Float64[], + unit_circle=false, hz=false, critical_point=-1, + balance=true, adaptive=true, polar=false, + rlimits=(:origin, nothing), kwargs...) systems_vec = systems isa AbstractVector ? systems : [systems] - systems, w = isnothing(w) ? _processfreqplot(Val{:nyquist}(), systems_vec; adaptive) : + systems, w = isnothing(w) ? _processfreqplot(Val{:nyquist}(), systems_vec; adaptive) : _processfreqplot(Val{:nyquist}(), systems_vec, w; adaptive) - + ny, nu = size(systems[1]) gl = GridLayout(fig[1, 1]) - + # Create axes grid axes = Matrix{Any}(undef, ny, nu) - + for j in 1:nu for i in 1:ny - ax = Axis(gl[i, j], - aspect = DataAspect(), - xlabel = j == ny ? "Real" : "", - ylabel = i == 1 ? "Imaginary" : "", - title = i == 1 && j == 1 ? "Nyquist Plot" : "") + if polar + ax = PolarAxis(gl[i, j], + thetaticks = Makie.AngularTicks(180/pi, "°"), + rlimits = rlimits, + title = i == 1 && j == 1 ? "Nyquist Plot" : "") + else + ax = Axis(gl[i, j], + aspect = DataAspect(), + xlabel = j == ny ? "Real" : "", + ylabel = i == 1 ? "Imaginary" : "", + title = i == 1 && j == 1 ? "Nyquist Plot" : "") + + # Add grid lines at zero + vlines!(ax, 0, color=:gray, alpha=0.3, linewidth=0.5) + hlines!(ax, 0, color=:gray, alpha=0.3, linewidth=0.5) + end axes[i, j] = ax - - # Add grid lines at zero - vlines!(ax, 0, color=:gray, alpha=0.3, linewidth=0.5) - hlines!(ax, 0, color=:gray, alpha=0.3, linewidth=0.5) end end - + + # Map Cartesian coordinates to the coordinates expected by the axis type; + # PolarAxis expects (angle, radius) tuples + coords = polar ? (x, y) -> (atan.(y, x), hypot.(x, y)) : tuple + θ = range(0, 2π, length=100) for (si, s) in enumerate(systems) @@ -280,37 +292,37 @@ function CSMakie.nyquistplot!(fig, systems::Union{LTISystem, AbstractVector{<:LT indsre = downsample(w, redata, 1/500)[3] indsim = downsample(w, imdata, 1/500)[3] inds = sort!(union(indsre, indsim)) - lines!(ax, redata[inds], imdata[inds], label=lab) + lines!(ax, coords(redata[inds], imdata[inds])..., label=lab) else - lines!(ax, redata, imdata, label=lab) + lines!(ax, coords(redata, imdata)..., label=lab) end - + # Add circles and critical point for last system if si == length(systems) # Ms circles for Ms in Ms_circles - lines!(ax, -1 .+ (1/Ms) .* cos.(θ), (1/Ms) .* sin.(θ), + lines!(ax, coords(-1 .+ (1/Ms) .* cos.(θ), (1/Ms) .* sin.(θ))..., color=:gray, linestyle=:dash, alpha=0.5, label="Ms = $(round(Ms, digits=2))") end - + # Mt circles for Mt in Mt_circles ct = -Mt^2/(Mt^2-1) rt = Mt/(abs(Mt^2-1)) - lines!(ax, ct .+ rt .* cos.(θ), rt .* sin.(θ), + lines!(ax, coords(ct .+ rt .* cos.(θ), rt .* sin.(θ))..., color=:gray, linestyle=:dash, alpha=0.5, label="Mt = $(round(Mt, digits=2))") end - + # Unit circle if unit_circle - lines!(ax, cos.(θ), sin.(θ), + lines!(ax, coords(cos.(θ), sin.(θ))..., color=:gray, linestyle=:dash, alpha=0.5) end - + # Critical point - scatter!(ax, [critical_point], [0], + scatter!(ax, coords([critical_point], [0])..., marker=:xcross, markersize=15, color=:red) end end diff --git a/lib/ControlSystemsBase/src/CSMakie.jl b/lib/ControlSystemsBase/src/CSMakie.jl index a681afde0..1f73a35fc 100644 --- a/lib/ControlSystemsBase/src/CSMakie.jl +++ b/lib/ControlSystemsBase/src/CSMakie.jl @@ -25,6 +25,13 @@ using ..ControlSystemsBase: LTISystem function bodeplot end function bodeplot! end +""" + nyquistplot(sys, [w]; Ms_circles=Float64[], Mt_circles=Float64[], unit_circle=false, critical_point=-1, balance=true, adaptive=true, polar=false, rlimits=(:origin, nothing)) + +Makie version of [`ControlSystemsBase.nyquistplot`](@ref). In addition to the keyword arguments of the Plots version, this version supports +- `polar`: If `true`, the Nyquist curve is drawn in a `Makie.PolarAxis` with polar grid lines (circles of constant magnitude and rays of constant phase) instead of a Cartesian axis. +- `rlimits`: Radial axis limits, e.g., `(0, 3)`. Only used when `polar = true`. Limiting the radial axis is useful when the system has poles on or close to the imaginary axis, causing the Nyquist curve to reach very large magnitudes. +""" function nyquistplot end function nyquistplot! end diff --git a/lib/ControlSystemsBase/test/test_makie_plots.jl b/lib/ControlSystemsBase/test/test_makie_plots.jl index 88064ed55..52e6db536 100644 --- a/lib/ControlSystemsBase/test/test_makie_plots.jl +++ b/lib/ControlSystemsBase/test/test_makie_plots.jl @@ -72,6 +72,16 @@ import CairoMakie.Makie fig = CSMakie.nyquistplot([P, P2]; unit_circle=true) @test fig isa Makie.Figure end + @test_nowarn begin + fig = CSMakie.nyquistplot(P; polar=true, rlimits=(0, 3), + Ms_circles=[1.5], Mt_circles=[1.5], unit_circle=true) + @test fig isa Makie.Figure + @test any(x -> x isa Makie.PolarAxis, fig.content) + end + @test_nowarn begin + fig = CSMakie.nyquistplot(Pmimo; polar=true) + @test fig isa Makie.Figure + end end @testset "sigmaplot" begin From 169386ee36c75cee98c473df2c138cee560d7c82 Mon Sep 17 00:00:00 2001 From: Fredrik Bagge Carlson Date: Fri, 10 Jul 2026 20:00:53 +0000 Subject: [PATCH 2/3] =?UTF-8?q?Label=20polar=20Nyquist=20angles=20in=20(-1?= =?UTF-8?q?80=C2=B0,=20180=C2=B0]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase lag of a typical system now reads as a negative angle in the lower half-plane, following the convention of polar Nyquist charts in textbooks. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KU255FUfQGyZa2cziDYf7C --- lib/ControlSystemsBase/ext/ControlSystemsBaseMakieExt.jl | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/ControlSystemsBase/ext/ControlSystemsBaseMakieExt.jl b/lib/ControlSystemsBase/ext/ControlSystemsBaseMakieExt.jl index f8786a5cb..f66d7440c 100644 --- a/lib/ControlSystemsBase/ext/ControlSystemsBaseMakieExt.jl +++ b/lib/ControlSystemsBase/ext/ControlSystemsBaseMakieExt.jl @@ -251,8 +251,13 @@ function CSMakie.nyquistplot!(fig, systems::Union{LTISystem, AbstractVector{<:LT for j in 1:nu for i in 1:ny if polar + # Label angles in (-180°, 180°] so that the phase lag of a typical + # system reads as a negative angle in the lower half-plane, + # following the convention of polar Nyquist charts in textbooks + thetatickvalues = range(0, 2π, step=π/4)[1:end-1] + thetaticklabels = [string(round(Int, rad2deg(v > π ? v - 2π : v)), "°") for v in thetatickvalues] ax = PolarAxis(gl[i, j], - thetaticks = Makie.AngularTicks(180/pi, "°"), + thetaticks = (collect(thetatickvalues), thetaticklabels), rlimits = rlimits, title = i == 1 && j == 1 ? "Nyquist Plot" : "") else From 856cb66c6db07ea7bf99026e48a6f251075170d3 Mon Sep 17 00:00:00 2001 From: Fredrik Bagge Carlson Date: Sat, 11 Jul 2026 11:34:54 +0000 Subject: [PATCH 3/3] Forward extra keyword arguments to axis constructor in Makie nyquistplot Allows customizing grid density and appearance through PolarAxis/Axis attributes such as rticks, thetaminorticks and rminorgridvisible. Unknown keyword arguments now error instead of being silently ignored. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KU255FUfQGyZa2cziDYf7C --- .../ext/ControlSystemsBaseMakieExt.jl | 12 ++++++++---- lib/ControlSystemsBase/src/CSMakie.jl | 9 ++++++++- lib/ControlSystemsBase/test/test_makie_plots.jl | 14 ++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/lib/ControlSystemsBase/ext/ControlSystemsBaseMakieExt.jl b/lib/ControlSystemsBase/ext/ControlSystemsBaseMakieExt.jl index f66d7440c..d28300391 100644 --- a/lib/ControlSystemsBase/ext/ControlSystemsBaseMakieExt.jl +++ b/lib/ControlSystemsBase/ext/ControlSystemsBaseMakieExt.jl @@ -256,16 +256,20 @@ function CSMakie.nyquistplot!(fig, systems::Union{LTISystem, AbstractVector{<:LT # following the convention of polar Nyquist charts in textbooks thetatickvalues = range(0, 2π, step=π/4)[1:end-1] thetaticklabels = [string(round(Int, rad2deg(v > π ? v - 2π : v)), "°") for v in thetatickvalues] - ax = PolarAxis(gl[i, j], + # kwargs... are forwarded to PolarAxis and may override the defaults + ax = PolarAxis(gl[i, j]; thetaticks = (collect(thetatickvalues), thetaticklabels), rlimits = rlimits, - title = i == 1 && j == 1 ? "Nyquist Plot" : "") + title = i == 1 && j == 1 ? "Nyquist Plot" : "", + kwargs...) else - ax = Axis(gl[i, j], + # kwargs... are forwarded to Axis and may override the defaults + ax = Axis(gl[i, j]; aspect = DataAspect(), xlabel = j == ny ? "Real" : "", ylabel = i == 1 ? "Imaginary" : "", - title = i == 1 && j == 1 ? "Nyquist Plot" : "") + title = i == 1 && j == 1 ? "Nyquist Plot" : "", + kwargs...) # Add grid lines at zero vlines!(ax, 0, color=:gray, alpha=0.3, linewidth=0.5) diff --git a/lib/ControlSystemsBase/src/CSMakie.jl b/lib/ControlSystemsBase/src/CSMakie.jl index 1f73a35fc..562a39302 100644 --- a/lib/ControlSystemsBase/src/CSMakie.jl +++ b/lib/ControlSystemsBase/src/CSMakie.jl @@ -26,11 +26,18 @@ function bodeplot end function bodeplot! end """ - nyquistplot(sys, [w]; Ms_circles=Float64[], Mt_circles=Float64[], unit_circle=false, critical_point=-1, balance=true, adaptive=true, polar=false, rlimits=(:origin, nothing)) + nyquistplot(sys, [w]; Ms_circles=Float64[], Mt_circles=Float64[], unit_circle=false, critical_point=-1, balance=true, adaptive=true, polar=false, rlimits=(:origin, nothing), kwargs...) Makie version of [`ControlSystemsBase.nyquistplot`](@ref). In addition to the keyword arguments of the Plots version, this version supports - `polar`: If `true`, the Nyquist curve is drawn in a `Makie.PolarAxis` with polar grid lines (circles of constant magnitude and rays of constant phase) instead of a Cartesian axis. - `rlimits`: Radial axis limits, e.g., `(0, 3)`. Only used when `polar = true`. Limiting the radial axis is useful when the system has poles on or close to the imaginary axis, causing the Nyquist curve to reach very large magnitudes. +- `kwargs...`: Any remaining keyword arguments are forwarded to the axis constructor (`Makie.PolarAxis` when `polar = true`, otherwise `Makie.Axis`) and may override the defaults set by this function. This allows customization of, e.g., grid density and appearance: +```julia +CSMakie.nyquistplot(sys; polar=true, rlimits=(0, 3), + rticks = 0:0.5:3, # magnitude circles + thetaminorticks = Makie.IntervalsBetween(3), # phase rays every 15° + thetaminorgridvisible = true, rminorgridvisible = true) +``` """ function nyquistplot end function nyquistplot! end diff --git a/lib/ControlSystemsBase/test/test_makie_plots.jl b/lib/ControlSystemsBase/test/test_makie_plots.jl index 52e6db536..dd0c4a911 100644 --- a/lib/ControlSystemsBase/test/test_makie_plots.jl +++ b/lib/ControlSystemsBase/test/test_makie_plots.jl @@ -82,6 +82,20 @@ import CairoMakie.Makie fig = CSMakie.nyquistplot(Pmimo; polar=true) @test fig isa Makie.Figure end + # Extra keyword arguments are forwarded to the axis constructor + @test_nowarn begin + fig = CSMakie.nyquistplot(P; polar=true, rlimits=(0, 3), + rticks=0:0.5:3, + thetaminorticks=Makie.IntervalsBetween(3), + thetaminorgridvisible=true, rminorgridvisible=true) + @test fig isa Makie.Figure + end + @test_nowarn begin + fig = CSMakie.nyquistplot(P; xgridvisible=false, title="Custom title") + @test fig isa Makie.Figure + end + # Unsupported keyword arguments now error instead of being silently ignored + @test_throws Exception CSMakie.nyquistplot(P; not_a_keyword=true) end @testset "sigmaplot" begin