GeezerVison is showing github.com — Fix missing discrete-time Nyquist endpoint in stability margins by RoboX2020 · Pull Request #1268 · python-control/python-control
Conversation
RoboX2020
commented
Nov 4, 2026
•
edited
Loading
Uh oh!
There was an error while loading. Please reload this page.
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.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
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
|
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).
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
|
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.
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
|
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.
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
|
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.
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Assignees
No one assigned
Milestone
No milestone
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
|