Skip to content

Commit 0ff0452

Browse files
authored
Merge pull request #1096 from murrayrm/fix_nyquist_plot-13Jan2025
Update nyquist_plot to call nyquist_response correctly
2 parents ec7dc8a + 54c4fd9 commit 0ff0452

File tree

3 files changed

+95
-12
lines changed

3 files changed

+95
-12
lines changed

control/freqplot.py

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1162,7 +1162,7 @@ def plot(self, *args, **kwargs):
11621162
def nyquist_response(
11631163
sysdata, omega=None, omega_limits=None, omega_num=None,
11641164
return_contour=False, warn_encirclements=True, warn_nyquist=True,
1165-
_check_kwargs=True, **kwargs):
1165+
_kwargs=None, _check_kwargs=True, **kwargs):
11661166
"""Nyquist response for a system.
11671167
11681168
Computes a Nyquist contour for the system over a (optional) frequency
@@ -1263,21 +1263,28 @@ def nyquist_response(
12631263
>>> lines = response.plot()
12641264
12651265
"""
1266+
# Create unified list of keyword arguments
1267+
if _kwargs is None:
1268+
_kwargs = kwargs
1269+
else:
1270+
# Use existing dictionary, to keep track of processed keywords
1271+
_kwargs |= kwargs
1272+
12661273
# Get values for params
12671274
omega_num_given = omega_num is not None
12681275
omega_num = config._get_param('freqplot', 'number_of_samples', omega_num)
12691276
indent_radius = config._get_param(
1270-
'nyquist', 'indent_radius', kwargs, _nyquist_defaults, pop=True)
1277+
'nyquist', 'indent_radius', _kwargs, _nyquist_defaults, pop=True)
12711278
encirclement_threshold = config._get_param(
1272-
'nyquist', 'encirclement_threshold', kwargs,
1279+
'nyquist', 'encirclement_threshold', _kwargs,
12731280
_nyquist_defaults, pop=True)
12741281
indent_direction = config._get_param(
1275-
'nyquist', 'indent_direction', kwargs, _nyquist_defaults, pop=True)
1282+
'nyquist', 'indent_direction', _kwargs, _nyquist_defaults, pop=True)
12761283
indent_points = config._get_param(
1277-
'nyquist', 'indent_points', kwargs, _nyquist_defaults, pop=True)
1284+
'nyquist', 'indent_points', _kwargs, _nyquist_defaults, pop=True)
12781285

1279-
if _check_kwargs and kwargs:
1280-
raise TypeError("unrecognized keywords: ", str(kwargs))
1286+
if _check_kwargs and _kwargs:
1287+
raise TypeError("unrecognized keywords: ", str(_kwargs))
12811288

12821289
# Convert the first argument to a list
12831290
syslist = sysdata if isinstance(sysdata, (list, tuple)) else [sysdata]
@@ -1782,15 +1789,14 @@ def _parse_linestyle(style_name, allow_false=False):
17821789
if all([isinstance(
17831790
sys, (StateSpace, TransferFunction, FrequencyResponseData))
17841791
for sys in data]):
1785-
# Get the response, popping off keywords used there
1792+
# Get the response; pop explicit keywords here, kwargs in _response()
17861793
nyquist_responses = nyquist_response(
17871794
data, omega=omega, return_contour=return_contour,
17881795
omega_limits=kwargs.pop('omega_limits', None),
17891796
omega_num=kwargs.pop('omega_num', None),
17901797
warn_encirclements=kwargs.pop('warn_encirclements', True),
17911798
warn_nyquist=kwargs.pop('warn_nyquist', True),
1792-
indent_radius=kwargs.pop('indent_radius', None),
1793-
_check_kwargs=False, **kwargs)
1799+
_kwargs=kwargs, _check_kwargs=False)
17941800
else:
17951801
nyquist_responses = data
17961802

control/rlocus.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,8 +186,6 @@ def root_locus_plot(
186186
then set the axis limits to the desired values.
187187
188188
"""
189-
from .pzmap import pole_zero_plot
190-
191189
# Legacy parameters
192190
for oldkey in ['kvect', 'k']:
193191
gains = config._process_legacy_keyword(kwargs, oldkey, 'gains', gains)

control/tests/response_test.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# response_test.py - test response/plot design pattern
2+
# RMM, 13 Jan 2025
3+
#
4+
# The standard pattern for control plots is to call a _response() or _map()
5+
# function and then use the plot() method. However, it is also allowed to
6+
# call the _plot() function directly, in which case the _response()/_map()
7+
# function is called internally.
8+
#
9+
# If there are arguments that are allowed in _plot() that need to be
10+
# processed by _response(), then we need to make sure that arguments are
11+
# properly passed from _plot() to _response(). The unit tests in this file
12+
# make sure that this functionality is implemented properly across all
13+
# *relevant* _response/_map/plot pairs.
14+
#
15+
# Response/map function Plotting function Comments
16+
# --------------------- ----------------- --------
17+
# describing_function_response describing_function_plot no passthru args
18+
# forced_response time_response_plot no passthru args
19+
# frequency_response bode_plot included below
20+
# frequency_response nichols_plot included below
21+
# gangof4_response gangof4_plot included below
22+
# impulse_response time_response_plot no passthru args
23+
# initial_response time_response_plot no passthru args
24+
# input_output_response time_response_plot no passthru args
25+
# nyquist_response nyquist_plot included below
26+
# pole_zero_map pole_zero_plot no passthru args
27+
# root_locus_map root_locus_plot included below
28+
# singular_values_response singular_values_plot included below
29+
# step_response time_response_plot no passthru args
30+
31+
import matplotlib.pyplot as plt
32+
import numpy as np
33+
import pytest
34+
35+
import control as ct
36+
37+
38+
# List of parameters that should be processed by response function
39+
@pytest.mark.parametrize("respfcn, plotfcn, respargs", [
40+
(ct.frequency_response, ct.bode_plot,
41+
{'omega_limits': [1e-2, 1e2], 'omega_num': 50, 'Hz': True}),
42+
(ct.frequency_response, ct.bode_plot, {'omega': np.logspace(2, 2)}),
43+
(ct.frequency_response, ct.nichols_plot, {'omega': np.logspace(2, 2)}),
44+
(ct.gangof4_response, ct.gangof4_plot, {'omega': np.logspace(2, 2)}),
45+
(ct.gangof4_response, ct.gangof4_plot,
46+
{'omega_limits': [1e-2, 1e2], 'omega_num': 50, 'Hz': True}),
47+
(ct.nyquist_response, ct.nyquist_plot,
48+
{'indent_direction': 'right', 'indent_radius': 0.1, 'indent_points': 100,
49+
'omega_num': 50, 'warn_nyquist': False}),
50+
(ct.root_locus_map, ct.root_locus_plot, {'gains': np.linspace(1, 10, 5)}),
51+
(ct.singular_values_response, ct.singular_values_plot,
52+
{'omega_limits': [1e-2, 1e2], 'omega_num': 50, 'Hz': True}),
53+
(ct.singular_values_response, ct.singular_values_plot,
54+
{'omega': np.logspace(2, 2)}),
55+
])
56+
@pytest.mark.usefixtures('mplcleanup')
57+
def test_response_plot(respfcn, plotfcn, respargs):
58+
if respfcn is ct.gangof4_response:
59+
# Two arguments required
60+
args = (ct.rss(4, 1, 1, strictly_proper=True), ct.rss(1, 1, 1))
61+
else:
62+
# Single argument is enough
63+
args = (ct.rss(4, 1, 1, strictly_proper=True), )
64+
65+
# Standard calling pattern - generate response, then plot
66+
plt.figure()
67+
resp = respfcn(*args, **respargs)
68+
if plotfcn is ct.nichols_plot:
69+
cplt_resp = resp.plot(plot_type='nichols')
70+
else:
71+
cplt_resp = resp.plot()
72+
73+
# Alternative calling pattern - call plotting function directly
74+
plt.figure()
75+
cplt_plot = plotfcn(*args, **respargs)
76+
77+
# Make sure the plots have the same elements
78+
assert cplt_resp.lines.shape == cplt_plot.lines.shape
79+
assert cplt_resp.axes.shape == cplt_plot.axes.shape

0 commit comments

Comments
 (0)