Skip to content

Commit 05006bf

Browse files
committed
Restrict Nyquist endpoint handling to phase crossovers
1 parent 4f168cd commit 05006bf

2 files changed

Lines changed: 41 additions & 27 deletions

File tree

‎control/margins.py‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,12 +102,8 @@ def _z_filter(z, dt, eps):
102102
# |z| == 1 with some float precision tolerance
103103
z = z[np.abs(np.abs(z) - 1.) < eps]
104104
zarg = np.angle(z)
105-
# also keep the Nyquist endpoint z = -1: its argument is exactly pi,
106-
# or exactly -pi when the root carries a negative imaginary zero.
107-
# No tolerance is applied to the angle: a genuine root near, but not
108-
# at, -1 must keep its computed frequency. abs() maps -pi to +pi.
109-
zidx = ((0 <= zarg) * (zarg < np.pi)) | (np.abs(zarg) == np.pi)
110-
omega = np.abs(zarg[zidx]) / dt
105+
zidx = (0 <= zarg) * (zarg < np.pi)
106+
omega = zarg[zidx] / dt
111107
return z[zidx], omega
112108

113109

@@ -349,6 +345,18 @@ def stability_margins(sysdata, returnall=False, epsw=0.0, method='best'):
349345
z, w_180 = _poly_z_real_crossing(*zargs, epsw=epsw)
350346
w180_resp = sys(z)
351347

348+
# the phase can also cross -180 degrees exactly at the Nyquist
349+
# frequency w = pi/dt: _z_filter excludes arg = pi, so the
350+
# polynomial search never returns z = -1. Check it directly
351+
# instead of widening the filter, which is shared with the
352+
# gain-crossover search and must not admit off-circle roots
353+
# whose argument happens to be pi.
354+
if np.pi / sys.dt >= epsw:
355+
resp = np.asarray(sys(-1), dtype=complex)
356+
if np.isclose(resp.imag, 0) and resp.real < 0:
357+
w_180 = np.append(w_180, np.pi / sys.dt)
358+
w180_resp = np.append(w180_resp, resp.real)
359+
352360
# phase margin
353361
z, wc = _poly_z_mag1_crossing(*zargs, epsw=epsw)
354362
wc_resp = sys(z)
@@ -489,6 +497,12 @@ def phase_crossover_frequencies(sys):
489497
z, omega = _poly_z_real_crossing(*zargs, epsw=0.)
490498
gains = np.real(sys(z, warn_infinite=False))
491499

500+
# the Nyquist plot also intersects the real axis at w = pi/dt
501+
# (the response at z = -1 is real for real coefficients); the
502+
# polynomial search never returns z = -1, so add it directly
503+
omega = np.append(omega, np.pi / tf.dt)
504+
gains = np.append(gains, np.real(tf(-1)))
505+
492506
return omega, gains
493507

494508

‎control/tests/margin_test.py‎

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ def test_margin_3input(tsys):
116116
(([1], [1, 1]), [0.], [1.]),
117117
(([2], [1, 3, 3, 1]), [1.732, 0.], [-0.25, 2.]),
118118
((np.array([3, 11, 3]) * 1e-4, [1., -2.7145, 2.4562, -0.7408], .1),
119-
[10*np.pi, 1.6235, 0.], [7.23432e-05, -0.28598, 1.88889]),
119+
[1.6235, 0., 10*np.pi], [-0.28598, 1.88889, 7.23432e-05]),
120120
(([200.0], [1.0, 21.0, 20.0, 0.0]),
121121
[4.47213595, 0], [-0.47619048, inf]),
122122
])
@@ -171,26 +171,6 @@ def test_stability_margins_discrete_nyquist(num, den, gm_ref, method):
171171
assert_allclose(wpc, np.pi / L.dt, rtol=1e-6)
172172

173173

174-
def test_z_filter_nyquist_endpoint():
175-
"""_z_filter keeps z = -1 exactly, without snapping nearby roots.
176-
177-
A genuine root just below pi keeps its computed frequency, its
178-
lower-half conjugate stays excluded, and -1 is kept whether its
179-
imaginary zero is positive or negative (arg pi or -pi).
180-
"""
181-
from control.margins import _z_filter
182-
# note: the -1 literals must be built with complex() - the expression
183-
# -1 - 0j has a *positive* imaginary zero, so it does not cover arg -pi
184-
zend = [complex(-1., -0.), complex(-1., 0.)]
185-
assert np.angle(zend[0]) == -np.pi
186-
assert np.angle(zend[1]) == np.pi
187-
z = np.array([np.exp(1j * (np.pi - 1e-6)),
188-
np.exp(-1j * (np.pi - 1e-6))] + zend)
189-
zf, omega = _z_filter(z, dt=2.0, eps=0.1)
190-
assert_allclose(np.sort(omega),
191-
np.sort([np.pi / 2, np.pi / 2, (np.pi - 1e-6) / 2]))
192-
193-
194174
@pytest.mark.parametrize("method", ["poly", "frd", "best"])
195175
def test_stability_margins_discrete_nyquist_positive(method):
196176
"""L(-1) > 0: no phase crossover at the Nyquist frequency."""
@@ -202,6 +182,26 @@ def test_stability_margins_discrete_nyquist_positive(method):
202182
assert gm == inf
203183

204184

185+
def test_stability_margins_moving_average_phase():
186+
"""No gain crossover at w = pi/dt when |L(-1)| = 0.
187+
188+
Regression test for the review on gh-1268: for an even-length moving
189+
average the |L| = 1 polynomial has off-circle roots with argument
190+
exactly pi, which must not be accepted as a gain crossover at the
191+
Nyquist frequency (that reported pm = 0 for a loop whose true phase
192+
margin is about 70 degrees).
193+
"""
194+
N = 16
195+
num = np.r_[0.0, np.full(N, 1.0 / N)]
196+
L = TransferFunction(num, np.polysub(np.r_[1.0, np.zeros(N)], num), 1.)
197+
assert abs(complex(L(-1))) < 1e-12
198+
with warnings.catch_warnings():
199+
warnings.simplefilter("ignore")
200+
gm, pm, sm, wpc, wgc, wms = stability_margins(L, method="poly")
201+
assert wpc != pytest.approx(np.pi) or gm == inf
202+
assert pm > 0 and wgc != pytest.approx(np.pi)
203+
204+
205205

206206
def test_frd():
207207
"""Test FrequencyResonseData margins"""

0 commit comments

Comments
 (0)