Skip to content

Commit 775b03f

Browse files
committed
implementation of initial_phase, wrap_phase keywords for bode()
1 parent 383e7a4 commit 775b03f

File tree

2 files changed

+139
-13
lines changed

2 files changed

+139
-13
lines changed

control/freqplot.py

Lines changed: 72 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
'bode.deg': True, # Plot phase in degrees
7979
'bode.Hz': False, # Plot frequency in Hertz
8080
'bode.grid': True, # Turn on grid for gain and phase
81+
'bode.wrap_phase': False, # Wrap the phase plot at a given value
8182
}
8283

8384

@@ -131,7 +132,18 @@ def bode_plot(syslist, omega=None,
131132
grid : bool
132133
If True, plot grid lines on gain and phase plots. Default is set by
133134
`config.defaults['bode.grid']`.
134-
135+
initial_phase : float
136+
Set the reference phase to use for the lowest frequency. If set, the
137+
initial phase of the Bode plot will be set to the value closest to the
138+
value specified. Default is 180 if wrap_phase is False, 0 if
139+
wrap_phase is True.
140+
wrap_phase : bool or float
141+
If wrap_phase is `False`, then the phase will be unwrapped so that it
142+
is continuously increasing or decreasing. If wrap_phase is `True` the
143+
phase will be restricted to the range [-180, 180) (or [:math:`-\\pi`,
144+
:math:`\\pi`) radians). If `wrap_phase` is specified as a float, the
145+
phase will be offset by 360 degrees if it falls below the specified
146+
value. Default to `False`, set by config.defaults['bode.wrap_phase'].
135147
136148
The default values for Bode plot configuration parameters can be reset
137149
using the `config.defaults` dictionary, with module name 'bode'.
@@ -171,6 +183,10 @@ def bode_plot(syslist, omega=None,
171183
grid = config._get_param('bode', 'grid', kwargs, _bode_defaults, pop=True)
172184
plot = config._get_param('bode', 'grid', plot, True)
173185
margins = config._get_param('bode', 'margins', margins, False)
186+
wrap_phase = config._get_param(
187+
'bode', 'wrap_phase', kwargs, _bode_defaults, pop=True)
188+
initial_phase = config._get_param(
189+
'bode', 'initial_phase', kwargs, None, pop=True)
174190

175191
# If argument was a singleton, turn it into a list
176192
if not getattr(syslist, '__iter__', False):
@@ -209,11 +225,47 @@ def bode_plot(syslist, omega=None,
209225
# TODO: What distance to the Nyquist frequency is appropriate?
210226
else:
211227
nyquistfrq = None
228+
212229
# Get the magnitude and phase of the system
213230
mag_tmp, phase_tmp, omega_sys = sys.freqresp(omega_sys)
214231
mag = np.atleast_1d(np.squeeze(mag_tmp))
215232
phase = np.atleast_1d(np.squeeze(phase_tmp))
216-
phase = unwrap(phase)
233+
234+
#
235+
# Post-process the phase to handle initial value and wrapping
236+
#
237+
238+
if initial_phase is None:
239+
# Start phase in the range 0 to -360 w/ initial phase = -180
240+
# If wrap_phase is true, use 0 instead (phase \in (-pi, pi])
241+
initial_phase = -math.pi if wrap_phase is not True else 0
242+
elif isinstance(initial_phase, (int, float)):
243+
# Allow the user to override the default calculation
244+
if deg:
245+
initial_phase = initial_phase/180. * math.pi
246+
else:
247+
raise ValueError("initial_phase must be a number.")
248+
249+
# Shift the phase if needed
250+
if abs(phase[0] - initial_phase) > math.pi:
251+
phase -= 2*math.pi * \
252+
round((phase[0] - initial_phase) / (2*math.pi))
253+
254+
# Phase wrapping
255+
if wrap_phase is False:
256+
phase = unwrap(phase) # unwrap the phase
257+
elif wrap_phase is True:
258+
pass # default calculation OK
259+
elif isinstance(wrap_phase, (int, float)):
260+
phase = unwrap(phase) # unwrap the phase first
261+
if deg:
262+
wrap_phase *= math.pi/180.
263+
264+
# Shift the phase if it is below the wrap_phase
265+
phase += 2*math.pi * np.maximum(
266+
0, np.ceil((wrap_phase - phase)/(2*math.pi)))
267+
else:
268+
raise ValueError("wrap_phase must be bool or float.")
217269

218270
mags.append(mag)
219271
phases.append(phase)
@@ -270,7 +322,9 @@ def bode_plot(syslist, omega=None,
270322
label='control-bode-phase',
271323
sharex=ax_mag)
272324

325+
#
273326
# Magnitude plot
327+
#
274328
if dB:
275329
pltline = ax_mag.semilogx(omega_plot, 20 * np.log10(mag),
276330
*args, **kwargs)
@@ -285,19 +339,22 @@ def bode_plot(syslist, omega=None,
285339
ax_mag.grid(grid and not margins, which='both')
286340
ax_mag.set_ylabel("Magnitude (dB)" if dB else "Magnitude")
287341

342+
#
288343
# Phase plot
289-
if deg:
290-
phase_plot = phase * 180. / math.pi
291-
else:
292-
phase_plot = phase
344+
#
345+
phase_plot = phase * 180. / math.pi if deg else phase
346+
347+
# Plot the data
293348
ax_phase.semilogx(omega_plot, phase_plot, *args, **kwargs)
294349

295350
# Show the phase and gain margins in the plot
296351
if margins:
352+
# Compute stability margins for the system
297353
margin = stability_margins(sys)
298-
gm, pm, Wcg, Wcp = \
299-
margin[0], margin[1], margin[3], margin[4]
300-
# TODO: add some documentation describing why this is here
354+
gm, pm, Wcg, Wcp = (margin[i] for i in (0, 1, 3, 4))
355+
356+
# Figure out sign of the phase at the first gain crossing
357+
# (needed if phase_wrap is True)
301358
phase_at_cp = phases[0][(np.abs(omegas[0] - Wcp)).argmin()]
302359
if phase_at_cp >= 0.:
303360
phase_limit = 180.
@@ -307,6 +364,7 @@ def bode_plot(syslist, omega=None,
307364
if Hz:
308365
Wcg, Wcp = Wcg/(2*math.pi), Wcp/(2*math.pi)
309366

367+
# Draw lines at gain and phase limits
310368
ax_mag.axhline(y=0 if dB else 1, color='k', linestyle=':',
311369
zorder=-20)
312370
ax_phase.axhline(y=phase_limit if deg else
@@ -315,6 +373,7 @@ def bode_plot(syslist, omega=None,
315373
mag_ylim = ax_mag.get_ylim()
316374
phase_ylim = ax_phase.get_ylim()
317375

376+
# Annotate the phase margin (if it exists)
318377
if pm != float('inf') and Wcp != float('nan'):
319378
if dB:
320379
ax_mag.semilogx(
@@ -327,7 +386,7 @@ def bode_plot(syslist, omega=None,
327386

328387
if deg:
329388
ax_phase.semilogx(
330-
[Wcp, Wcp], [1e5, phase_limit+pm],
389+
[Wcp, Wcp], [1e5, phase_limit + pm],
331390
color='k', linestyle=':', zorder=-20)
332391
ax_phase.semilogx(
333392
[Wcp, Wcp], [phase_limit + pm, phase_limit],
@@ -343,6 +402,7 @@ def bode_plot(syslist, omega=None,
343402
math.radians(phase_limit)],
344403
color='k', zorder=-20)
345404

405+
# Annotate the gain margin (if it exists)
346406
if gm != float('inf') and Wcg != float('nan'):
347407
if dB:
348408
ax_mag.semilogx(
@@ -360,11 +420,11 @@ def bode_plot(syslist, omega=None,
360420

361421
if deg:
362422
ax_phase.semilogx(
363-
[Wcg, Wcg], [1e-8, phase_limit],
423+
[Wcg, Wcg], [0, phase_limit],
364424
color='k', linestyle=':', zorder=-20)
365425
else:
366426
ax_phase.semilogx(
367-
[Wcg, Wcg], [1e-8, math.radians(phase_limit)],
427+
[Wcg, Wcg], [0, math.radians(phase_limit)],
368428
color='k', linestyle=':', zorder=-20)
369429

370430
ax_mag.set_ylim(mag_ylim)

control/tests/freqresp_test.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import matplotlib.pyplot as plt
1010
import numpy as np
1111
from numpy.testing import assert_allclose
12+
import math
1213
import pytest
1314

1415
import control as ctrl
@@ -173,7 +174,7 @@ def test_bode_margin(dB, maginfty1, maginfty2, gminv,
173174
rtol=1e-5)
174175

175176
phase_to_infinity = (np.array([Wcg, Wcg]),
176-
np.array([1e-8, p0]))
177+
np.array([0, p0]))
177178
assert_allclose(phase_to_infinity, allaxes[1].lines[4].get_data(),
178179
rtol=1e-5)
179180

@@ -271,3 +272,68 @@ def test_options(editsdefaults):
271272
# Make sure we got the right number of points
272273
assert numpoints1 != numpoints3
273274
assert numpoints3 == 13
275+
276+
@pytest.mark.parametrize(
277+
"TF, initial_phase, default_phase, expected_phase",
278+
[pytest.param(ctrl.tf([1], [1, 0]),
279+
None, -math.pi/2, -math.pi/2, id="order1, default"),
280+
pytest.param(ctrl.tf([1], [1, 0]),
281+
180, -math.pi/2, 3*math.pi/2, id="order1, 180"),
282+
pytest.param(ctrl.tf([1], [1, 0, 0]),
283+
None, -math.pi, -math.pi, id="order2, default"),
284+
pytest.param(ctrl.tf([1], [1, 0, 0]),
285+
180, -math.pi, math.pi, id="order2, 180"),
286+
pytest.param(ctrl.tf([1], [1, 0, 0, 0]),
287+
None, -3*math.pi/2, -3*math.pi/2, id="order2, default"),
288+
pytest.param(ctrl.tf([1], [1, 0, 0, 0]),
289+
180, -3*math.pi/2, math.pi/2, id="order2, 180"),
290+
pytest.param(ctrl.tf([1], [1, 0, 0, 0, 0]),
291+
None, 0, 0, id="order4, default"),
292+
pytest.param(ctrl.tf([1], [1, 0, 0, 0, 0]),
293+
180, 0, 0, id="order4, 180"),
294+
pytest.param(ctrl.tf([1], [1, 0, 0, 0, 0]),
295+
-360, 0, -2*math.pi, id="order4, -360"),
296+
])
297+
def test_initial_phase(TF, initial_phase, default_phase, expected_phase):
298+
# Check initial phase of standard transfer functions
299+
mag, phase, omega = ctrl.bode(TF)
300+
assert(abs(phase[0] - default_phase) < 0.1)
301+
302+
# Now reset the initial phase to +180 and see if things work
303+
mag, phase, omega = ctrl.bode(TF, initial_phase=initial_phase)
304+
assert(abs(phase[0] - expected_phase) < 0.1)
305+
306+
# Make sure everything works in rad/sec as well
307+
# Turn off plotting since that seems to slow things down a lot (?)
308+
if initial_phase:
309+
mag, phase, omega = ctrl.bode(
310+
TF, initial_phase=initial_phase/180. * math.pi, deg=False,
311+
plot=False)
312+
assert(abs(phase[0] - expected_phase) < 0.1)
313+
314+
315+
@pytest.mark.parametrize(
316+
"TF, wrap_phase, min_phase, max_phase",
317+
[pytest.param(ctrl.tf([1], [1, 0]),
318+
None, -math.pi/2, 0, id="order1, default"),
319+
pytest.param(ctrl.tf([1], [1, 0]),
320+
True, -math.pi, math.pi, id="order1, True"),
321+
pytest.param(ctrl.tf([1], [1, 0]),
322+
-270, -3*math.pi/2, math.pi/2, id="order1, -270"),
323+
pytest.param(ctrl.tf([1], [1, 0, 0, 0]),
324+
None, -3*math.pi/2, 0, id="order3, default"),
325+
pytest.param(ctrl.tf([1], [1, 0, 0, 0]),
326+
True, -math.pi, math.pi, id="order3, True"),
327+
pytest.param(ctrl.tf([1], [1, 0, 0, 0]),
328+
-270, -3*math.pi/2, math.pi/2, id="order3, -270"),
329+
pytest.param(ctrl.tf([1], [1, 0, 0, 0, 0, 0]),
330+
True, -3*math.pi/2, 0, id="order5, default"),
331+
pytest.param(ctrl.tf([1], [1, 0, 0, 0, 0, 0]),
332+
True, -math.pi, math.pi, id="order5, True"),
333+
pytest.param(ctrl.tf([1], [1, 0, 0, 0, 0, 0]),
334+
-270, -3*math.pi/2, math.pi/2, id="order5, -270"),
335+
])
336+
def test_phase_wrap(TF, wrap_phase, min_phase, max_phase):
337+
mag, phase, omega = ctrl.bode(TF, wrap_phase=wrap_phase)
338+
assert(min(phase) >= min_phase)
339+
assert(max(phase) <= max_phase)

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy