Skip to content

Commit bb47d59

Browse files
authored
Merge pull request #1155 from murrayrm/fix_nyquist_rescaling-24Mar2025
Update Nyquist rescaling + other improvements
2 parents 2745b12 + cf3daa4 commit bb47d59

File tree

9 files changed

+152
-43
lines changed

9 files changed

+152
-43
lines changed

control/ctrlplot.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@
4141
# # Customize axes (curvilinear grids, shared axes, etc)
4242
#
4343
# # Plot the data
44-
# lines = np.full(ax_array.shape, [])
44+
# lines = np.empty(ax_array.shape, dtype=object)
45+
# for i in range(ax_array.shape[0]):
46+
# for j in range(ax_array.shape[1]):
47+
# lines[i, j] = []
4548
# line_labels = _process_line_labels(label, ntraces, nrows, ncols)
4649
# color_offset, color_cycle = _get_color_offset(ax)
4750
# for i, j in itertools.product(range(nrows), range(ncols)):

control/descfcn.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -521,7 +521,8 @@ def describing_function_plot(
521521
# Plot the Nyquist response
522522
cplt = dfresp.response.plot(**kwargs)
523523
ax = cplt.axes[0, 0] # Get the axes where the plot was made
524-
lines[0] = cplt.lines[0] # Return Nyquist lines for first system
524+
lines[0] = np.concatenate( # Return Nyquist lines for first system
525+
cplt.lines.flatten()).tolist()
525526

526527
# Add the describing function curve to the plot
527528
lines[1] = ax.plot(dfresp.N_vals.real, dfresp.N_vals.imag)

control/freqplot.py

Lines changed: 52 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,13 +1100,14 @@ def gen_zero_centered_series(val_min, val_max, period):
11001100
_nyquist_defaults = {
11011101
'nyquist.primary_style': ['-', '-.'], # style for primary curve
11021102
'nyquist.mirror_style': ['--', ':'], # style for mirror curve
1103-
'nyquist.arrows': 2, # number of arrows around curve
1103+
'nyquist.arrows': 3, # number of arrows around curve
11041104
'nyquist.arrow_size': 8, # pixel size for arrows
11051105
'nyquist.encirclement_threshold': 0.05, # warning threshold
11061106
'nyquist.indent_radius': 1e-4, # indentation radius
11071107
'nyquist.indent_direction': 'right', # indentation direction
1108-
'nyquist.indent_points': 50, # number of points to insert
1109-
'nyquist.max_curve_magnitude': 20, # clip large values
1108+
'nyquist.indent_points': 200, # number of points to insert
1109+
'nyquist.max_curve_magnitude': 15, # rescale large values
1110+
'nyquist.blend_fraction': 0.15, # when to start scaling
11101111
'nyquist.max_curve_offset': 0.02, # offset of primary/mirror
11111112
'nyquist.start_marker': 'o', # marker at start of curve
11121113
'nyquist.start_marker_size': 4, # size of the marker
@@ -1638,6 +1639,10 @@ def nyquist_plot(
16381639
The matplotlib axes to draw the figure on. If not specified and
16391640
the current figure has a single axes, that axes is used.
16401641
Otherwise, a new figure is created.
1642+
blend_fraction : float, optional
1643+
For portions of the Nyquist curve that are scaled to have a maximum
1644+
magnitude of `max_curve_magnitude`, begin a smooth rescaling at
1645+
this fraction of `max_curve_magnitude`. Default value is 0.15.
16411646
encirclement_threshold : float, optional
16421647
Define the threshold for generating a warning if the number of net
16431648
encirclements is a non-integer value. Default value is 0.05 and can
@@ -1751,8 +1756,8 @@ def nyquist_plot(
17511756
to avoid poles, resulting in a scaling of the Nyquist plot, the line
17521757
styles are according to the settings of the `primary_style` and
17531758
`mirror_style` keywords. By default the scaled portions of the primary
1754-
curve use a dotted line style and the scaled portion of the mirror
1755-
image use a dashdot line style.
1759+
curve use a dashdot line style and the scaled portions of the mirror
1760+
image use a dotted line style.
17561761
17571762
Examples
17581763
--------
@@ -1784,6 +1789,8 @@ def nyquist_plot(
17841789
ax_user = ax
17851790
max_curve_magnitude = config._get_param(
17861791
'nyquist', 'max_curve_magnitude', kwargs, _nyquist_defaults, pop=True)
1792+
blend_fraction = config._get_param(
1793+
'nyquist', 'blend_fraction', kwargs, _nyquist_defaults, pop=True)
17871794
max_curve_offset = config._get_param(
17881795
'nyquist', 'max_curve_offset', kwargs, _nyquist_defaults, pop=True)
17891796
rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True)
@@ -1878,10 +1885,16 @@ def _parse_linestyle(style_name, allow_false=False):
18781885
legend_loc, _, show_legend = _process_legend_keywords(
18791886
kwargs, None, 'upper right')
18801887

1888+
# Figure out where the blended curve should start
1889+
if blend_fraction < 0 or blend_fraction > 1:
1890+
raise ValueError("blend_fraction must be between 0 and 1")
1891+
blend_curve_start = (1 - blend_fraction) * max_curve_magnitude
1892+
18811893
# Create a list of lines for the output
1882-
out = np.empty(len(nyquist_responses), dtype=object)
1883-
for i in range(out.shape[0]):
1884-
out[i] = [] # unique list in each element
1894+
out = np.empty((len(nyquist_responses), 4), dtype=object)
1895+
for i in range(len(nyquist_responses)):
1896+
for j in range(4):
1897+
out[i, j] = [] # unique list in each element
18851898

18861899
for idx, response in enumerate(nyquist_responses):
18871900
resp = response.response
@@ -1892,20 +1905,31 @@ def _parse_linestyle(style_name, allow_false=False):
18921905

18931906
# Find the different portions of the curve (with scaled pts marked)
18941907
reg_mask = np.logical_or(
1895-
np.abs(resp) > max_curve_magnitude,
1896-
splane_contour.real != 0)
1897-
# reg_mask = np.logical_or(
1898-
# np.abs(resp.real) > max_curve_magnitude,
1899-
# np.abs(resp.imag) > max_curve_magnitude)
1908+
np.abs(resp) > blend_curve_start,
1909+
np.logical_not(np.isclose(splane_contour.real, 0)))
19001910

19011911
scale_mask = ~reg_mask \
19021912
& np.concatenate((~reg_mask[1:], ~reg_mask[-1:])) \
19031913
& np.concatenate((~reg_mask[0:1], ~reg_mask[:-1]))
19041914

19051915
# Rescale the points with large magnitude
1906-
rescale = np.logical_and(
1907-
reg_mask, abs(resp) > max_curve_magnitude)
1908-
resp[rescale] *= max_curve_magnitude / abs(resp[rescale])
1916+
rescale_idx = (np.abs(resp) > blend_curve_start)
1917+
1918+
if np.any(rescale_idx): # Only process if rescaling is needed
1919+
subset = resp[rescale_idx]
1920+
abs_subset = np.abs(subset)
1921+
unit_vectors = subset / abs_subset # Preserve phase/direction
1922+
1923+
if blend_curve_start == max_curve_magnitude:
1924+
# Clip at max_curve_magnitude
1925+
resp[rescale_idx] = max_curve_magnitude * unit_vectors
1926+
else:
1927+
# Logistic scaling
1928+
newmag = blend_curve_start + \
1929+
(max_curve_magnitude - blend_curve_start) * \
1930+
(abs_subset - blend_curve_start) / \
1931+
(abs_subset + max_curve_magnitude - 2 * blend_curve_start)
1932+
resp[rescale_idx] = newmag * unit_vectors
19091933

19101934
# Get the label to use for the line
19111935
label = response.sysname if line_labels is None else line_labels[idx]
@@ -1916,7 +1940,7 @@ def _parse_linestyle(style_name, allow_false=False):
19161940
p = ax.plot(
19171941
x_reg, y_reg, primary_style[0], color=color, label=label, **kwargs)
19181942
c = p[0].get_color()
1919-
out[idx] += p
1943+
out[idx, 0] += p
19201944

19211945
# Figure out how much to offset the curve: the offset goes from
19221946
# zero at the start of the scaled section to max_curve_offset as
@@ -1928,12 +1952,12 @@ def _parse_linestyle(style_name, allow_false=False):
19281952
x_scl = np.ma.masked_where(scale_mask, resp.real)
19291953
y_scl = np.ma.masked_where(scale_mask, resp.imag)
19301954
if x_scl.count() >= 1 and y_scl.count() >= 1:
1931-
out[idx] += ax.plot(
1955+
out[idx, 1] += ax.plot(
19321956
x_scl * (1 + curve_offset),
19331957
y_scl * (1 + curve_offset),
19341958
primary_style[1], color=c, **kwargs)
19351959
else:
1936-
out[idx] += [None]
1960+
out[idx, 1] += [None]
19371961

19381962
# Plot the primary curve (invisible) for setting arrows
19391963
x, y = resp.real.copy(), resp.imag.copy()
@@ -1948,15 +1972,15 @@ def _parse_linestyle(style_name, allow_false=False):
19481972
# Plot the mirror image
19491973
if mirror_style is not False:
19501974
# Plot the regular and scaled segments
1951-
out[idx] += ax.plot(
1975+
out[idx, 2] += ax.plot(
19521976
x_reg, -y_reg, mirror_style[0], color=c, **kwargs)
19531977
if x_scl.count() >= 1 and y_scl.count() >= 1:
1954-
out[idx] += ax.plot(
1978+
out[idx, 3] += ax.plot(
19551979
x_scl * (1 - curve_offset),
19561980
-y_scl * (1 - curve_offset),
19571981
mirror_style[1], color=c, **kwargs)
19581982
else:
1959-
out[idx] += [None]
1983+
out[idx, 3] += [None]
19601984

19611985
# Add the arrows (on top of an invisible contour)
19621986
x, y = resp.real.copy(), resp.imag.copy()
@@ -1966,12 +1990,15 @@ def _parse_linestyle(style_name, allow_false=False):
19661990
_add_arrows_to_line2D(
19671991
ax, p[0], arrow_pos, arrowstyle=arrow_style, dir=-1)
19681992
else:
1969-
out[idx] += [None, None]
1993+
out[idx, 2] += [None]
1994+
out[idx, 3] += [None]
19701995

19711996
# Mark the start of the curve
19721997
if start_marker:
1973-
ax.plot(resp[0].real, resp[0].imag, start_marker,
1974-
color=c, markersize=start_marker_size)
1998+
segment = 0 if 0 in rescale_idx else 1 # regular vs scaled
1999+
out[idx, segment] += ax.plot(
2000+
resp[0].real, resp[0].imag, start_marker,
2001+
color=c, markersize=start_marker_size)
19752002

19762003
# Mark the -1 point
19772004
ax.plot([-1], [0], 'r+')

control/tests/descfcn_test.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,11 +190,11 @@ def test_describing_function_plot():
190190

191191
cplt = response.plot()
192192
assert len(plt.gcf().get_axes()) == 1 # make sure there is a plot
193-
assert len(cplt.lines[0]) == 4 and len(cplt.lines[1]) == 1
193+
assert len(cplt.lines[0]) == 5 and len(cplt.lines[1]) == 1
194194

195195
# Call plot directly
196196
cplt = ct.describing_function_plot(H_larger, F_saturation, amp, omega)
197-
assert len(cplt.lines[0]) == 4 and len(cplt.lines[1]) == 1
197+
assert len(cplt.lines[0]) == 5 and len(cplt.lines[1]) == 1
198198

199199

200200
def test_describing_function_exceptions():

control/tests/nyquist_test.py

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ def test_nyquist_indent_default(indentsys):
291291

292292
def test_nyquist_indent_dont(indentsys):
293293
# first value of default omega vector was 0.1, replaced by 0. for contour
294-
# indent_radius is larger than 0.1 -> no extra quater circle around origin
294+
# indent_radius is larger than 0.1 -> no extra quarter circle around origin
295295
with pytest.warns() as record:
296296
count, contour = ct.nyquist_response(
297297
indentsys, omega=[0, 0.2, 0.3, 0.4], indent_radius=.1007,
@@ -406,15 +406,16 @@ def test_linestyle_checks():
406406
# Set the line styles
407407
cplt = ct.nyquist_plot(
408408
sys, primary_style=[':', ':'], mirror_style=[':', ':'])
409-
assert all([line.get_linestyle() == ':' for line in cplt.lines[0]])
409+
assert all([lines[0].get_linestyle() == ':' for lines in cplt.lines[0, :]])
410410

411411
# Set the line colors
412412
cplt = ct.nyquist_plot(sys, color='g')
413-
assert all([line.get_color() == 'g' for line in cplt.lines[0]])
413+
assert all([line.get_color() == 'g' for line in cplt.lines[0, 0]])
414414

415415
# Turn off the mirror image
416416
cplt = ct.nyquist_plot(sys, mirror_style=False)
417-
assert cplt.lines[0][2:] == [None, None]
417+
assert cplt.lines[0, 2] == [None]
418+
assert cplt.lines[0, 3] == [None]
418419

419420
with pytest.raises(ValueError, match="invalid 'primary_style'"):
420421
ct.nyquist_plot(sys, primary_style=False)
@@ -428,6 +429,7 @@ def test_linestyle_checks():
428429
ct.nyquist_plot(sys, primary_style=':', mirror_style='-.')
429430

430431
@pytest.mark.usefixtures("editsdefaults")
432+
@pytest.mark.xfail(reason="updated code avoids warning")
431433
def test_nyquist_legacy():
432434
ct.use_legacy_defaults('0.9.1')
433435

@@ -526,6 +528,42 @@ def test_no_indent_pole():
526528
sys, warn_encirclements=False, indent_direction='none')
527529

528530

531+
def test_nyquist_rescale():
532+
sys = 2 * ct.tf([1], [1, 1]) * ct.tf([1], [1, 0])**2
533+
sys.name = 'How example'
534+
535+
# Default case
536+
resp = ct.nyquist_response(sys, indent_direction='left')
537+
cplt = resp.plot(label='default [0.15]')
538+
assert len(cplt.lines[0, 0]) == 2
539+
assert all([len(cplt.lines[0, i]) == 1 for i in range(1, 4)])
540+
541+
# Sharper corner
542+
cplt = ct.nyquist_plot(
543+
sys*4, indent_direction='left',
544+
max_curve_magnitude=17, blend_fraction=0.05, label='fraction=0.05')
545+
assert len(cplt.lines[0, 0]) == 2
546+
assert all([len(cplt.lines[0, i]) == 1 for i in range(1, 4)])
547+
548+
# More gradual corner
549+
cplt = ct.nyquist_plot(
550+
sys*0.25, indent_direction='left',
551+
max_curve_magnitude=13, blend_fraction=0.25, label='fraction=0.25')
552+
assert len(cplt.lines[0, 0]) == 2
553+
assert all([len(cplt.lines[0, i]) == 1 for i in range(1, 4)])
554+
555+
# No corner
556+
cplt = ct.nyquist_plot(
557+
sys*12, indent_direction='left',
558+
max_curve_magnitude=19, blend_fraction=0, label='fraction=0')
559+
assert len(cplt.lines[0, 0]) == 2
560+
assert all([len(cplt.lines[0, i]) == 1 for i in range(1, 4)])
561+
562+
# Bad value
563+
with pytest.raises(ValueError, match="blend_fraction must be between"):
564+
ct.nyquist_plot(sys, indent_direction='left', blend_fraction=1.2)
565+
566+
529567
if __name__ == "__main__":
530568
#
531569
# Interactive mode: generate plots for manual viewing
@@ -566,8 +604,8 @@ def test_no_indent_pole():
566604
sys = 3 * (s+6)**2 / (s * (s**2 + 1e-4 * s + 1))
567605
plt.figure()
568606
ct.nyquist_plot(sys)
569-
ct.nyquist_plot(sys, max_curve_magnitude=15)
570-
ct.nyquist_plot(sys, indent_radius=1e-6, max_curve_magnitude=25)
607+
ct.nyquist_plot(sys, max_curve_magnitude=10)
608+
ct.nyquist_plot(sys, indent_radius=1e-6, max_curve_magnitude=20)
571609

572610
print("Unusual Nyquist plot")
573611
sys = ct.tf([1], [1, 3, 2]) * ct.tf([1], [1, 0, 1])
@@ -595,3 +633,29 @@ def test_no_indent_pole():
595633
plt.figure()
596634
cplt = ct.nyquist_plot([sys, sys1, sys2])
597635
cplt.set_plot_title("Mixed FRD, tf data")
636+
637+
plt.figure()
638+
print("Jon How example")
639+
test_nyquist_rescale()
640+
641+
#
642+
# Save the figures in a PDF file for later comparisons
643+
#
644+
import subprocess
645+
from matplotlib.backends.backend_pdf import PdfPages
646+
from datetime import date
647+
648+
# Create the file to store figures
649+
try:
650+
git_info = subprocess.check_output(
651+
['git', 'describe'], text=True).strip()
652+
except subprocess.CalledProcessError:
653+
git_info = 'UNKNOWN-REPO-INFO'
654+
pdf = PdfPages(
655+
f'nyquist_gallery-{git_info}-{date.today().isoformat()}.pdf')
656+
657+
# Go through each figure and save it
658+
for fignum in plt.get_fignums():
659+
pdf.savefig(plt.figure(fignum))
660+
661+
pdf.close()
-89 Bytes
Loading
401 Bytes
Loading

doc/response.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -547,7 +547,7 @@ the computation of the Nyquist curve and the way the data are plotted:
547547
sys = ct.tf([1, 0.2], [1, 0, 1]) * ct.tf([1], [1, 0])
548548
nyqresp = ct.nyquist_response(sys)
549549
nyqresp.plot(
550-
max_curve_magnitude=6, max_curve_offset=1,
550+
max_curve_magnitude=6, max_curve_offset=1, blend_fraction=0.05,
551551
arrows=[0, 0.15, 0.3, 0.6, 0.7, 0.925],
552552
title='Custom Nyquist plot')
553553
print("Encirclements =", nyqresp.count)

0 commit comments

Comments
 (0)