Skip to content

Fix stability_margins missing a phase crossover at the Nyquist frequency for discrete-time systems - #1271

Closed
gimballock wants to merge 2 commits into
python-control:mainfrom
gimballock:fix-discrete-nyquist-margin
Closed

gimballock wants to merge 2 commits into
python-control:mainfrom
gimballock:fix-discrete-nyquist-margin

Conversation

@gimballock

Copy link
Copy Markdown

Fixes #1267. This resubmits #1268 by @RoboX2020, which was closed before review; its two commits are unchanged.

What it does, for discrete-time systems:

How it was checked:

Disclosure: written with an AI agent, Claude Code (Anthropic's Claude Opus 5.5 model). The code is #1268's, by @RoboX2020, unchanged; its author has said it was bot-written. The agent ran the checks above, reviewed the diff and wrote this description. Before posting, it walked me through the change and this description, with a short quiz on the key points: the bug, why the fix checks z = −1 directly, and what it leaves to #1266. I didn't write any of the code.

For a discrete-time loop whose phase reaches -180 degrees exactly at
w = pi/dt, stability_margins returned gm = inf under every method.
For real coefficients L(-1) is always real, so this is a common case:
L(z) = 1/(z-1) has a phase crossover at z = -1 with gm = 2, but
'poly', 'frd' and 'best' all reported gm = inf.

- _z_filter kept only unit-circle roots with 0 <= arg(z) < pi, dropping
  the root at z = -1. Now also keep roots whose argument is exactly +pi
  or exactly -pi (the root-finder returns an exact imaginary zero for a
  real root, with either sign) and report the endpoint frequency via
  abs(arg). No tolerance is applied to the angle, so a genuine root
  near, but not at, -1 keeps its computed frequency and its lower-half
  conjugate stays excluded, exactly as before.
- The 'frd' path detects phase crossovers as sign changes inside the
  frequency grid, which excludes w = pi/dt for discrete-time systems.
  When a transfer function is converted to FRD, evaluate L(-1) and count
  it as a phase crossover if it is real and negative and pi/dt >= epsw.
  (Other FRD-detected crossings ignore epsw; that pre-existing behavior
  is unchanged.)

This also makes phase_crossover_frequencies include the real-axis
intersection at w = pi/dt for discrete-time systems, matching the w = 0
endpoint that was already returned; one existing test reference is
updated accordingly.

Adds regression tests: gm at the Nyquist endpoint under all three
methods, a loop with L(-1) > 0 that must keep gm = inf, and a _z_filter
unit test covering the exact endpoint and a nearby conjugate root pair.
The new tests fail at baseline and pass with the fix.

Fixes python-control#1267
@slivingston

Copy link
Copy Markdown
Member

In explaining your contribution, do not use AI to automatically generate comments, pull request descriptions, or issue descriptions.

https://numpy.org/doc/stable/dev/ai_policy.html

@slivingston slivingston closed this Nov 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

stability_margins misses a phase crossover at the Nyquist frequency (w = pi/dt) for discrete-time systems, so gm is inf when L(-1) < 0

3 participants