Powered by AppSignal & Oban Pro

Nyquist Plot Example

examples/nyquist_plot.livemd

Nyquist Plot Example

Setup

Mix.install([
  {:nx, "~> 0.13"},
  {:nx_lapack, path: System.fetch_env!("HOME") <> "/Documents/forks/nx_lapack"},
  {:nx_control, path: System.fetch_env!("HOME") <> "/Documents/forks/nx_control"},
  {:vega_lite, "~> 0.1"},
  {:kino_vega_lite, "~> 0.1"}
])

System Definition

Consider an open-loop transfer function:

[ G(s) = \frac{1}{s(s+1)(s+10)} ]

tf = NxControl.TransferFunction.new([1], [1, 11, 10, 0])

Nyquist Data

{real, imag} = NxControl.FrequencyResp.nyquist_data(tf)

real_list = Nx.to_flat_list(real)
imag_list = Nx.to_flat_list(imag)
imag_neg = Enum.map(imag_list, &(-&1))

Plot

# Positive frequency (ω ≥ 0) — solid blue line
pos = %{
  real: real_list,
  imag: imag_list
}

# Negative frequency (ω < 0) — dashed blue line
neg = %{
  real: Enum.reverse(real_list),
  imag: Enum.reverse(imag_neg)
}

# Critical point (-1, j0)
crit = %{
  real: [-1.0],
  imag: [0.0]
}

Vl.new(width: 500, height: 500, title: "Nyquist Plot")
|> Vl.layers([
  Vl.new()
  |> Vl.data_from_values(pos)
  |> Vl.mark(:line, color: "steelblue")
  |> Vl.encode_field(:x, "real", type: :quantitative, title: "Re(G(jω))")
  |> Vl.encode_field(:y, "imag", type: :quantitative, title: "Im(G(jω))"),
  Vl.new()
  |> Vl.data_from_values(neg)
  |> Vl.mark(:line, color: "steelblue", opacity: 0.4, stroke_dash: [4, 4])
  |> Vl.encode_field(:x, "real", type: :quantitative)
  |> Vl.encode_field(:y, "imag", type: :quantitative),
  Vl.new()
  |> Vl.data_from_values(crit)
  |> Vl.mark(:point, color: "red", size: 100, shape: "cross")
  |> Vl.encode_field(:x, "real", type: :quantitative)
  |> Vl.encode_field(:y, "imag", type: :quantitative)
])

Nyquist Stability Criterion

From the Nyquist stability criterion:

[ Z = P - N ]

where:

  • P = number of unstable open-loop poles (Re > 0)
  • N = number of counter-clockwise encirclements of (-1, j0)
  • Z = number of unstable closed-loop poles
# Open-loop poles
poles = NxControl.TransferFunction.poles(tf)
p_unstable = Nx.to_flat_list(Nx.real(poles))
|> Enum.count(&(&1 > 1.0e-10))

IO.puts "Unstable open-loop poles P = #{p_unstable}"

# Check if the curve passes near the critical point (-1, j0)
near_critical = Enum.zip(real_list, imag_list)
|> Enum.any?(fn {r, i} -> (r + 1) ** 2 + i ** 2 < 0.01 end)

IO.puts "Curve near critical point? #{near_critical}"

Stability Margins

{gm_mag, gm_phase, pm_freq, pm_phase} = NxControl.FrequencyResp.margins(tf)

IO.puts "Gain Margin: #{if gm_mag == :infinity, do: "∞", else: Float.round(gm_mag, 4)}"
IO.puts "Phase Margin: #{if pm_phase == nil, do: "—", else: "#{Float.round(pm_phase, 2)}°"}"