Skip to content

Commit 83c9e4e

Browse files
committed
refactoring of describing_function_plot in response/plot + updated unit tests
1 parent e5360dc commit 83c9e4e

3 files changed

Lines changed: 173 additions & 34 deletions

File tree

control/descfcn.py

Lines changed: 133 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@
1919
from warnings import warn
2020

2121
from .freqplot import nyquist_response
22+
from . import config
2223

2324
__all__ = ['describing_function', 'describing_function_plot',
24-
'DescribingFunctionNonlinearity', 'friction_backlash_nonlinearity',
25-
'relay_hysteresis_nonlinearity', 'saturation_nonlinearity']
25+
'describing_function_response', 'DescribingFunctionNonlinearity',
26+
'friction_backlash_nonlinearity', 'relay_hysteresis_nonlinearity',
27+
'saturation_nonlinearity']
2628

2729
# Class for nonlinearities with a built-in describing function
2830
class DescribingFunctionNonlinearity():
@@ -205,14 +207,41 @@ def describing_function(
205207
# Return the values in the same shape as they were requested
206208
return retdf
207209

210+
#
211+
# Describing function response/plot
212+
#
208213

209-
def describing_function_plot(
210-
H, F, A, omega=None, refine=True, label="%5.2g @ %-5.2g",
211-
warn=None, **kwargs):
212-
"""Plot a Nyquist plot with a describing function for a nonlinear system.
214+
# Simple class to store the describing function response
215+
class DescribingFunctionResponse:
216+
def __init__(self, response, N_vals, positions, intersections):
217+
self.response = response
218+
self.N_vals = N_vals
219+
self.positions = positions
220+
self.intersections = intersections
221+
222+
def plot(self, **kwargs):
223+
return describing_function_plot(self, **kwargs)
224+
225+
# Implement iter, getitem, len to allow recovering the intersections
226+
def __iter__(self):
227+
return iter(self.intersections)
228+
229+
def __getitem__(self, index):
230+
return list(self.__iter__())[index]
231+
232+
def __len__(self):
233+
return len(self.intersections)
213234

214-
This function generates a Nyquist plot for a closed loop system consisting
215-
of a linear system with a static nonlinear function in the feedback path.
235+
236+
# Compute the describing function response + intersections
237+
def describing_function_response(
238+
H, F, A, omega=None, refine=True, warn_nyquist=None,
239+
plot=False, check_kwargs=True, **kwargs):
240+
"""Compute the describing function response of a system.
241+
242+
This function uses describing function analysis to analyze a closed
243+
loop system consisting of a linear system with a static nonlinear
244+
function in the feedback path.
216245
217246
Parameters
218247
----------
@@ -226,10 +255,7 @@ def describing_function_plot(
226255
List of amplitudes to be used for the describing function plot.
227256
omega : list, optional
228257
List of frequencies to be used for the linear system Nyquist curve.
229-
label : str, optional
230-
Formatting string used to label intersection points on the Nyquist
231-
plot. Defaults to "%5.2g @ %-5.2g". Set to `None` to omit labels.
232-
warn : bool, optional
258+
warn_nyquist : bool, optional
233259
Set to True to turn on warnings generated by `nyquist_plot` or False
234260
to turn off warnings. If not set (or set to None), warnings are
235261
turned off if omega is specified, otherwise they are turned on.
@@ -249,31 +275,27 @@ def describing_function_plot(
249275
>>> H_simple = ct.tf([8], [1, 2, 2, 1])
250276
>>> F_saturation = ct.saturation_nonlinearity(1)
251277
>>> amp = np.linspace(1, 4, 10)
252-
>>> ct.describing_function_plot(H_simple, F_saturation, amp) # doctest: +SKIP
278+
>>> ct.describing_function_response(H_simple, F_saturation, amp) # doctest: +SKIP
253279
[(3.343844998258643, 1.4142293090899216)]
254280
255281
"""
256282
# Decide whether to turn on warnings or not
257-
if warn is None:
283+
if warn_nyquist is None:
258284
# Turn warnings on unless omega was specified
259-
warn = omega is None
285+
warn_nyquist = omega is None
260286

261287
# Start by drawing a Nyquist curve
262288
response = nyquist_response(
263-
H, omega, warn_encirclements=warn, warn_nyquist=warn,
264-
check_kwargs=False, **kwargs)
265-
response.plot(**kwargs)
289+
H, omega, warn_encirclements=warn_nyquist, warn_nyquist=warn_nyquist,
290+
check_kwargs=check_kwargs, **kwargs)
266291
H_omega, H_vals = response.contour.imag, H(response.contour)
267292

268293
# Compute the describing function
269294
df = describing_function(F, A)
270295
N_vals = -1/df
271296

272-
# Now add the describing function curve to the plot
273-
plt.plot(N_vals.real, N_vals.imag)
274-
275297
# Look for intersection points
276-
intersections = []
298+
positions, intersections = [], []
277299
for i in range(N_vals.size - 1):
278300
for j in range(H_vals.size - 1):
279301
intersect = _find_intersection(
@@ -306,17 +328,99 @@ def _cost(x):
306328
else:
307329
a_final, omega_final = res.x[0], res.x[1]
308330

309-
# Add labels to the intersection points
310-
if isinstance(label, str):
311-
pos = H(1j * omega_final)
312-
plt.text(pos.real, pos.imag, label % (a_final, omega_final))
313-
elif label is not None or label is not False:
314-
raise ValueError("label must be formatting string or None")
331+
pos = H(1j * omega_final)
315332

316333
# Save the final estimate
334+
positions.append(pos)
317335
intersections.append((a_final, omega_final))
318336

319-
return intersections
337+
return DescribingFunctionResponse(
338+
response, N_vals, positions, intersections)
339+
340+
341+
def describing_function_plot(
342+
*sysdata, label="%5.2g @ %-5.2g", **kwargs):
343+
"""Plot a Nyquist plot with a describing function for a nonlinear system.
344+
345+
This function generates a Nyquist plot for a closed loop system
346+
consisting of a linear system with a static nonlinear function in the
347+
feedback path.
348+
349+
Parameters
350+
----------
351+
H : LTI system
352+
Linear time-invariant (LTI) system (state space, transfer function, or
353+
FRD)
354+
F : static nonlinear function
355+
A static nonlinearity, either a scalar function or a single-input,
356+
single-output, static input/output system.
357+
A : list
358+
List of amplitudes to be used for the describing function plot.
359+
omega : list, optional
360+
List of frequencies to be used for the linear system Nyquist curve.
361+
refine : bool, optional
362+
If True (default), refine the location of the intersection of the
363+
Nyquist curve for the linear system and the describing function to
364+
determine the intersection point
365+
label : str, optional
366+
Formatting string used to label intersection points on the Nyquist
367+
plot. Defaults to "%5.2g @ %-5.2g". Set to `None` to omit labels.
368+
369+
Returns
370+
-------
371+
intersections : 1D array of 2-tuples or None
372+
A list of all amplitudes and frequencies in which :math:`H(j\\omega)
373+
N(a) = -1`, where :math:`N(a)` is the describing function associated
374+
with `F`, or `None` if there are no such points. Each pair represents
375+
a potential limit cycle for the closed loop system with amplitude
376+
given by the first value of the tuple and frequency given by the
377+
second value.
378+
379+
Examples
380+
--------
381+
>>> H_simple = ct.tf([8], [1, 2, 2, 1])
382+
>>> F_saturation = ct.saturation_nonlinearity(1)
383+
>>> amp = np.linspace(1, 4, 10)
384+
>>> ct.describing_function_plot(H_simple, F_saturation, amp) # doctest: +SKIP
385+
[(3.343844998258643, 1.4142293090899216)]
386+
387+
"""
388+
# Process keywords
389+
warn_nyquist = config._process_legacy_keyword(
390+
kwargs, 'warn', 'warn_nyquist', kwargs.pop('warn_nyquist', None))
391+
392+
if label not in (False, None) and not isinstance(label, str):
393+
raise ValueError("label must be formatting string, False, or None")
394+
395+
# Get the describing function response
396+
if len(sysdata) == 3:
397+
sysdata = sysdata + (None, ) # set omega to default value
398+
if len(sysdata) == 4:
399+
dfresp = describing_function_response(
400+
*sysdata, refine=kwargs.pop('refine', True),
401+
warn_nyquist=warn_nyquist)
402+
elif len(sysdata) == 1:
403+
dfresp = sysdata[0]
404+
else:
405+
raise TypeError("1, 3, or 4 position arguments required")
406+
407+
# Create a list of lines for the output
408+
out = np.empty(2, dtype=object)
409+
410+
# Plot the Nyquist response
411+
out[0] = dfresp.response.plot(**kwargs)[0]
412+
413+
# Add the describing function curve to the plot
414+
lines = plt.plot(dfresp.N_vals.real, dfresp.N_vals.imag)
415+
out[1] = lines
416+
417+
# Label the intersection points
418+
if label:
419+
for pos, (a, omega) in zip(dfresp.positions, dfresp.intersections):
420+
# Add labels to the intersection points
421+
plt.text(pos.real, pos.imag, label % (a, omega))
422+
423+
return out
320424

321425

322426
# Utility function to figure out whether two line segments intersection

control/tests/descfcn_test.py

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import numpy as np
1313
import control as ct
1414
import math
15+
import matplotlib.pyplot as plt
1516
from control.descfcn import saturation_nonlinearity, \
1617
friction_backlash_nonlinearity, relay_hysteresis_nonlinearity
1718

@@ -137,7 +138,7 @@ def test_describing_function(fcn, amin, amax):
137138
ct.describing_function(fcn, -1)
138139

139140

140-
def test_describing_function_plot():
141+
def test_describing_function_response():
141142
# Simple linear system with at most 1 intersection
142143
H_simple = ct.tf([1], [1, 2, 2, 1])
143144
omega = np.logspace(-1, 2, 100)
@@ -147,12 +148,12 @@ def test_describing_function_plot():
147148
amp = np.linspace(1, 4, 10)
148149

149150
# No intersection
150-
xsects = ct.describing_function_plot(H_simple, F_saturation, amp, omega)
151-
assert xsects == []
151+
xsects = ct.describing_function_response(H_simple, F_saturation, amp, omega)
152+
assert len(xsects) == 0
152153

153154
# One intersection
154155
H_larger = H_simple * 8
155-
xsects = ct.describing_function_plot(H_larger, F_saturation, amp, omega)
156+
xsects = ct.describing_function_response(H_larger, F_saturation, amp, omega)
156157
for a, w in xsects:
157158
np.testing.assert_almost_equal(
158159
H_larger(1j*w),
@@ -163,12 +164,38 @@ def test_describing_function_plot():
163164
omega = np.logspace(-1, 3, 50)
164165
F_backlash = ct.descfcn.friction_backlash_nonlinearity(1)
165166
amp = np.linspace(0.6, 5, 50)
166-
xsects = ct.describing_function_plot(H_multiple, F_backlash, amp, omega)
167+
xsects = ct.describing_function_response(H_multiple, F_backlash, amp, omega)
167168
for a, w in xsects:
168169
np.testing.assert_almost_equal(
169170
-1/ct.describing_function(F_backlash, a),
170171
H_multiple(1j*w), decimal=5)
171172

173+
174+
def test_describing_function_plot():
175+
# Simple linear system with at most 1 intersection
176+
H_larger = ct.tf([1], [1, 2, 2, 1]) * 8
177+
omega = np.logspace(-1, 2, 100)
178+
179+
# Saturation nonlinearity
180+
F_saturation = ct.descfcn.saturation_nonlinearity(1)
181+
amp = np.linspace(1, 4, 10)
182+
183+
# Plot via response
184+
plt.clf() # clear axes
185+
response = ct.describing_function_response(
186+
H_larger, F_saturation, amp, omega)
187+
assert len(response.intersections) == 1
188+
assert len(plt.gcf().get_axes()) == 0 # make sure there is no plot
189+
190+
out = response.plot()
191+
assert len(plt.gcf().get_axes()) == 1 # make sure there is a plot
192+
assert len(out[0]) == 4 and len(out[1]) == 1
193+
194+
# Call plot directly
195+
out = ct.describing_function_plot(H_larger, F_saturation, amp, omega)
196+
assert len(out[0]) == 4 and len(out[1]) == 1
197+
198+
172199
def test_describing_function_exceptions():
173200
# Describing function with non-zero bias
174201
with pytest.warns(UserWarning, match="asymmetric"):
@@ -194,3 +221,8 @@ def test_describing_function_exceptions():
194221
amp = np.linspace(1, 4, 10)
195222
with pytest.raises(ValueError, match="formatting string"):
196223
ct.describing_function_plot(H_simple, F_saturation, amp, label=1)
224+
225+
# Unrecognized keyword
226+
with pytest.raises(TypeError, match="unrecognized keyword"):
227+
ct.describing_function_response(
228+
H_simple, F_saturation, amp, None, unknown=None)

control/tests/kwargs_test.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import control.tests.stochsys_test as stochsys_test
2828
import control.tests.trdata_test as trdata_test
2929
import control.tests.timeplot_test as timeplot_test
30+
import control.tests.descfcn_test as descfcn_test
3031

3132
@pytest.mark.parametrize("module, prefix", [
3233
(control, ""), (control.flatsys, "flatsys."), (control.optimal, "optimal.")
@@ -210,6 +211,8 @@ def test_response_plot_kwargs(data_fcn, plot_fcn, mimo):
210211
'create_estimator_iosystem': stochsys_test.test_estimator_errors,
211212
'create_statefbk_iosystem': statefbk_test.TestStatefbk.test_statefbk_errors,
212213
'describing_function_plot': test_matplotlib_kwargs,
214+
'describing_function_response':
215+
descfcn_test.test_describing_function_exceptions,
213216
'dlqe': test_unrecognized_kwargs,
214217
'dlqr': test_unrecognized_kwargs,
215218
'drss': test_unrecognized_kwargs,

0 commit comments

Comments
 (0)