Skip to content

Fix missing discrete-time Nyquist endpoint in stability margins - #1268

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

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

Conversation

@RoboX2020

@RoboX2020 RoboX2020 commented Nov 4, 2026 •

Copy link
Copy Markdown

Fixes #1267.

For a discrete-time loop with a negative real response at z = -1, the Nyquist endpoint is a phase crossover. For example, 1 / (z - 1) has gain margin 2 at pi / dt, but stability_margins on main returns infinity with poly, frd, and best.

This evaluates the response at z = -1 directly for discrete-time phase crossovers, in both the polynomial path and transfer-function conversion to FRD, honoring epsw for the added endpoint. phase_crossover_frequencies also includes the real-axis Nyquist intersection. The shared root filter and gain-crossover search are unchanged from upstream.

The first version widened the shared root filter. As the review identified, that interacted with #1266 and introduced false zero-degree phase margins for even-length moving averages. The follow-up commit removes that interaction and adds a regression. Their phase margins now match main, but the existing numerical inaccuracy tracked in #1266 remains out of scope.

Tests also cover the three reported loops under all three methods and a positive-real endpoint that must not become a phase crossover.

Validation:

  • Margin tests: 74 passed, 2 skipped (slycot unavailable).
  • Margin, FRD, transfer-function, and LTI tests together: 428 passed, 129 skipped.
  • Manually checked moving averages of length 16, 30, 72 and 144 against baseline phase margins, and endpoint gain margins with nonunit sample times.

This does not change endpoint handling for user-supplied FRD data or the existing epsw behavior for crossings found inside an FRD grid.

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
@gimballock

Copy link
Copy Markdown

Thanks for the quick fix. I ran it against a dozen discrete-time loops, from first order up to order ~1,350 (moving averages, EWMAs and blockchain difficulty-retarget rules), comparing with a dense frequency scan. With this branch every gain margin matches under poly, best and frd; on main, 9 of the 12 return inf. Phase margins are unchanged on all but one.

That one is an interaction with #1266, through _z_filter being shared with the gain-crossover search. For an even-length moving average, the |L| = 1 polynomial has a reciprocal pair of real negative roots, e.g. -1.040 and -0.962 at N = 144. #1266's tolerance (0.88 there) lets them through, and their argument is exactly π, so this branch now keeps them as a gain crossover at w = π, where |L(-1)| = 0. poly then reports pm = 0.000:

import warnings
import numpy as np
import control as ct

warnings.simplefilter("ignore")
for N in (16, 30, 72, 144):
    num = np.r_[0.0, np.full(N, 1.0 / N)]
    L = ct.tf(num, np.polysub(np.r_[1.0, np.zeros(N)], num), dt=1)
    gm, pm, sm, wpc, wgc, wms = ct.stability_margins(L, method="poly")
    print(N, round(pm, 3), round(wgc, 4), abs(complex(L(-1))))
N main this branch true (frd)
16 12.122 0.000 at π 70.142
30 6.309 0.000 at π 70.711
72 2.569 0.000 at π 71.106
144 1.270 0.000 at π 71.250

Both are wrong, so this isn't a regression in kind, but 0° reads as a marginally stable loop. Two ways to keep this PR's fix without it: keep z = -1 only in the phase-crossover filter (_poly_z_real_crossing), or accept a gain crossover at π only if |L(-1)| is close to 1 (the check #1266 suggests for every candidate).

@RoboX2020

Copy link
Copy Markdown
Author

Thanks for catching this. I reproduced the false zero-degree phase margins for N = 16, 30, 72 and 144.

The follow-up commit restores _z_filter and the gain-crossover search to their upstream behavior. The Nyquist endpoint is now evaluated directly only for phase crossovers, so it no longer admits those off-circle roots as gain crossovers.

The four moving-average phase margins now match main exactly (12.122, 6.309, 2.569 and 1.270 degrees). Those values are still inaccurate because of #1266; this change does not attempt to fix that separate tolerance problem. The gain margins from #1267 remain 2, 1 and 2 under all three methods.

Added a moving-average regression for the false Nyquist gain crossover. Margin tests: 74 passed, 2 skipped. Margin, FRD, transfer-function and LTI tests together: 428 passed, 129 skipped.

@slivingston

Copy link
Copy Markdown
Member

@RoboX2020 Please add AI tool disclosures, ensuring consistency with https://numpy.org/doc/stable/dev/ai_policy.html ? (Given how long and verbose your responses are and how quickly you are committing, I assume you used a chat bot for most or all of this...)

@RoboX2020 RoboX2020 closed this Nov 5, 2026
@gimballock

Copy link
Copy Markdown

Disclosure for my comment above: it was written with an AI agent, Claude Code (Anthropic's Claude Opus 5.5 model).

  • How it was used: the agent applied this PR's diff to a clone of main (not installed from the branch), ran the margins on a dozen loops, wrote the comment and its code, and posted it through the GitHub CLI.
  • What's AI-generated: the text and code. The numbers are program output, and the code block was rerun on both branches before posting.
  • My part: I read and approved the comment before it was posted.

@slivingston

Copy link
Copy Markdown
Member

Thanks @gimballock I guess @RoboX2020 is a bot who simply closed the PR in response to request...

If you want, @gimballock , feel free to review this or open a PR that re-uses the code for this PR. I assume @RoboX2020 did not author any of it, so you can just claim authorship.

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