Skip to content

Commit 43e3779

Browse files
committed
DEV:
- added singular values plot (function singular_values_plot in freqplot.py) - added a test of the new function (test_singular_values_plot in freqresp_test.py) - added an example jupyter notebook (singular-values-plot.ipynb)
1 parent 8b900ca commit 43e3779

File tree

3 files changed

+3378
-3
lines changed

3 files changed

+3378
-3
lines changed

control/freqplot.py

Lines changed: 162 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,10 @@
5454
from .exception import ControlMIMONotImplemented
5555
from .statesp import StateSpace
5656
from .xferfcn import TransferFunction
57+
from .lti import evalfr
5758
from . import config
5859

59-
__all__ = ['bode_plot', 'nyquist_plot', 'gangof4_plot',
60+
__all__ = ['bode_plot', 'nyquist_plot', 'gangof4_plot', 'singular_values_plot',
6061
'bode', 'nyquist', 'gangof4']
6162

6263
# Default values for module parameter variables
@@ -172,7 +173,7 @@ def bode_plot(syslist, omega=None,
172173
>>> mag, phase, omega = bode(sys)
173174
174175
"""
175-
# Make a copy of the kwargs dictonary since we will modify it
176+
# Make a copy of the kwargs dictionary since we will modify it
176177
kwargs = dict(kwargs)
177178

178179
# Check to see if legacy 'Plot' keyword was used
@@ -1039,14 +1040,173 @@ def gangof4_plot(P, C, omega=None, **kwargs):
10391040

10401041
plt.tight_layout()
10411042

1043+
#
1044+
# Singular value plot
1045+
#
1046+
1047+
1048+
def singular_values_plot(syslist, omega=None,
1049+
plot=True, omega_limits=None, omega_num=None,
1050+
*args, **kwargs):
1051+
"""Singular value plot for a system
1052+
1053+
Plots a Singular Value plot for the system over a (optional) frequency range.
1054+
1055+
Parameters
1056+
----------
1057+
syslist : linsys
1058+
List of linear systems (single system is OK)
1059+
omega : array_like
1060+
List of frequencies in rad/sec to be used for frequency response
1061+
plot : bool
1062+
If True (default), plot magnitude and phase
1063+
omega_limits : array_like of two values
1064+
Limits of the to generate frequency vector.
1065+
If Hz=True the limits are in Hz otherwise in rad/s.
1066+
omega_num : int
1067+
Number of samples to plot. Defaults to
1068+
config.defaults['freqplot.number_of_samples'].
1069+
1070+
Returns
1071+
-------
1072+
sigma : ndarray (or list of ndarray if len(syslist) > 1))
1073+
singular values
1074+
omega : ndarray (or list of ndarray if len(syslist) > 1))
1075+
frequency in rad/sec
1076+
1077+
Other Parameters
1078+
----------------
1079+
grid : bool
1080+
If True, plot grid lines on gain and phase plots. Default is set by
1081+
`config.defaults['bode.grid']`.
1082+
1083+
Examples
1084+
--------
1085+
>>> den = [75, 1]
1086+
>>> sys = ct.tf([[[87.8], [-86.4]], [[108.2], [-109.6]]], [[den, den], [den, den]])
1087+
>>> omega = np.logspace(-4, 1, 1000)
1088+
>>> sigma, omega = singular_values_plot(sys)
1089+
1090+
"""
1091+
# Make a copy of the kwargs dictionary since we will modify it
1092+
kwargs = dict(kwargs)
1093+
1094+
# Check to see if legacy 'Plot' keyword was used
1095+
if 'Plot' in kwargs:
1096+
import warnings
1097+
warnings.warn("'Plot' keyword is deprecated in bode_plot; use 'plot'",
1098+
FutureWarning)
1099+
# Map 'Plot' keyword to 'plot' keyword
1100+
plot = kwargs.pop('Plot')
1101+
1102+
# Get values for params (and pop from list to allow keyword use in plot)
1103+
dB = config._get_param('bode', 'dB', kwargs, _bode_defaults, pop=True)
1104+
Hz = config._get_param('bode', 'Hz', kwargs, _bode_defaults, pop=True)
1105+
grid = config._get_param('bode', 'grid', kwargs, _bode_defaults, pop=True)
1106+
plot = config._get_param('bode', 'grid', plot, True)
1107+
1108+
# If argument was a singleton, turn it into a tuple
1109+
if not hasattr(syslist, '__iter__'):
1110+
syslist = (syslist,)
1111+
1112+
# Decide whether to go above Nyquist frequency
1113+
omega_range_given = True if omega is not None else False
1114+
1115+
if omega is None:
1116+
omega_num = config._get_param(
1117+
'freqplot', 'number_of_samples', omega_num)
1118+
if omega_limits is None:
1119+
# Select a default range if none is provided
1120+
omega = _default_frequency_range(syslist, number_of_samples=omega_num)
1121+
else:
1122+
omega_range_given = True
1123+
omega_limits = np.asarray(omega_limits)
1124+
if len(omega_limits) != 2:
1125+
raise ValueError("len(omega_limits) must be 2")
1126+
if Hz:
1127+
omega_limits *= 2. * math.pi
1128+
omega = np.logspace(np.log10(omega_limits[0]),
1129+
np.log10(omega_limits[1]), num=omega_num,
1130+
endpoint=True)
1131+
1132+
if plot:
1133+
fig = plt.gcf()
1134+
ax_sigma = None
1135+
1136+
# Get the current axes if they already exist
1137+
for ax in fig.axes:
1138+
if ax.get_label() == 'control-sigma':
1139+
ax_sigma = ax
1140+
1141+
# If no axes present, create them from scratch
1142+
if ax_sigma is None:
1143+
plt.clf()
1144+
ax_sigma = plt.subplot(111, label='control-sigma')
10421145

1146+
color_cycle = plt.rcParams['axes.prop_cycle'].by_key()['color']
1147+
1148+
sigmas, omegas, nyquistfrqs = [], [], []
1149+
for idx_sys, sys in enumerate(syslist):
1150+
omega_sys = np.asarray(omega)
1151+
if sys.isdtime(strict=True):
1152+
nyquistfrq = math.pi / sys.dt
1153+
if not omega_range_given:
1154+
# limit up to and including nyquist frequency
1155+
omega_sys = np.hstack((
1156+
omega_sys[omega_sys < nyquistfrq], nyquistfrq))
1157+
else:
1158+
nyquistfrq = None
1159+
1160+
mag, phase, omega = sys.frequency_response(omega)
1161+
fresp = mag * np.exp(1j * phase)
1162+
#fresp = evalfr(sys, 1j * omega_sys)
1163+
1164+
fresp = fresp.transpose((2, 0, 1))
1165+
sigma = np.linalg.svd(fresp, compute_uv=False)
1166+
1167+
sigmas.append(sigma)
1168+
omegas.append(omega_sys)
1169+
nyquistfrqs.append(nyquistfrq)
1170+
1171+
if plot:
1172+
color = color_cycle[idx_sys % len(color_cycle)]
1173+
1174+
nyquistfrq_plot = None
1175+
if Hz:
1176+
omega_plot = omega_sys / (2. * math.pi)
1177+
if nyquistfrq:
1178+
nyquistfrq_plot = nyquistfrq / (2. * math.pi)
1179+
else:
1180+
omega_plot = omega_sys
1181+
if nyquistfrq:
1182+
nyquistfrq_plot = nyquistfrq
1183+
sigma_plot = sigma
1184+
1185+
if dB:
1186+
ax_sigma.semilogx(omega_plot, 20 * np.log10(sigma_plot), color=color, *args, **kwargs)
1187+
else:
1188+
ax_sigma.loglog(omega_plot, sigma_plot, color=color, *args, **kwargs)
1189+
1190+
if nyquistfrq_plot is not None:
1191+
ax_sigma.axvline(x=nyquistfrq_plot, color=color)
1192+
1193+
# Add a grid to the plot + labeling
1194+
ax_sigma.grid(grid, which='both')
1195+
ax_sigma.set_ylabel("Magnitude (dB)" if dB else "Magnitude")
1196+
ax_sigma.set_xlabel("Frequency (Hz)" if Hz else "Frequency (rad/sec)")
1197+
1198+
if len(syslist) == 1:
1199+
return sigmas[0], omegas[0]
1200+
else:
1201+
return sigmas, omegas
10431202
#
10441203
# Utility functions
10451204
#
10461205
# This section of the code contains some utility functions for
10471206
# generating frequency domain plots
10481207
#
10491208

1209+
10501210
# Compute reasonable defaults for axes
10511211
def _default_frequency_range(syslist, Hz=None, number_of_samples=None,
10521212
feature_periphery_decades=None):

control/tests/freqresp_test.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from control.statesp import StateSpace
1717
from control.xferfcn import TransferFunction
1818
from control.matlab import ss, tf, bode, rss
19-
from control.freqplot import bode_plot, nyquist_plot
19+
from control.freqplot import bode_plot, nyquist_plot, singular_values_plot
2020
from control.tests.conftest import slycotonly
2121

2222
pytestmark = pytest.mark.usefixtures("mplcleanup")
@@ -39,6 +39,7 @@ def ss_mimo():
3939
D = np.array([[0, 0]])
4040
return StateSpace(A, B, C, D)
4141

42+
4243
def test_freqresp_siso(ss_siso):
4344
"""Test SISO frequency response"""
4445
omega = np.linspace(10e-2, 10e2, 1000)
@@ -508,3 +509,17 @@ def test_dcgain_consistency():
508509

509510
sys_ss = ctrl.tf2ss(sys_tf)
510511
np.testing.assert_almost_equal(sys_ss.dcgain(), -1)
512+
513+
514+
def test_singular_values_plot():
515+
den = [75, 1]
516+
sys = tf([[[87.8], [-86.4]],
517+
[[108.2], [-109.6]]],
518+
[[den, den],
519+
[den, den]])
520+
sigma, omega = singular_values_plot(sys, 0.0)
521+
sys_dc = np.array([[87.8, -86.4],
522+
[108.2, -109.6]])
523+
524+
u, s, v = np.linalg.svd(sys_dc)
525+
np.testing.assert_almost_equal(sigma.ravel(), s.ravel())

0 commit comments

Comments
 (0)