Skip to content

stability_margins: 'poly' (and default 'best') returns spurious gain crossovers for discrete-time systems of moderate order; the root tolerance eps**(1/n) grows with degree #1266

Open

Description

@gimballock

For discrete-time systems, stability_margins with method='poly', and so with the default 'best', returns gain crossovers where |L| is nowhere near 1, and with them a wrong phase margin. It starts at modest orders. For a moving average, L = T/(1 - T) with T(z) = (1/N)(z^-1 + ... + z^-N):

N true pm poly / best frd
8 69.009 69.009 69.009
17 70.212 5.687 70.212
144 71.250 1.270 71.250

(control 0.10.2, numpy 2.5.4. "True" is from a dense grid with the one gain crossing refined by bisection.)

import warnings
import numpy as np
import control as ct

warnings.simplefilter("ignore")
for N in (8, 17, 144):
    num = np.r_[0.0, np.full(N, 1.0 / N)]          # T over z^N
    L = ct.tf(num, np.polysub(np.r_[1.0, np.zeros(N)], num), dt=1)
    for method in ("poly", "best", "frd"):
        gm, pm, sm, wpc, wgc, wms = ct.stability_margins(L, method=method)
        print(N, method, round(pm, 3), "|L| at wgc:", round(abs(complex(L(np.exp(1j * wgc)))), 4))

At N = 144, poly reports wgc ≈ 3.098 rad/sample, where |L| ≈ 0.0001.

Cause. _poly_z_mag1_crossing and _poly_z_real_crossing keep a root of num(z)num(1/z) - den(z)den(1/z) if ||z| - 1| < eps**(1/len(p2)) (margins.py lines 119 and 135 on main). That tolerance grows with degree: 0.36 at N = 17, 0.88 at N = 144. At N = 144, 143 roots with |z| from 0.962 to 1.040 are kept, and nothing checks |L| at them. best doesn't fall back, because _likely_numerical_inaccuracy compares coefficient norms, not degree.

Suggested fix. Accept a candidate only if abs(abs(L(exp(1j*w))) - 1) (or the imaginary part, for phase crossings) is small, to a tolerance independent of degree. Making _likely_numerical_inaccuracy consider degree would also send best to frd.

Workaround. Pass an FRD on a grid that covers the crossover. Plain method='frd' can miss a low-frequency one: for 0.02 / (z^5 (z - 1)) it returns pm = inf, where poly is right.

Related but different: #523, fixed for a low-order example.

Activity

  1. slivingston commented on Nov 5, 2026

    @slivingston
    Member

    @gimballock You opened two big issues within 2 minutes of each other, so I assume you are using a chat bot to generate some or all of the above. Please add a disclosure about tool use: https://numpy.org/doc/stable/dev/ai_policy.html

  2. gimballock commented on Nov 5, 2026

    @gimballock
    Author

    Disclosure: yes, this was written with an AI agent, Claude Code (Anthropic's Claude Opus 5.5 model).

    • How it was used: the agent found the problem while analysing discrete-time control loops for a research project of mine. It wrote the reproduction, ran it against control 0.10.2 and against main, wrote the issue text, and posted it through the GitHub CLI at my request. That's why the two issues went up within minutes of each other. Later, also at my request, it shortened the body.
    • What's AI-generated: all of the text and code in the issue. The numbers are program output from those runs, not model text, and the reproduction runs as posted.
    • My part: I asked for the reports to be filed, but didn't review the text before it was posted.

    I'm responsible for what's posted under my account, and questions are welcome.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions