From e01c580afdb80e3b79dea1a028f7e2387011541b Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 11 May 2024 10:04:36 -0700 Subject: [PATCH 01/15] small updates to comments + remove obsolete Pending --- Pending | 63 ---------------------------------- examples/steering-gainsched.py | 4 +-- 2 files changed, 2 insertions(+), 65 deletions(-) delete mode 100644 Pending diff --git a/Pending b/Pending deleted file mode 100644 index a1b5bda09..000000000 --- a/Pending +++ /dev/null @@ -1,63 +0,0 @@ -List of Pending changes for control-python -RMM, 5 Sep 09 - -This file contains brief notes on features that need to be added to -the python control library. Mainly intended to keep track of "bigger -picture" things that need to be done. - ---> See src/matlab.py for a list of MATLAB functions that eventually need - to be implemented. - -OPEN BUGS - * matlab.step() doesn't handle systems with a pole at the origin (use lsim2) - * TF <-> SS transformations are buggy; see tests/convert_test.py - * hsvd returns different value than MATLAB (2010a); see modelsimp_test.py - * lsim doesn't work for StateSpace systems (signal.lsim2 bug??) - -Transfer code from Roberto Bucher's yottalab to python-control - acker - pole placement using Ackermann method - c2d - contimous to discrete time conversion - full_obs - full order observer - red_obs - reduced order observer - comp_form - state feedback controller+observer in compact form - comp_form_i - state feedback controller+observer+integ in compact form - dsimul - simulate discrete time systems - dstep - step response (plot) of discrete time systems - dimpulse - imoulse response (plot) of discrete time systems - bb_step - step response (plot) of continous time systems - sysctr - system+controller+observer+feedback - care - Solve Riccati equation for contimous time systems - dare - Solve Riccati equation for discrete time systems - dlqr - discrete linear quadratic regulator - minreal - minimal state space representation - -Transfer code from Ryan Krauss's control.py to python-control - * phase margin computations (as part of margin command) - * step reponse - * c2d, c2d_tustin (compare to Bucher version first) - -Examples and test cases - * Put together unit tests for all functions (after deciding on framework) - * Figure out how to import 'figure' command properly (version issue?) - * Figure out source of BadCoefficients warning messages (pvtol-lqr and others) - * tests/test_all.py should report on failed tests - * tests/freqresp.py needs to be converted to unit test - * Convert examples/test-{response,statefbk}.py to unit tests - -Root locus plot improvements - * Make sure that scipy.signal.lti objects still work - * Update calling syntax to be consistent with other plotting commands - -State space class fixes - * Implement pzmap for state space systems - -Basic functions to be added - * margin - compute gain and phase margin (no plot) - * lyap - solve Lyapunov equation (use SLICOT SB03MD.f) - * See http://www.slicot.org/shared/libindex.html for list of functions - ----- -Instructions for building python package - * python setup.py build - * python setup.py install - * python setup.py sdist diff --git a/examples/steering-gainsched.py b/examples/steering-gainsched.py index 88eed9a95..85e8d8bda 100644 --- a/examples/steering-gainsched.py +++ b/examples/steering-gainsched.py @@ -222,7 +222,7 @@ def trajgen_output(t, x, u, params): vehicle, (gains, points), name='controller', ud_labels=['vd', 'phid'], gainsched_indices=['vd', 'theta'], gainsched_method='linear') -# Connect everything together (note that controller inputs are different +# Connect everything together (note that controller inputs are different) steering = ct.interconnect( # List of subsystems (trajgen, controller, vehicle), name='steering', @@ -235,7 +235,7 @@ def trajgen_output(t, x, u, params): ['controller.x', 'vehicle.x'], ['controller.y', 'vehicle.y'], ['controller.theta', 'vehicle.theta'], - ['controller.vd', ('trajgen', 'vd', 0.2)], # create error + ['controller.vd', ('trajgen', 'vd', 0.2)], # create some error ['controller.phid', 'trajgen.phid'], ['vehicle.v', 'controller.v'], ['vehicle.phi', 'controller.phi'] From f078b3e63c12ff23bd0f49f743fda34ea74eb919 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 18 May 2024 21:42:54 -0700 Subject: [PATCH 02/15] docstring updates for nyquist_plot --- control/freqplot.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/control/freqplot.py b/control/freqplot.py index 798b6da58..52454ebf2 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -1613,9 +1613,8 @@ def nyquist_plot( config.defaults['freqplot.number_of_samples']. Ignored if data is not a list of systems. plot : bool, optional - (legacy) If given, `bode_plot` returns the legacy return values - of magnitude, phase, and frequency. If False, just return the - values with no plot. + (legacy) If given, `nyquist_plot` returns the legacy return values + of (counts, contours). If False, return the values with no plot. primary_style : [str, str], optional Linestyles for primary image of the Nyquist curve. The first element is used for unscaled portions of the Nyquist curve, From 4838bbffdc4f0f8987304f813a40cc5a1c6ba263 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Mon, 29 Jul 2024 05:29:44 -0700 Subject: [PATCH 03/15] add unit test for testing documentation --- control/tests/docstrings_test.py | 87 ++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 control/tests/docstrings_test.py diff --git a/control/tests/docstrings_test.py b/control/tests/docstrings_test.py new file mode 100644 index 000000000..cafb9c8c1 --- /dev/null +++ b/control/tests/docstrings_test.py @@ -0,0 +1,87 @@ +# docstrings_test.py - test for undocumented arguments +# RMM, 28 Jul 2024 +# +# This unit test looks through all functions in the package and attempts to +# identify arguments that are not documented. It will check anything that +# is an explicitly listed argument, as well as attempt to find keyword +# arguments that are extracted using kwargs.pop(), config._get_param(), or +# config.use_legacy_defaults. + +import inspect +import warnings + +import pytest +import re + +import control +import control.flatsys + +# List of functions that we can skip testing (special cases) +skiplist = [ + control.ControlPlot.reshape, # needed for legacy interface +] + +@pytest.mark.parametrize("module, prefix", [ + (control, ""), (control.flatsys, "flatsys."), + (control.optimal, "optimal."), (control.phaseplot, "phaseplot.") +]) +def test_docstrings(module, prefix): + # Look through every object in the package + print(f"Checking module {module}") + for name, obj in inspect.getmembers(module): + # Skip anything that is outside of this module + if inspect.getmodule(obj) is not None and \ + not inspect.getmodule(obj).__name__.startswith('control'): + # Skip anything that isn't part of the control package + continue + + if inspect.isclass(obj): + print(f" Checking class {name}") + # Check member functions within the class + test_docstrings(obj, prefix + obj.__name__ + '.') + + if inspect.isfunction(obj): + # Skip anything that is inherited or hidden + if inspect.isclass(module) and obj.__name__ not in module.__dict__ \ + or obj.__name__.startswith('_') or obj in skiplist: + continue + + # Make sure there is a docstring + print(f" Checking function {name}") + if obj.__doc__ is None: + warnings.warn( + f"{module.__name__}.{obj.__name__} is missing docstring") + continue + + # Get the signature for the function + sig = inspect.signature(obj) + + # Go through each parameter and make sure it is in the docstring + for argname, par in sig.parameters.items(): + if argname == 'self': + continue + + if par.kind == inspect.Parameter.VAR_KEYWORD: + # Found a keyword argument; look at code for parsing + warnings.warn("keyword argument checks not yet implemented") + + # Make sure this argument is documented properly in docstring + else: + assert _check_docstring(obj.__name__, argname, obj.__doc__) + + +# Utility function to check for an argument in a docstring +def _check_docstring(funcname, argname, docstring): + if re.search(f" ([ \\w]+, )*{argname}(,[ \\w]+)*[^ ]:", docstring): + # Found the string, but not in numpydoc form + warnings.warn(f"{funcname} '{argname}' docstring missing space") + return True + + elif not re.search(f" ([ \\w]+, )*{argname}(,[ \\w]+)* :", docstring): + # return False + # + # Just issue a warning for now + warnings.warn(f"{funcname} '{argname}' not documented") + return True + + return True From b767e52565d6ab96ecea720e89a66cd570ed4f72 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Mon, 29 Jul 2024 06:53:03 -0700 Subject: [PATCH 04/15] change check_kwargs to _check_kwargs and disable docstring check --- control/descfcn.py | 4 ++-- control/freqplot.py | 6 +++--- control/phaseplot.py | 24 ++++++++++++------------ control/tests/docstrings_test.py | 2 +- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/control/descfcn.py b/control/descfcn.py index 4dce09250..35927a469 100644 --- a/control/descfcn.py +++ b/control/descfcn.py @@ -271,7 +271,7 @@ def __len__(self): # Compute the describing function response + intersections def describing_function_response( H, F, A, omega=None, refine=True, warn_nyquist=None, - plot=False, check_kwargs=True, **kwargs): + plot=False, _check_kwargs=True, **kwargs): """Compute the describing function response of a system. This function uses describing function analysis to analyze a closed @@ -328,7 +328,7 @@ def describing_function_response( # Start by drawing a Nyquist curve response = nyquist_response( H, omega, warn_encirclements=warn_nyquist, warn_nyquist=warn_nyquist, - check_kwargs=check_kwargs, **kwargs) + _check_kwargs=_check_kwargs, **kwargs) H_omega, H_vals = response.contour.imag, H(response.contour) # Compute the describing function diff --git a/control/freqplot.py b/control/freqplot.py index 52454ebf2..35ab5d4ec 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -1145,7 +1145,7 @@ def plot(self, *args, **kwargs): def nyquist_response( sysdata, omega=None, plot=None, omega_limits=None, omega_num=None, return_contour=False, warn_encirclements=True, warn_nyquist=True, - check_kwargs=True, **kwargs): + _check_kwargs=True, **kwargs): """Nyquist response for a system. Computes a Nyquist contour for the system over a (optional) frequency @@ -1260,7 +1260,7 @@ def nyquist_response( indent_points = config._get_param( 'nyquist', 'indent_points', kwargs, _nyquist_defaults, pop=True) - if check_kwargs and kwargs: + if _check_kwargs and kwargs: raise TypeError("unrecognized keywords: ", str(kwargs)) # Convert the first argument to a list @@ -1769,7 +1769,7 @@ def _parse_linestyle(style_name, allow_false=False): warn_encirclements=kwargs.pop('warn_encirclements', True), warn_nyquist=kwargs.pop('warn_nyquist', True), indent_radius=kwargs.pop('indent_radius', None), - check_kwargs=False, **kwargs) + _check_kwargs=False, **kwargs) else: nyquist_responses = data diff --git a/control/phaseplot.py b/control/phaseplot.py index 859c60c6a..2c56386a3 100644 --- a/control/phaseplot.py +++ b/control/phaseplot.py @@ -172,7 +172,7 @@ def _create_kwargs(global_kwargs, local_kwargs, **other_kwargs): kwargs, plot_streamlines, gridspec=gridspec, gridtype=gridtype, ax=ax) out[0] += streamlines( - sys, pointdata, timedata, check_kwargs=False, + sys, pointdata, timedata, _check_kwargs=False, suppress_warnings=suppress_warnings, **kwargs_local) # Get rid of keyword arguments handled by streamlines @@ -188,7 +188,7 @@ def _create_kwargs(global_kwargs, local_kwargs, **other_kwargs): kwargs_local = _create_kwargs( kwargs, plot_separatrices, gridspec=gridspec, ax=ax) out[0] += separatrices( - sys, pointdata, check_kwargs=False, **kwargs_local) + sys, pointdata, _check_kwargs=False, **kwargs_local) # Get rid of keyword arguments handled by separatrices for kw in ['arrows', 'arrow_size', 'arrow_style', 'params']: @@ -198,7 +198,7 @@ def _create_kwargs(global_kwargs, local_kwargs, **other_kwargs): kwargs_local = _create_kwargs( kwargs, plot_vectorfield, gridspec=gridspec, ax=ax) out[1] = vectorfield( - sys, pointdata, check_kwargs=False, **kwargs_local) + sys, pointdata, _check_kwargs=False, **kwargs_local) # Get rid of keyword arguments handled by vectorfield for kw in ['color', 'params']: @@ -208,7 +208,7 @@ def _create_kwargs(global_kwargs, local_kwargs, **other_kwargs): kwargs_local = _create_kwargs( kwargs, plot_equilpoints, gridspec=gridspec, ax=ax) out[2] = equilpoints( - sys, pointdata, check_kwargs=False, **kwargs_local) + sys, pointdata, _check_kwargs=False, **kwargs_local) # Get rid of keyword arguments handled by equilpoints for kw in ['params']: @@ -231,7 +231,7 @@ def _create_kwargs(global_kwargs, local_kwargs, **other_kwargs): def vectorfield( sys, pointdata, gridspec=None, ax=None, suppress_warnings=False, - check_kwargs=True, **kwargs): + _check_kwargs=True, **kwargs): """Plot a vector field in the phase plane. This function plots a vector field for a two-dimensional state @@ -301,7 +301,7 @@ def vectorfield( color = _get_color(kwargs, ax=ax) # Make sure all keyword arguments were processed - if check_kwargs and kwargs: + if _check_kwargs and kwargs: raise TypeError("unrecognized keywords: ", str(kwargs)) # Generate phase plane (quiver) data @@ -321,7 +321,7 @@ def vectorfield( def streamlines( sys, pointdata, timedata=1, gridspec=None, gridtype=None, dir=None, - ax=None, check_kwargs=True, suppress_warnings=False, **kwargs): + ax=None, _check_kwargs=True, suppress_warnings=False, **kwargs): """Plot stream lines in the phase plane. This function plots stream lines for a two-dimensional state space @@ -399,7 +399,7 @@ def streamlines( color = _get_color(kwargs, ax=ax) # Make sure all keyword arguments were processed - if check_kwargs and kwargs: + if _check_kwargs and kwargs: raise TypeError("unrecognized keywords: ", str(kwargs)) # Create reverse time system, if needed @@ -433,7 +433,7 @@ def streamlines( def equilpoints( - sys, pointdata, gridspec=None, color='k', ax=None, check_kwargs=True, + sys, pointdata, gridspec=None, color='k', ax=None, _check_kwargs=True, **kwargs): """Plot equilibrium points in the phase plane. @@ -496,7 +496,7 @@ def equilpoints( points, _ = _make_points(pointdata, gridspec, 'meshgrid') # Make sure all keyword arguments were processed - if check_kwargs and kwargs: + if _check_kwargs and kwargs: raise TypeError("unrecognized keywords: ", str(kwargs)) # Search for equilibrium points @@ -513,7 +513,7 @@ def equilpoints( def separatrices( sys, pointdata, timedata=None, gridspec=None, ax=None, - check_kwargs=True, suppress_warnings=False, **kwargs): + _check_kwargs=True, suppress_warnings=False, **kwargs): """Plot separatrices in the phase plane. This function plots separatrices for a two-dimensional state space @@ -606,7 +606,7 @@ def separatrices( stable_color = unstable_color = color # Make sure all keyword arguments were processed - if check_kwargs and kwargs: + if _check_kwargs and kwargs: raise TypeError("unrecognized keywords: ", str(kwargs)) # Create a "reverse time" system to use for simulation diff --git a/control/tests/docstrings_test.py b/control/tests/docstrings_test.py index cafb9c8c1..c90fdf682 100644 --- a/control/tests/docstrings_test.py +++ b/control/tests/docstrings_test.py @@ -58,7 +58,7 @@ def test_docstrings(module, prefix): # Go through each parameter and make sure it is in the docstring for argname, par in sig.parameters.items(): - if argname == 'self': + if argname == 'self' or argname[0] == '_': continue if par.kind == inspect.Parameter.VAR_KEYWORD: From 3697afa9fbb3429a3f975ea376a95d262d2390fe Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 3 Aug 2024 02:19:45 -0700 Subject: [PATCH 05/15] improve docstrings unit test for deprecation, seq args; skip duplicates --- control/tests/docstrings_test.py | 135 +++++++++++++++++++++++++------ 1 file changed, 111 insertions(+), 24 deletions(-) diff --git a/control/tests/docstrings_test.py b/control/tests/docstrings_test.py index c90fdf682..5792b1230 100644 --- a/control/tests/docstrings_test.py +++ b/control/tests/docstrings_test.py @@ -17,71 +17,158 @@ import control.flatsys # List of functions that we can skip testing (special cases) -skiplist = [ +function_skiplist = [ control.ControlPlot.reshape, # needed for legacy interface + control.phase_plot, # legacy function ] +# List of keywords that we can skip testing (special cases) +keyword_skiplist = { + control.input_output_response: ['method'], + control.nyquist_plot: ['color'], # checked separately + control.optimal.solve_ocp: ['method'], # deprecated + control.sisotool: ['kvect'], # deprecated +} + +# Decide on the level of verbosity (use -rP when running pytest) +verbose = 1 + @pytest.mark.parametrize("module, prefix", [ (control, ""), (control.flatsys, "flatsys."), (control.optimal, "optimal."), (control.phaseplot, "phaseplot.") ]) def test_docstrings(module, prefix): # Look through every object in the package - print(f"Checking module {module}") + if verbose > 1: + print(f"Checking module {module}") for name, obj in inspect.getmembers(module): # Skip anything that is outside of this module - if inspect.getmodule(obj) is not None and \ - not inspect.getmodule(obj).__name__.startswith('control'): + if inspect.getmodule(obj) is not None and ( + not inspect.getmodule(obj).__name__.startswith('control') + or prefix != "" and inspect.getmodule(obj) != module): # Skip anything that isn't part of the control package continue if inspect.isclass(obj): - print(f" Checking class {name}") + if verbose > 1: + print(f" Checking class {name}") # Check member functions within the class - test_docstrings(obj, prefix + obj.__name__ + '.') + test_docstrings(obj, prefix + name + '.') if inspect.isfunction(obj): - # Skip anything that is inherited or hidden - if inspect.isclass(module) and obj.__name__ not in module.__dict__ \ - or obj.__name__.startswith('_') or obj in skiplist: + # Skip anything that is inherited, hidden, or deprecated + if inspect.isclass(module) and name not in module.__dict__ \ + or name.startswith('_') or obj in function_skiplist: continue - # Make sure there is a docstring - print(f" Checking function {name}") + # Get the docstring (skip w/ warning if there isn't one) + if verbose > 1: + print(f" Checking function {name}") if obj.__doc__ is None: warnings.warn( - f"{module.__name__}.{obj.__name__} is missing docstring") + f"{module.__name__}.{name} is missing docstring") + continue + else: + docstring = inspect.getdoc(obj) + source = inspect.getsource(obj) + + # Skip deprecated functions + if f"{name} is deprecated" in docstring or \ + "function is deprecated" in docstring or \ + ".. deprecated::" in docstring: + if verbose > 1: + print(" [deprecated]") continue - + + elif f"{name} is deprecated" in source: + if verbose: + print(f" {name} is deprecated, but not documented") + warnings.warn(f"{name} deprecated, but not documented") + continue + # Get the signature for the function sig = inspect.signature(obj) # Go through each parameter and make sure it is in the docstring for argname, par in sig.parameters.items(): - if argname == 'self' or argname[0] == '_': + + # Look for arguments that we can skip + if argname == 'self' or argname[0] == '_' or \ + obj in keyword_skiplist and argname in keyword_skiplist[obj]: continue - - if par.kind == inspect.Parameter.VAR_KEYWORD: - # Found a keyword argument; look at code for parsing - warnings.warn("keyword argument checks not yet implemented") + + # Check for positional arguments + if par.kind == inspect.Parameter.VAR_POSITIONAL: + # Too complicated to check + if f"*{argname}" not in docstring and verbose: + print(f" {name} has positional arguments; " + "check manually") + continue + + # Check for keyword arguments (then look at code for parsing) + elif par.kind == inspect.Parameter.VAR_KEYWORD: + # See if we documented the keyward argumnt directly + if f"**{argname}" in docstring: + continue + + # Look for direct kwargs argument access + kwargnames = set() + for _, kwargname in re.findall( + argname + r"(\[|\.pop\(|\.get\()'([\w]+)'", + source): + if verbose > 2: + print(" Found direct keyword argument", + kwargname) + kwargnames.add(kwargname) + + # Look for kwargs access via _process_legacy_keyword + for kwargname in re.findall( + r"_process_legacy_keyword\([\s]*" + argname + + r",[\s]*'[\w]+',[\s]*'([\w]+)'", source): + if verbose > 2: + print(" Found legacy keyword argument", + {kwargname}) + kwargnames.add(kwargname) + + for kwargname in kwargnames: + if obj in keyword_skiplist and \ + kwargname in keyword_skiplist[obj]: + continue + if verbose > 3: + print(f" Checking keyword argument {kwargname}") + assert _check_docstring( + name, kwargname, inspect.getdoc(obj), + prefix=prefix) # Make sure this argument is documented properly in docstring else: - assert _check_docstring(obj.__name__, argname, obj.__doc__) + if verbose > 3: + print(f" Checking argument {argname}") + assert _check_docstring( + name, argname, docstring, prefix=prefix) # Utility function to check for an argument in a docstring -def _check_docstring(funcname, argname, docstring): - if re.search(f" ([ \\w]+, )*{argname}(,[ \\w]+)*[^ ]:", docstring): +def _check_docstring(funcname, argname, docstring, prefix=""): + funcname = prefix + funcname + if re.search( + "\n" + r"((\w+|\.{3}), )*" + argname + r"(, (\w+|\.{3}))*:", + docstring): # Found the string, but not in numpydoc form + if verbose: + print(f" {funcname}: {argname} docstring missing space") warnings.warn(f"{funcname} '{argname}' docstring missing space") return True - - elif not re.search(f" ([ \\w]+, )*{argname}(,[ \\w]+)* :", docstring): + + elif not re.search( + "\n" + r"((\w+|\.{3}), )*" + argname + r"(, (\w+|\.{3}))* :", + docstring): # return False # # Just issue a warning for now + if verbose: + print(f" {funcname}: {argname} not documented") warnings.warn(f"{funcname} '{argname}' not documented") return True - + return True From a59184c6031dff3132271d223acce3667e4c2565 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 3 Aug 2024 06:32:52 -0700 Subject: [PATCH 06/15] add hashes for positional arguments --- control/tests/docstrings_test.py | 76 +++++++++++++++++++++++++++----- 1 file changed, 64 insertions(+), 12 deletions(-) diff --git a/control/tests/docstrings_test.py b/control/tests/docstrings_test.py index 5792b1230..4635844e8 100644 --- a/control/tests/docstrings_test.py +++ b/control/tests/docstrings_test.py @@ -20,14 +20,36 @@ function_skiplist = [ control.ControlPlot.reshape, # needed for legacy interface control.phase_plot, # legacy function + control.drss, # documention in rss ] +# Checksums to use for checking whether a docstring has changed +function_docstring_hash = { + control.append: 'be014503250ef73253a5372a0d082566', + control.describing_function_plot: '726a10eef8f2b50ef46a203653f398c7', + control.dlqe: '9f637afdf36c7e17b7524f9a736772b6', + control.dlqr: 'a9265c5ed66388661729edb2379fd9a1', + control.lqe: 'd265b0daf0369569e4b755fa35db7a54', + control.lqr: '0b76455c2b873abcbcd959e069d9d241', + control.frd: '7ac3076368f11e407653cd1046bbc98d', + control.margin: '8ee27989f1ca521ce9affe5900605b75', + control.parallel: 'daa3b8708200a364d9b5536b6cbb5c91', + control.series: '7241169911b641c43f9456bd12168271', + control.ss: 'aa77e816305850502c21bc40ce796f40', + control.ss2tf: '8d663d474ade2950dd22ec86fe3a53b7', + control.tf: '4e8d21e71312d83ba2e15b9c095fd962', + control.tf2ss: '0e5da4f3ed4aaf000f3b454c466f9013', +} + # List of keywords that we can skip testing (special cases) keyword_skiplist = { control.input_output_response: ['method'], - control.nyquist_plot: ['color'], # checked separately - control.optimal.solve_ocp: ['method'], # deprecated - control.sisotool: ['kvect'], # deprecated + control.nyquist_plot: ['color'], # separate check + control.optimal.solve_ocp: ['method', 'return_x'], # deprecated + control.sisotool: ['kvect'], # deprecated + control.nyquist_response: ['return_contour'], # deprecated + control.create_estimator_iosystem: ['state_labels'], # deprecated + control.bode_plot: ['sharex', 'sharey', 'margin_info'] # deprecated } # Decide on the level of verbosity (use -rP when running pytest) @@ -38,6 +60,8 @@ (control.optimal, "optimal."), (control.phaseplot, "phaseplot.") ]) def test_docstrings(module, prefix): + checked = set() # Keep track of functions we have checked + # Look through every object in the package if verbose > 1: print(f"Checking module {module}") @@ -56,10 +80,13 @@ def test_docstrings(module, prefix): test_docstrings(obj, prefix + name + '.') if inspect.isfunction(obj): - # Skip anything that is inherited, hidden, or deprecated + # Skip anything that is inherited, hidden, deprecated, or checked if inspect.isclass(module) and name not in module.__dict__ \ - or name.startswith('_') or obj in function_skiplist: + or name.startswith('_') or obj in function_skiplist or \ + obj in checked: continue + else: + checked.add(obj) # Get the docstring (skip w/ warning if there isn't one) if verbose > 1: @@ -73,12 +100,18 @@ def test_docstrings(module, prefix): source = inspect.getsource(obj) # Skip deprecated functions - if f"{name} is deprecated" in docstring or \ - "function is deprecated" in docstring or \ - ".. deprecated::" in docstring: + if ".. deprecated::" in docstring: if verbose > 1: print(" [deprecated]") continue + elif f"{name} is deprecated" in docstring or \ + "function is deprecated" in docstring: + if verbose > 1: + print(" [deprecated, but not numpydoc compliant]") + elif verbose: + print(f" {name} deprecation is not numpydoc compliant") + warnings.warn(f"{name} deprecated, but not numpydoc compliant") + continue elif f"{name} is deprecated" in source: if verbose: @@ -99,17 +132,27 @@ def test_docstrings(module, prefix): # Check for positional arguments if par.kind == inspect.Parameter.VAR_POSITIONAL: + if obj in function_docstring_hash: + import hashlib + hash = hashlib.md5( + docstring.encode('utf-8')).hexdigest() + assert function_docstring_hash[obj] == hash + continue + # Too complicated to check if f"*{argname}" not in docstring and verbose: print(f" {name} has positional arguments; " "check manually") + warnings.warn( + f"{name} {argname} has positional arguments; " + "docstring not checked") continue # Check for keyword arguments (then look at code for parsing) elif par.kind == inspect.Parameter.VAR_KEYWORD: # See if we documented the keyward argumnt directly - if f"**{argname}" in docstring: - continue + # if f"**{argname} :" in docstring: + # continue # Look for direct kwargs argument access kwargnames = set() @@ -121,7 +164,16 @@ def test_docstrings(module, prefix): kwargname) kwargnames.add(kwargname) - # Look for kwargs access via _process_legacy_keyword + # Look for kwargs accessed via _get_param + for kwargname in re.findall( + r"_get_param\(\s*'\w*',\s*'([\w]+)',\s*" + argname, + source): + if verbose > 2: + print(" Found config keyword argument", + {kwargname}) + kwargnames.add(kwargname) + + # Look for kwargs accessed via _process_legacy_keyword for kwargname in re.findall( r"_process_legacy_keyword\([\s]*" + argname + r",[\s]*'[\w]+',[\s]*'([\w]+)'", source): @@ -169,6 +221,6 @@ def _check_docstring(funcname, argname, docstring, prefix=""): if verbose: print(f" {funcname}: {argname} not documented") warnings.warn(f"{funcname} '{argname}' not documented") - return True + return False return True From 4d9f57dd54109400a9105d2d2b637d6886fd987e Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 3 Aug 2024 06:33:20 -0700 Subject: [PATCH 07/15] update intersphinx URL for SciPy --- doc/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/conf.py b/doc/conf.py index 824f57904..75981d630 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -103,7 +103,7 @@ # This config value contains the locations and names of other projects that # should be linked to in this documentation. intersphinx_mapping = \ - {'scipy': ('https://docs.scipy.org/doc/scipy/reference', None), + {'scipy': ('https://docs.scipy.org/doc/scipy', None), 'numpy': ('https://numpy.org/doc/stable', None), 'matplotlib': ('https://matplotlib.org/stable/', None), } From 7f48535c1b035976a7eabf5dc7f6aea5ef02b88f Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 3 Aug 2024 06:43:14 -0700 Subject: [PATCH 08/15] update docstrings to fix missing arguments, incorrect format --- control/bdalg.py | 10 ++-- control/canonical.py | 26 ++++----- control/config.py | 7 +++ control/ctrlplot.py | 10 +++- control/ctrlutil.py | 3 +- control/delay.py | 8 +-- control/descfcn.py | 16 +++++- control/freqplot.py | 40 +++++++++++--- control/iosys.py | 34 ++++++++---- control/lti.py | 27 +++++++--- control/mateqn.py | 34 +++++++----- control/modelsimp.py | 62 ++++++++++++---------- control/nichols.py | 5 +- control/nlsys.py | 36 ++++++------- control/optimal.py | 34 +++++++++--- control/phaseplot.py | 38 ++++++++++++-- control/pzmap.py | 7 ++- control/rlocus.py | 2 +- control/robust.py | 90 ++++++++++++++++++-------------- control/sisotool.py | 18 +++---- control/statefbk.py | 10 ++-- control/statesp.py | 16 +++--- control/stochsys.py | 19 ++++++- control/tests/docstrings_test.py | 3 +- control/tests/nyquist_test.py | 6 ++- control/timeplot.py | 7 ++- control/xferfcn.py | 32 ++++++------ 27 files changed, 395 insertions(+), 205 deletions(-) diff --git a/control/bdalg.py b/control/bdalg.py index 7bfd327eb..b9965777f 100644 --- a/control/bdalg.py +++ b/control/bdalg.py @@ -73,7 +73,7 @@ def series(sys1, *sysn, **kwargs): Parameters ---------- - sys1, sys2, ..., sysn: scalar, array, or :class:`InputOutputSystem` + sys1, sys2, ..., sysn : scalar, array, or :class:`InputOutputSystem` I/O systems to combine. Returns @@ -145,7 +145,7 @@ def parallel(sys1, *sysn, **kwargs): Parameters ---------- - sys1, sys2, ..., sysn: scalar, array, or :class:`InputOutputSystem` + sys1, sys2, ..., sysn : scalar, array, or :class:`InputOutputSystem` I/O systems to combine. Returns @@ -213,7 +213,7 @@ def negate(sys, **kwargs): Parameters ---------- - sys: scalar, array, or :class:`InputOutputSystem` + sys : scalar, array, or :class:`InputOutputSystem` I/O systems to negate. Returns @@ -265,9 +265,9 @@ def feedback(sys1, sys2=1, sign=-1, **kwargs): Parameters ---------- - sys1, sys2: scalar, array, or :class:`InputOutputSystem` + sys1, sys2 : scalar, array, or :class:`InputOutputSystem` I/O systems to combine. - sign: scalar + sign : scalar The sign of feedback. `sign` = -1 indicates negative feedback, and `sign` = 1 indicates positive feedback. `sign` is an optional argument; it assumes a value of -1 if not specified. diff --git a/control/canonical.py b/control/canonical.py index 7d091b22f..a62044322 100644 --- a/control/canonical.py +++ b/control/canonical.py @@ -204,7 +204,7 @@ def similarity_transform(xsys, T, timescale=1, inverse=False): The matrix `T` defines the new set of coordinates z = T x. timescale : float, optional If present, also rescale the time unit to tau = timescale * t - inverse: boolean, optional + inverse : bool, optional If True (default), transform so z = T x. If False, transform so x = T z. @@ -397,21 +397,21 @@ def bdschur(a, condmax=None, sort=None): Parameters ---------- - a : (M, M) array_like - Real matrix to decompose - condmax : None or float, optional - If None (default), use 1/sqrt(eps), which is approximately 1e8 - sort : {None, 'continuous', 'discrete'} - Block sorting; see below. + a : (M, M) array_like + Real matrix to decompose + condmax : None or float, optional + If None (default), use 1/sqrt(eps), which is approximately 1e8 + sort : {None, 'continuous', 'discrete'} + Block sorting; see below. Returns ------- - amodal : (M, M) real ndarray - Block-diagonal Schur decomposition of `a` - tmodal : (M, M) real ndarray - Similarity transform relating `a` and `amodal` - blksizes : (N,) int ndarray - Array of Schur block sizes + amodal : (M, M) real ndarray + Block-diagonal Schur decomposition of `a` + tmodal : (M, M) real ndarray + Similarity transform relating `a` and `amodal` + blksizes : (N,) int ndarray + Array of Schur block sizes Notes ----- diff --git a/control/config.py b/control/config.py index 260c7dac6..db0b64374 100644 --- a/control/config.py +++ b/control/config.py @@ -83,6 +83,13 @@ def set_defaults(module, **keywords): The set_defaults() function can be used to modify multiple parameter values for a module at the same time, using keyword arguments. + Parameters + ---------- + module : str + Name of the module for which the defaults are being given. + **keywords : keyword arguments + Parameter value assignments. + Examples -------- >>> ct.defaults['freqplot.number_of_samples'] diff --git a/control/ctrlplot.py b/control/ctrlplot.py index bc5b2cb04..cc97e6885 100644 --- a/control/ctrlplot.py +++ b/control/ctrlplot.py @@ -207,7 +207,8 @@ def suptitle( title, fig=None, frame='axes', **kwargs): """Add a centered title to a figure. - This function is deprecated. Use :func:`ControlPlot.set_plot_title`. + .. deprecated:: 0.10.1 + Use :func:`ControlPlot.set_plot_title`. """ warnings.warn( @@ -220,6 +221,10 @@ def suptitle( def get_plot_axes(line_array): """Get a list of axes from an array of lines. + .. deprecated:: 0.10.1 + This function will be removed in a future version of python-control. + Use `cplt.axes` to obtain axes for a control plot `cplt`. + This function can be used to return the set of axes corresponding to the line array that is returned by `time_response_plot`. This is useful for generating an axes array that can be passed to @@ -268,6 +273,9 @@ def pole_zero_subplots( Scaling to apply to the subplots. fig : :class:`matplotlib.figure.Figure` Figure to use for creating subplots. + rcParams : dict + Override the default parameters used for generating plots. + Default is set up config.default['freqplot.rcParams']. Returns ------- diff --git a/control/ctrlutil.py b/control/ctrlutil.py index 6cd32593b..c16b8c35a 100644 --- a/control/ctrlutil.py +++ b/control/ctrlutil.py @@ -88,7 +88,8 @@ def unwrap(angle, period=2*math.pi): def issys(obj): """Deprecated function to check if an object is an LTI system. - Use isinstance(obj, ct.LTI) + .. deprecated:: 0.10.0 + Use isinstance(obj, ct.LTI) """ warnings.warn("issys() is deprecated; use isinstance(obj, ct.LTI)", diff --git a/control/delay.py b/control/delay.py index d22e44107..9a05675b0 100644 --- a/control/delay.py +++ b/control/delay.py @@ -57,10 +57,10 @@ def pade(T, n=1, numdeg=None): time delay n : positive integer degree of denominator of approximation - numdeg: integer, or None (the default) - If None, numerator degree equals denominator degree - If >= 0, specifies degree of numerator - If < 0, numerator degree is n+numdeg + numdeg : integer, or None (the default) + If numdeg is `None`, numerator degree equals denominator degree. + If numdeg >= 0, specifies degree of numerator. + If numdeg < 0, numerator degree is n+numdeg. Returns ------- diff --git a/control/descfcn.py b/control/descfcn.py index 35927a469..e17582c3f 100644 --- a/control/descfcn.py +++ b/control/descfcn.py @@ -102,6 +102,10 @@ def describing_function( A : array_like The amplitude(s) at which the describing function should be calculated. + num_points : int, optional + Number of points to use in computing describing function (default = + 100). + zero_check : bool, optional If `True` (default) then `A` is zero, the function will be evaluated and checked to make sure it is zero. If not, a `TypeError` exception @@ -271,7 +275,7 @@ def __len__(self): # Compute the describing function response + intersections def describing_function_response( H, F, A, omega=None, refine=True, warn_nyquist=None, - plot=False, _check_kwargs=True, **kwargs): + _check_kwargs=True, **kwargs): """Compute the describing function response of a system. This function uses describing function analysis to analyze a closed @@ -294,6 +298,10 @@ def describing_function_response( Set to True to turn on warnings generated by `nyquist_plot` or False to turn off warnings. If not set (or set to None), warnings are turned off if omega is specified, otherwise they are turned on. + refine : bool, optional + If `True`, :func:`scipy.optimize.minimize` to refine the estimate + of the intersection of the frequency response and the describing + function. Returns ------- @@ -420,6 +428,8 @@ def describing_function_plot( If True (default), refine the location of the intersection of the Nyquist curve for the linear system and the describing function to determine the intersection point + label : str or array_like of str, optional + If present, replace automatically generated label with the given label. point_label : str, optional Formatting string used to label intersection points on the Nyquist plot. Defaults to "%5.2g @ %-5.2g". Set to `None` to omit labels. @@ -429,6 +439,10 @@ def describing_function_plot( Otherwise, a new figure is created. title : str, optional Set the title of the plot. Defaults to plot type and system name(s). + warn_nyquist : bool, optional + Set to True to turn on warnings generated by `nyquist_plot` or False + to turn off warnings. If not set (or set to None), warnings are + turned off if omega is specified, otherwise they are turned on. **kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional Additional keywords passed to `matplotlib` to specify line properties for Nyquist curve. diff --git a/control/freqplot.py b/control/freqplot.py index 35ab5d4ec..dc2318b6f 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -147,8 +147,9 @@ def bode_plot( figure with the correct number and shape of axes, a new figure is created. The shape of the array must match the shape of the plotted data. - freq_label: str, optional - Frequency label (defaults to "rad/sec" or "Hertz") + freq_label, magnitude_label, phase_label : str, optional + Labels to use for the frequency, magnitude, and phase axes. + Defaults are set by `config.defaults['freqplot.']`. grid : bool, optional If True, plot grid lines on gain and phase plots. Default is set by `config.defaults['freqplot.grid']`. @@ -183,21 +184,36 @@ def bode_plot( Number of samples to use for the frequeny range. Defaults to config.defaults['freqplot.number_of_samples']. Ignored if data is not a list of systems. + overlay_inputs, overlay_outputs : bool, optional + If set to True, combine input and/or output signals onto a single + plot and use line colors, labels, and a legend to distinguish them. phase_label : str, optional Label to use for phase axis. Defaults to "Phase [rad]". plot : bool, optional (legacy) If given, `bode_plot` returns the legacy return values of magnitude, phase, and frequency. If False, just return the values with no plot. + plot_magnitude, plot_phase : bool, optional + If set to `False`, don't plot the magnitude or phase, respectively. rcParams : dict Override the default parameters used for generating plots. Default is set by config.default['ctrlplot.rcParams']. + share_frequency, share_magnitude, share_phase : str or bool, optional + Determine whether and how axis limits are shared between the + indicated variables. Can be set set to 'row' to share across all + subplots in a row, 'col' to set across all subplots in a column, or + `False` to allow independent limits. show_legend : bool, optional Force legend to be shown if ``True`` or hidden if ``False``. If ``None``, then show legend when there is more than one line on an axis or ``legend_loc`` or ``legend_map`` has been specified. title : str, optional Set the title of the plot. Defaults to plot type and system name(s). + title_frame : str, optional + Set the frame of reference used to center the plot title. If set to + 'axes' (default), the horizontal position of the title will + centered relative to the axes. If set to `figure`, it will be + centered with respect to the figure (faster execution). wrap_phase : bool or float If wrap_phase is `False` (default), then the phase will be unwrapped so that it is continuously increasing or decreasing. If wrap_phase is @@ -1143,7 +1159,7 @@ def plot(self, *args, **kwargs): def nyquist_response( - sysdata, omega=None, plot=None, omega_limits=None, omega_num=None, + sysdata, omega=None, omega_limits=None, omega_num=None, return_contour=False, warn_encirclements=True, warn_nyquist=True, _check_kwargs=True, **kwargs): """Nyquist response for a system. @@ -1221,8 +1237,7 @@ def nyquist_response( right of stable poles and the left of unstable poles. If a pole is exactly on the imaginary axis, the `indent_direction` parameter can be used to set the direction of indentation. Setting `indent_direction` - to `none` will turn off indentation. If `return_contour` is True, the - exact contour used for evaluation is returned. + to `none` will turn off indentation. 3. For those portions of the Nyquist plot in which the contour is indented to avoid poles, resuling in a scaling of the Nyquist plot, @@ -1623,7 +1638,7 @@ def nyquist_plot( determined by config.defaults['nyquist.mirror_style']. rcParams : dict Override the default parameters used for generating plots. - Default is set by config.default['freqplot.rcParams']. + Default is set by config.default['ctrlplot.rcParams']. return_contour : bool, optional (legacy) If 'True', return the encirclement count and Nyquist contour used to generate the Nyquist plot. @@ -1640,6 +1655,11 @@ def nyquist_plot( 4 and can be set using config.defaults['nyquist.start_marker_size']. title : str, optional Set the title of the plot. Defaults to plot type and system name(s). + title_frame : str, optional + Set the frame of reference used to center the plot title. If set to + 'axes' (default), the horizontal position of the title will + centered relative to the axes. If set to `figure`, it will be + centered with respect to the figure (faster execution). warn_nyquist : bool, optional If set to 'False', turn off warnings about frequencies above Nyquist. warn_encirclements : bool, optional @@ -2206,6 +2226,7 @@ def gangof4_plot( *args, omega=omega, omega_limits=omega_limits, omega_num=omega_num, Hz=Hz).plot(**kwargs) + # # Singular values plot # @@ -2339,6 +2360,8 @@ def singular_values_plot( The matplotlib axes to draw the figure on. If not specified and the current figure has a single axes, that axes is used. Otherwise, a new figure is created. + color : matplotlib color spec + Color to use for singular values (or None for matplotlib default). grid : bool If True, plot grid lines on gain and phase plots. Default is set by `config.defaults['freqplot.grid']`. @@ -2370,6 +2393,11 @@ def singular_values_plot( axis or ``legend_loc`` or ``legend_map`` has been specified. title : str, optional Set the title of the plot. Defaults to plot type and system name(s). + title_frame : str, optional + Set the frame of reference used to center the plot title. If set to + 'axes' (default), the horizontal position of the title will + centered relative to the axes. If set to `figure`, it will be + centered with respect to the figure (faster execution). See Also -------- diff --git a/control/iosys.py b/control/iosys.py index d00dade65..d76f65488 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -421,7 +421,7 @@ def isctime(self, strict=False): ---------- sys : Named I/O system System to be checked - strict: bool, optional + strict : bool, optional If strict is True, make sure that timebase is not None. Default is False. """ @@ -436,7 +436,7 @@ def isdtime(self, strict=False): Parameters ---------- - strict: bool, optional + strict : bool, optional If strict is True, make sure that timebase is not None. Default is False. """ @@ -466,7 +466,7 @@ def issiso(sys, strict=False): ---------- sys : I/O or LTI system System to be checked - strict: bool (default = False) + strict : bool (default = False) If strict is True, do not treat scalars as SISO """ if isinstance(sys, (int, float, complex, np.number)) and not strict: @@ -484,7 +484,21 @@ def timebase(sys, strict=True): dt = timebase(sys) returns the timebase for a system 'sys'. If the strict option is - set to False, dt = True will be returned as 1. + set to `True`, dt = True will be returned as 1. + + Parameters + ---------- + sys : InputOutputSystem or float + System whose timebase is to be determined. + strict : bool, optional + Whether to implement strict checking. If set to `True` (default), + a float will always be returned (dt = `True` will be returned as 1). + + Returns + ------- + dt : timebase + Timebase for the system (0 = continuous time, `None` = unspecified). + """ # System needs to be either a constant or an I/O or LTI system if isinstance(sys, (int, float, complex, np.number)): @@ -493,9 +507,9 @@ def timebase(sys, strict=True): raise ValueError("Timebase not defined") # Return the sample time, with converstion to float if strict is false - if (sys.dt == None): + if sys.dt == None: return None - elif (strict): + elif strict: return float(sys.dt) return sys.dt @@ -506,12 +520,12 @@ def common_timebase(dt1, dt2): Parameters ---------- - dt1, dt2: number or system with a 'dt' attribute (e.g. TransferFunction + dt1, dt2 : number or system with a 'dt' attribute (e.g. TransferFunction or StateSpace system) Returns ------- - dt: number + dt : number The common timebase of dt1 and dt2, as specified in :ref:`conventions-ref`. @@ -560,7 +574,7 @@ def isdtime(sys=None, strict=False, dt=None): System to be checked. dt : None or number, optional Timebase to be checked. - strict: bool, default=False + strict : bool, default=False If strict is True, make sure that timebase is not None. """ @@ -592,7 +606,7 @@ def isctime(sys=None, dt=None, strict=False): System to be checked. dt : None or number, optional Timebase to be checked. - strict: bool (default = False) + strict : bool (default = False) If strict is True, make sure that timebase is not None. """ diff --git a/control/lti.py b/control/lti.py index 2d69f6b91..dd4bcbced 100644 --- a/control/lti.py +++ b/control/lti.py @@ -210,12 +210,12 @@ def poles(sys): Parameters ---------- - sys: StateSpace or TransferFunction + sys : StateSpace or TransferFunction Linear system Returns ------- - poles: ndarray + poles : ndarray Array that contains the system's poles. See Also @@ -235,7 +235,7 @@ def zeros(sys): Parameters ---------- - sys: StateSpace or TransferFunction + sys : StateSpace or TransferFunction Linear system Returns @@ -330,7 +330,7 @@ def evalfr(sys, x, squeeze=None): Parameters ---------- - sys: StateSpace or TransferFunction + sys : StateSpace or TransferFunction Linear system x : complex scalar or 1D array_like Complex frequency(s) @@ -514,7 +514,13 @@ def frequency_response( # Alternative name (legacy) def freqresp(sys, omega): - """Legacy version of frequency_response.""" + """Legacy version of frequency_response. + + .. deprecated:: 0.9.0 + This function will be removed in a future version of python-control. + Use `frequency_response` instead. + + """ warn("freqresp is deprecated; use frequency_response", DeprecationWarning) return frequency_response(sys, omega) @@ -522,6 +528,11 @@ def freqresp(sys, omega): def dcgain(sys): """Return the zero-frequency (or DC) gain of the given system. + Parameters + ---------- + sys : LTI + System for which the zero-frequency gain is computed. + Returns ------- gain : ndarray @@ -544,11 +555,11 @@ def bandwidth(sys, dbdrop=-3): Parameters ---------- - sys: StateSpace or TransferFunction - Linear system + sys : StateSpace or TransferFunction + Linear system for which the bandwidth should be computed. dbdrop : float, optional By how much the gain drop in dB (default = -3) that defines the - bandwidth. Should be a negative scalar + bandwidth. Should be a negative scalar. Returns ------- diff --git a/control/mateqn.py b/control/mateqn.py index 05b47ffae..b73abdfcc 100644 --- a/control/mateqn.py +++ b/control/mateqn.py @@ -341,7 +341,7 @@ def dlyap(A, Q, C=None, E=None, method=None): # def care(A, B, Q, R=None, S=None, E=None, stabilizing=True, method=None, - A_s="A", B_s="B", Q_s="Q", R_s="R", S_s="S", E_s="E"): + _As="A", _Bs="B", _Qs="Q", _Rs="R", _Ss="S", _Es="E"): """Solves the continuous-time algebraic Riccati equation. X, L, G = care(A, B, Q, R=None) solves @@ -375,6 +375,9 @@ def care(A, B, Q, R=None, S=None, E=None, stabilizing=True, method=None, Set the method used for computing the result. Current methods are 'slycot' and 'scipy'. If set to None (default), try 'slycot' first and then 'scipy'. + stabilizing : bool, optional + If `method` is 'slycot', unstabilized eigenvalues will be returned + in the initial elements of `L`. Not supported for 'scipy'. Returns ------- @@ -404,10 +407,10 @@ def care(A, B, Q, R=None, S=None, E=None, stabilizing=True, method=None, m = B.shape[1] # Check to make sure input matrices are the right shape and type - _check_shape(A_s, A, n, n, square=True) - _check_shape(B_s, B, n, m) - _check_shape(Q_s, Q, n, n, square=True, symmetric=True) - _check_shape(R_s, R, m, m, square=True, symmetric=True) + _check_shape(_As, A, n, n, square=True) + _check_shape(_Bs, B, n, m) + _check_shape(_Qs, Q, n, n, square=True, symmetric=True) + _check_shape(_Rs, R, m, m, square=True, symmetric=True) # Solve the standard algebraic Riccati equation if S is None and E is None: @@ -454,8 +457,8 @@ def care(A, B, Q, R=None, S=None, E=None, stabilizing=True, method=None, E = np.eye(A.shape[0]) if E is None else np.array(E, ndmin=2) # Check to make sure input matrices are the right shape and type - _check_shape(E_s, E, n, n, square=True) - _check_shape(S_s, S, n, m) + _check_shape(_Es, E, n, n, square=True) + _check_shape(_Ss, S, n, m) # See if we should solve this using SciPy if method == 'scipy': @@ -494,7 +497,7 @@ def care(A, B, Q, R=None, S=None, E=None, stabilizing=True, method=None, return _ssmatrix(X), L, _ssmatrix(G) def dare(A, B, Q, R, S=None, E=None, stabilizing=True, method=None, - A_s="A", B_s="B", Q_s="Q", R_s="R", S_s="S", E_s="E"): + _As="A", _Bs="B", _Qs="Q", _Rs="R", _Ss="S", _Es="E"): """Solves the discrete-time algebraic Riccati equation. @@ -529,6 +532,9 @@ def dare(A, B, Q, R, S=None, E=None, stabilizing=True, method=None, Set the method used for computing the result. Current methods are 'slycot' and 'scipy'. If set to None (default), try 'slycot' first and then 'scipy'. + stabilizing : bool, optional + If `method` is 'slycot', unstabilized eigenvalues will be returned + in the initial elements of `L`. Not supported for 'scipy'. Returns ------- @@ -558,14 +564,14 @@ def dare(A, B, Q, R, S=None, E=None, stabilizing=True, method=None, m = B.shape[1] # Check to make sure input matrices are the right shape and type - _check_shape(A_s, A, n, n, square=True) - _check_shape(B_s, B, n, m) - _check_shape(Q_s, Q, n, n, square=True, symmetric=True) - _check_shape(R_s, R, m, m, square=True, symmetric=True) + _check_shape(_As, A, n, n, square=True) + _check_shape(_Bs, B, n, m) + _check_shape(_Qs, Q, n, n, square=True, symmetric=True) + _check_shape(_Rs, R, m, m, square=True, symmetric=True) if E is not None: - _check_shape(E_s, E, n, n, square=True) + _check_shape(_Es, E, n, n, square=True) if S is not None: - _check_shape(S_s, S, n, m) + _check_shape(_Ss, S, n, m) # Figure out how to solve the problem if method == 'scipy': diff --git a/control/modelsimp.py b/control/modelsimp.py index 62e210e99..d2eadc4c3 100644 --- a/control/modelsimp.py +++ b/control/modelsimp.py @@ -109,24 +109,25 @@ def hankel_singular_values(sys): def model_reduction(sys, ELIM, method='matchdc'): - """ + """Model reduction by state elimination. + Model reduction of `sys` by eliminating the states in `ELIM` using a given method. Parameters ---------- - sys: StateSpace - Original system to reduce - ELIM: array - Vector of states to eliminate - method: string - Method of removing states in `ELIM`: either ``'truncate'`` or - ``'matchdc'``. + sys : StateSpace + Original system to reduce. + ELIM : array + Vector of states to eliminate. + method : string + Method of removing states in `ELIM`: either 'truncate' or + 'matchdc'. Returns ------- - rsys: StateSpace - A reduced order model + rsys : StateSpace + A reduced order model. Raises ------ @@ -220,6 +221,7 @@ def model_reduction(sys, ELIM, method='matchdc'): def balanced_reduction(sys, orders, method='truncate', alpha=None): """Balanced reduced order model of sys of a given order. + States are eliminated based on Hankel singular value. If sys has unstable modes, they are removed, the balanced realization is done on the stable part, then @@ -231,14 +233,14 @@ def balanced_reduction(sys, orders, method='truncate', alpha=None): Parameters ---------- - sys: StateSpace - Original system to reduce - orders: integer or array of integer + sys : StateSpace + Original system to reduce. + orders : integer or array of integer Desired order of reduced order model (if a vector, returns a vector - of systems) - method: string - Method of removing states, either ``'truncate'`` or ``'matchdc'``. - alpha: float + of systems). + method : string + Method of removing states, either ``'truncate'`` or ``'matchdc'``.. + alpha : float Redefines the stability boundary for eigenvalues of the system matrix A. By default for continuous-time systems, alpha <= 0 defines the stability boundary for the real part of A's eigenvalues @@ -248,7 +250,7 @@ def balanced_reduction(sys, orders, method='truncate', alpha=None): Returns ------- - rsys: StateSpace + rsys : StateSpace A reduced order model or a list of reduced order models if orders is a list. @@ -343,7 +345,8 @@ def balanced_reduction(sys, orders, method='truncate', alpha=None): def minimal_realization(sys, tol=None, verbose=True): - ''' + """ Eliminate uncontrollable or unobservable states. + Eliminates uncontrollable or unobservable states in state-space models or cancelling pole-zero pairs in transfer functions. The output sysr has minimal order and the same response @@ -351,18 +354,19 @@ def minimal_realization(sys, tol=None, verbose=True): Parameters ---------- - sys: StateSpace or TransferFunction - Original system - tol: real - Tolerance - verbose: bool - Print results if True + sys : StateSpace or TransferFunction + Original system. + tol : real + Tolerance. + verbose : bool + Print results if True. Returns ------- - rsys: StateSpace or TransferFunction - Cleaned model - ''' + rsys : StateSpace or TransferFunction + Cleaned model. + + """ sysr = sys.minreal(tol) if verbose: print("{nstates} states have been removed from the model".format( @@ -371,7 +375,7 @@ def minimal_realization(sys, tol=None, verbose=True): def _block_hankel(Y, m, n): - """Create a block Hankel matrix from impulse response""" + """Create a block Hankel matrix from impulse response.""" q, p, _ = Y.shape YY = Y.transpose(0,2,1) # transpose for reshape diff --git a/control/nichols.py b/control/nichols.py index 188b5ec0c..ac42c9c37 100644 --- a/control/nichols.py +++ b/control/nichols.py @@ -88,6 +88,9 @@ def nichols_plot( legend_loc : int or str, optional Include a legend in the given location. Default is 'upper left', with no legend for a single response. Use False to suppress legend. + rcParams : dict + Override the default parameters used for generating plots. + Default is set by config.default['ctrlplot.rcParams']. show_legend : bool, optional Force legend to be shown if ``True`` or hidden if ``False``. If ``None``, then show legend when there is more than one line on the @@ -201,7 +204,7 @@ def nichols_grid(cl_mags=None, cl_phases=None, line_style='dotted', ax=None, ` ax : matplotlib.axes.Axes, optional Axes to add grid to. If ``None``, use ``matplotlib.pyplot.gca()``. - label_cl_phases: bool, optional + label_cl_phases : bool, optional If True, closed-loop phase lines will be labelled. Returns diff --git a/control/nlsys.py b/control/nlsys.py index d6d3b1b76..4dd184583 100644 --- a/control/nlsys.py +++ b/control/nlsys.py @@ -92,9 +92,8 @@ class NonlinearIOSystem(InputOutputSystem): generic name is generated with a unique integer id. params : dict, optional - Parameter values for the systems. Passed to the evaluation - functions for the system as default values, overriding internal - defaults. + Parameter values for the system. Passed to the evaluation functions + for the system as default values, overriding internal defaults. See Also -------- @@ -1270,9 +1269,8 @@ def nlsys( generic name is generated with a unique integer id. params : dict, optional - Parameter values for the systems. Passed to the evaluation - functions for the system as default values, overriding internal - defaults. + Parameter values for the system. Passed to the evaluation functions + for the system as default values, overriding internal defaults. Returns ------- @@ -1320,31 +1318,28 @@ def input_output_response( ---------- sys : NonlinearIOSystem or list of NonlinearIOSystem I/O system(s) for which input/output response is simulated. - T : array-like Time steps at which the input is defined; values must be evenly spaced. - U : array-like, list, or number, optional Input array giving input at each time `T` (default = 0). If a list is specified, each element in the list will be treated as a portion of the input and broadcast (if necessary) to match the time vector. - X0 : array-like, list, or number, optional Initial condition (default = 0). If a list is given, each element in the list will be flattened and stacked into the initial condition. If a smaller number of elements are given that the number of states in the system, the initial condition will be padded with zeros. - t_eval : array-list, optional List of times at which the time response should be computed. Defaults to ``T``. - return_x : bool, optional If True, return the state vector when assigning to a tuple (default = False). See :func:`forced_response` for more details. If True, return the values of the state at each time (default = False). - + params : dict, optional + Parameter values for the system. Passed to the evaluation functions + for the system as default values, overriding internal defaults. squeeze : bool, optional If True and if the system has a single output, return the system output as a 1D array rather than a 2D array. If False, return the @@ -1379,16 +1374,19 @@ def input_output_response( Other parameters ---------------- - solve_ivp_method : str, optional - Set the method used by :func:`scipy.integrate.solve_ivp`. Defaults - to 'RK45'. - solve_ivp_kwargs : dict, optional - Pass additional keywords to :func:`scipy.integrate.solve_ivp`. ignore_errors : bool, optional If ``False`` (default), errors during computation of the trajectory will raise a ``RuntimeError`` exception. If ``True``, do not raise an exception and instead set ``results.success`` to ``False`` and place an error message in ``results.message``. + solve_ivp_method : str, optional + Set the method used by :func:`scipy.integrate.solve_ivp`. Defaults + to 'RK45'. + solve_ivp_kwargs : dict, optional + Pass additional keywords to :func:`scipy.integrate.solve_ivp`. + transpose : bool, default=False + If True, transpose all input and output arrays (for backward + compatibility with MATLAB and :func:`scipy.signal.lsim`). Raises ------ @@ -1675,6 +1673,8 @@ def find_eqpt(sys, x0, u0=None, y0=None, t=0, params=None, Parameters ---------- + sys : NonlinearIOSystem + I/O system for which the equilibrium point is sought. x0 : list of initial state values Initial guess for the value of the state near the equilibrium point. u0 : list of input values, optional @@ -1935,7 +1935,7 @@ def linearize(sys, xeq, ueq=None, t=0, params=None, **kw): Parameters ---------- sys : InputOutputSystem - The system to be linearized + The system to be linearized. xeq : array The state at which the linearization will be evaluated (does not need to be an equilibrium state). diff --git a/control/optimal.py b/control/optimal.py index ce80eccfc..7a641acf4 100644 --- a/control/optimal.py +++ b/control/optimal.py @@ -72,7 +72,7 @@ class OptimalControlProblem(): Method to use for carrying out the optimization. Currently supported methods are 'shooting' and 'collocation' (continuous time only). The default value is 'shooting' for discrete time systems and - 'collocation' for continuous time systems + 'collocation' for continuous time systems. initial_guess : (tuple of) 1D or 2D array_like Initial states and/or inputs to use as a guess for the optimal trajectory. For shooting methods, an array of inputs for each time @@ -732,6 +732,7 @@ def _simulate_states(self, x0, inputs): logging.debug("input =\n" + str(inputs)) # Simulate the system to get the state + # TODO: update to use response object; remove return_x _, _, states = ct.input_output_response( self.system, self.timepts, inputs, x0, return_x=True, solve_ivp_kwargs=self.solve_ivp_kwargs, t_eval=self.timepts) @@ -981,7 +982,7 @@ def solve_ocp( timepts : 1D array_like List of times at which the optimal input should be computed. - X0: array-like or number, optional + X0 : array-like or number, optional Initial condition (default = 0). cost : callable @@ -1022,6 +1023,16 @@ def solve_ocp( 1D input of shape (ninputs,) that will be broadcast by extension of the time axis. + basis : BasisFamily, optional + Use the given set of basis functions for the inputs instead of + setting the value of the input at each point in the timepts vector. + + trajectory_method : string, optional + Method to use for carrying out the optimization. Currently supported + methods are 'shooting' and 'collocation' (continuous time only). The + default value is 'shooting' for discrete time systems and + 'collocation' for continuous time systems. + log : bool, optional If `True`, turn on logging messages (using Python logging module). @@ -1061,6 +1072,11 @@ def solve_ocp( res.states : array Time evolution of the state vector (if return_states=True). + Other Parameters + ---------------- + minimize_method : str, optional + Set the method used by :func:`scipy.optimize.minimize`. + Notes ----- 1. For discrete time systems, the final value of the timepts vector @@ -1079,9 +1095,9 @@ def solve_ocp( """ # Process keyword arguments - if trajectory_constraints is None: - # Backwards compatibility - trajectory_constraints = kwargs.pop('constraints', []) + trajectory_constraints = config._process_legacy_keyword( + kwargs, 'constraints', 'trajectory_constraints', + trajectory_constraints) # Allow 'return_x` as a synonym for 'return_states' return_states = ct.config._get_param( @@ -1168,6 +1184,10 @@ def create_mpc_iosystem( inputs, outputs, states : int or list of str, optional Set the names of the inputs, outputs, and states, as described in :func:`~control.InputOutputSystem`. + log : bool, optional + If `True`, turn on logging messages (using Python logging module). + Use :py:func:`logging.basicConfig` to enable logging output + (e.g., to a file). name : string, optional System name (used for specifying signals). If unspecified, a generic name is generated with a unique integer id. @@ -1936,12 +1956,12 @@ def solve_oep( I/O system for which the optimal input will be computed. timepts : 1D array_like List of times at which the optimal input should be computed. - Y, U: 2D array_like + Y, U : 2D array_like Values of the outputs and inputs at each time point. trajectory_cost : callable Function that returns the cost given the current state and input. Called as `cost(y, u, x0)`. - X0: 1D array_like, optional + X0 : 1D array_like, optional Mean value of the initial condition (defaults to 0). trajectory_constraints : list of tuples, optional List of constraints that should hold at each point in the time vector. diff --git a/control/phaseplot.py b/control/phaseplot.py index 2c56386a3..e48755080 100644 --- a/control/phaseplot.py +++ b/control/phaseplot.py @@ -119,6 +119,11 @@ def phase_plane_plot( Other parameters ---------------- + dir : str, optional + Direction to draw streamlines: 'forward' to flow forward in time + from the reference points, 'reverse' to flow backward in time, or + 'both' to flow both forward and backward. The amount of time to + simulate in each direction is given by the ``timedata`` argument. plot_streamlines : bool or dict, optional If `True` (default) then plot streamlines based on the pointdata and gridtype. If set to a dict, pass on the key-value pairs in @@ -135,6 +140,9 @@ def phase_plane_plot( If `True` (default) then plot separatrices starting from each equilibrium point. If set to a dict, pass on the key-value pairs in the dict as keywords to :func:`~control.phaseplot.separatrices`. + rcParams : dict + Override the default parameters used for generating plots. + Default is set by config.default['ctrlplot.rcParams']. suppress_warnings : bool, optional If set to `True`, suppress warning messages in generating trajectories. title : str, optional @@ -274,6 +282,9 @@ def vectorfield( Other parameters ---------------- + rcParams : dict + Override the default parameters used for generating plots. + Default is set by config.default['ctrlplot.rcParams']. suppress_warnings : bool, optional If set to `True`, suppress warning messages in generating trajectories. @@ -352,6 +363,11 @@ def streamlines( If gridtype is 'circlegrid', then `gridspec` is a 2-tuple specifying the radius and number of points around each point in the `pointdata` array. + dir : str, optional + Direction to draw streamlines: 'forward' to flow forward in time + from the reference points, 'reverse' to flow backward in time, or + 'both' to flow both forward and backward. The amount of time to + simulate in each direction is given by the ``timedata`` argument. params : dict or list, optional Parameters to pass to system. For an I/O system, `params` should be a dict of parameters and values. For a callable, `params` should be @@ -367,6 +383,9 @@ def streamlines( Other parameters ---------------- + rcParams : dict + Override the default parameters used for generating plots. + Default is set by config.default['ctrlplot.rcParams']. suppress_warnings : bool, optional If set to `True`, suppress warning messages in generating trajectories. @@ -474,6 +493,12 @@ def equilpoints( ------- out : list of Line2D objects + Other parameters + ---------------- + rcParams : dict + Override the default parameters used for generating plots. + Default is set by config.default['ctrlplot.rcParams']. + """ # Process keywords rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True) @@ -563,6 +588,9 @@ def separatrices( Other parameters ---------------- + rcParams : dict + Override the default parameters used for generating plots. + Default is set by config.default['ctrlplot.rcParams']. suppress_warnings : bool, optional If set to `True`, suppress warning messages in generating trajectories. @@ -686,13 +714,13 @@ def boxgrid(xvals, yvals): Parameters ---------- - xvals, yvals: 1D array-like + xvals, yvals : 1D array-like Array of points defining the points on the lower and left edges of the box. Returns ------- - grid: 2D array + grid : 2D array Array with shape (p, 2) defining the points along the edges of the box, where p is the number of points around the edge. @@ -715,7 +743,7 @@ def meshgrid(xvals, yvals): Parameters ---------- - xvals, yvals: 1D array-like + xvals, yvals : 1D array-like Array of points defining the points on the lower and left edges of the box. @@ -1243,9 +1271,13 @@ def phase_plot(odefun, X=None, Y=None, scale=1, X0=None, T=None, def box_grid(xlimp, ylimp): """box_grid generate list of points on edge of box + .. deprecated:: 0.10.0 + Use `phaseplot.boxgrid` instead. + list = box_grid([xmin xmax xnum], [ymin ymax ynum]) generates a list of points that correspond to a uniform grid at the end of the box defined by the corners [xmin ymin] and [xmax ymax]. + """ # Generate a deprecation warning diff --git a/control/pzmap.py b/control/pzmap.py index c248cf84a..77f617977 100644 --- a/control/pzmap.py +++ b/control/pzmap.py @@ -142,7 +142,7 @@ def pole_zero_map(sysdata): Parameters ---------- - sys : LTI system (StateSpace or TransferFunction) + sysdata : LTI system (StateSpace or TransferFunction) Linear system for which poles and zeros are computed. Returns @@ -185,7 +185,7 @@ def pole_zero_plot( Parameters ---------- - sysdata : List of PoleZeroData objects or LTI systems + data : List of PoleZeroData objects or LTI systems List of pole/zero response data objects generated by pzmap_response() or rootlocus_response() that are to be plotted. If a list of systems is given, the poles and zeros of those systems will be plotted. @@ -251,6 +251,9 @@ def pole_zero_plot( Set the size of the markers used for poles and zeros. marker_width : int, optional Set the line width of the markers used for poles and zeros. + rcParams : dict + Override the default parameters used for generating plots. + Default is set by config.default['ctrlplot.rcParams']. scaling : str or list, optional Set the type of axis scaling. Can be 'equal' (default), 'auto', or a list of the form [xmin, xmax, ymin, ymax]. diff --git a/control/rlocus.py b/control/rlocus.py index 95fda3e9a..6a9e56b32 100644 --- a/control/rlocus.py +++ b/control/rlocus.py @@ -47,7 +47,7 @@ def root_locus_map(sysdata, gains=None): Parameters ---------- - sys : LTI system or list of LTI systems + sysdata : LTI system or list of LTI systems Linear input/output systems (SISO only, for now). gains : array_like, optional Gains to use in computing plot of closed-loop poles. If not given, diff --git a/control/robust.py b/control/robust.py index d5e5540fb..f9283af48 100644 --- a/control/robust.py +++ b/control/robust.py @@ -52,13 +52,17 @@ def h2syn(P, nmeas, ncon): Parameters ---------- - P: partitioned lti plant (State-space sys) - nmeas: number of measurements (input to controller) - ncon: number of control inputs (output from controller) + P : StateSpace + Partitioned LTI plant (state-space system). + nmeas : int + Number of measurements (input to controller). + ncon : int + Number of control inputs (output from controller). Returns ------- - K: controller to stabilize P (State-space sys) + K : StateSpace + Controller to stabilize `P`. Raises ------ @@ -121,25 +125,32 @@ def h2syn(P, nmeas, ncon): def hinfsyn(P, nmeas, ncon): + # TODO: document significance of rcond """H_{inf} control synthesis for plant P. Parameters ---------- - P: partitioned lti plant - nmeas: number of measurements (input to controller) - ncon: number of control inputs (output from controller) + P : StateSpace + Partitioned LTI plant (state-space system). + nmeas : int + Number of measurements (input to controller). + ncon : int + Number of control inputs (output from controller). Returns ------- - K: controller to stabilize P (State-space sys) - CL: closed loop system (State-space sys) - gam: infinity norm of closed loop system - rcond: 4-vector, reciprocal condition estimates of: - 1: control transformation matrix - 2: measurement transformation matrix - 3: X-Riccati equation - 4: Y-Riccati equation - TODO: document significance of rcond + K : StateSpace + Controller to stabilize `P`. + CL : StateSpace + Closed loop system. + gam : float + Infinity norm of closed loop system. + rcond : list + 4-vector, reciprocal condition estimates of: + 1: control transformation matrix + 2: measurement transformation matrix + 3: X-Riccati equation + 4: Y-Riccati equation Raises ------ @@ -263,18 +274,18 @@ def augw(g, w1=None, w2=None, w3=None): Parameters ---------- - g: LTI object, ny-by-nu - Plant - w1: None, scalar, or k1-by-ny LTI object - Weighting on S - w2: None, scalar, or k2-by-nu LTI object - Weighting on KS - w3: None, scalar, or k3-by-ny LTI object - Weighting on T + g : LTI object, ny-by-nu + Plant. + w1 : None, scalar, or k1-by-ny LTI object + Weighting on S. + w2 : None, scalar, or k2-by-nu LTI object + Weighting on KS. + w3 : None, scalar, or k3-by-ny LTI object + Weighting on T. Returns ------- - p: StateSpace + p : StateSpace Plant augmented with weightings, suitable for submission to hinfsyn or h2syn. @@ -375,20 +386,20 @@ def mixsyn(g, w1=None, w2=None, w3=None): Parameters ---------- - g: LTI - The plant for which controller must be synthesized - w1: None, or scalar or k1-by-ny LTI - Weighting on S = (1+G*K)**-1 - w2: None, or scalar or k2-by-nu LTI - Weighting on K*S - w3: None, or scalar or k3-by-ny LTI - Weighting on T = G*K*(1+G*K)**-1; + g : LTI + The plant for which controller must be synthesized. + w1 : None, or scalar or k1-by-ny LTI + Weighting on S = (1+G*K)**-1. + w2 : None, or scalar or k2-by-nu LTI + Weighting on K*S. + w3 : None, or scalar or k3-by-ny LTI + Weighting on T = G*K*(1+G*K)**-1. Returns ------- - k: StateSpace - Synthesized controller; - cl: StateSpace + k : StateSpace + Synthesized controller. + cl : StateSpace Closed system mapping evaluation inputs to evaluation outputs. Let p be the augmented plant, with:: @@ -400,10 +411,10 @@ def mixsyn(g, w1=None, w2=None, w3=None): info: tuple gamma: scalar - H-infinity norm of cl + H-infinity norm of cl. rcond: array - Estimates of reciprocal condition numbers - computed during synthesis. See hinfsyn for details + Estimates of reciprocal condition numbers computed during + synthesis. See hinfsyn for details. If a weighting w is scalar, it will be replaced by I*w, where I is ny-by-ny for w1 and w3, and nu-by-nu for w2. @@ -411,6 +422,7 @@ def mixsyn(g, w1=None, w2=None, w3=None): See Also -------- hinfsyn, augw + """ nmeas = g.noutputs ncon = g.ninputs diff --git a/control/sisotool.py b/control/sisotool.py index a6b9d468b..f34b210c6 100644 --- a/control/sisotool.py +++ b/control/sisotool.py @@ -324,30 +324,30 @@ def rootlocus_pid_designer(plant, gain='P', sign=+1, input_signal='r', ---------- plant : :class:`LTI` (:class:`TransferFunction` or :class:`StateSpace` system) The dynamical system to be controlled. - gain : string (optional) + gain : string, optional Which gain to vary by `deltaK`. Must be one of `'P'`, `'I'`, or `'D'` (proportional, integral, or derative). - sign : int (optional) + sign : int, optional The sign of deltaK gain perturbation. - input : string (optional) + input_signal : string, optional The input used for the step response; must be `'r'` (reference) or `'d'` (disturbance) (see figure above). - Kp0, Ki0, Kd0 : float (optional) + Kp0, Ki0, Kd0 : float, optional Initial values for proportional, integral, and derivative gains, respectively. - deltaK : float (optional) + deltaK : float, optional Perturbation value for gain specified by the `gain` keywoard. - tau : float (optional) + tau : float, optional The time constant associated with the pole in the continuous-time derivative term. This is required to make the derivative transfer function proper. - C_ff : float or :class:`LTI` system (optional) + C_ff : float or :class:`LTI` system, optional Feedforward controller. If :class:`LTI`, must have timebase that is compatible with plant. - derivative_in_feedback_path : bool (optional) + derivative_in_feedback_path : bool, optional Whether to place the derivative term in feedback transfer function `C_b` instead of the forward transfer function `C_f`. - plot : bool (optional) + plot : bool, optional Whether to create Sisotool interactive plot. Returns diff --git a/control/statefbk.py b/control/statefbk.py index a385516ee..16eeb36ee 100644 --- a/control/statefbk.py +++ b/control/statefbk.py @@ -438,7 +438,7 @@ def lqr(*args, **kwargs): raise TypeError("unrecognized keywords: ", str(kwargs)) # Compute the result (dimension and symmetry checking done in care()) - X, L, G = care(A, B, Q, R, N, None, method=method, S_s="N") + X, L, G = care(A, B, Q, R, N, None, method=method, _Ss="N") return G, X, L @@ -575,7 +575,7 @@ def dlqr(*args, **kwargs): raise TypeError("unrecognized keywords: ", str(kwargs)) # Compute the result (dimension and symmetry checking done in dare()) - S, E, K = dare(A, B, Q, R, N, method=method, S_s="N") + S, E, K = dare(A, B, Q, R, N, method=method, _Ss="N") return _ssmatrix(K), _ssmatrix(S), E @@ -716,10 +716,10 @@ def create_statefbk_iosystem( specified as either integer offsets or as estimator/system output signal names. If not specified, defaults to the system states. - inputs, outputs : str, or list of str, optional + inputs, outputs, states : str, or list of str, optional List of strings that name the individual signals of the transformed - system. If not given, the inputs and outputs are the same as the - original system. + system. If not given, the inputs, outputs, and states are the same + as the original system. name : string, optional System name. If unspecified, a generic name is generated diff --git a/control/statesp.py b/control/statesp.py index 717fc9a73..a4cce7835 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -1576,6 +1576,10 @@ def ss(*args, **kwargs): name : string, optional System name (used for specifying signals). If unspecified, a generic name is generated with a unique integer id. + method : str, optional + Set the method used for computing the result. Current methods are + 'slycot' and 'scipy'. If set to None (default), try 'slycot' first + and then 'scipy' (SISO only). Returns ------- @@ -1915,15 +1919,15 @@ def rss(states=1, outputs=1, inputs=1, strictly_proper=False, **kwargs): Parameters ---------- - inputs : int, list of str, or None - Description of the system inputs. This can be given as an integer + states : int, list of str, or None + Description of the system states. Same format as `inputs`. + outputs : int, list of str, or None + Description of the system outputs. This can be given as an integer count or as a list of strings that name the individual signals. If an integer count is specified, the names of the signal will be of the form `s[i]` (where `s` is one of `u`, `y`, or `x`). - outputs : int, list of str, or None - Description of the system outputs. Same format as `inputs`. - states : int, list of str, or None - Description of the system states. Same format as `inputs`. + inputs : int, list of str, or None + Description of the system inputs. Same format as `outputs`. strictly_proper : bool, optional If set to 'True', returns a proper system (no direct term). dt : None, True or float, optional diff --git a/control/stochsys.py b/control/stochsys.py index fe11a4fb5..b31083f19 100644 --- a/control/stochsys.py +++ b/control/stochsys.py @@ -177,7 +177,7 @@ def lqe(*args, **kwargs): # Compute the result (dimension and symmetry checking done in care()) P, E, LT = care(A.T, C.T, G @ QN @ G.T, RN, method=method, - B_s="C", Q_s="QN", R_s="RN", S_s="NN") + _Bs="C", _Qs="QN", _Rs="RN", _Ss="NN") return _ssmatrix(LT.T), _ssmatrix(P), E @@ -297,7 +297,7 @@ def dlqe(*args, **kwargs): # Compute the result (dimension and symmetry checking done in dare()) P, E, LT = dare(A.T, C.T, G @ QN @ G.T, RN, method=method, - B_s="C", Q_s="QN", R_s="RN", S_s="NN") + _Bs="C", _Qs="QN", _Rs="RN", _Ss="NN") return _ssmatrix(LT.T), _ssmatrix(P), E @@ -604,6 +604,21 @@ def white_noise(T, Q, dt=0): covariance Q at each point in time (without any scaling based on the sample time). + Parameters + ---------- + T : 1D array_like + Array of linearly spaced times. + Q : 2D array_like + Noise intensity matrix of dimension nxn. + dt : float, optional + If 0, generate continuous time noise signal, otherwise discrete time. + + Returns + ------- + V : array + Noise signal indexed as `V[i, j]` where `i` is the signal index and + `j` is the time index. + """ # Convert input arguments to arrays T = np.atleast_1d(T) diff --git a/control/tests/docstrings_test.py b/control/tests/docstrings_test.py index 4635844e8..03b68b74d 100644 --- a/control/tests/docstrings_test.py +++ b/control/tests/docstrings_test.py @@ -49,7 +49,8 @@ control.sisotool: ['kvect'], # deprecated control.nyquist_response: ['return_contour'], # deprecated control.create_estimator_iosystem: ['state_labels'], # deprecated - control.bode_plot: ['sharex', 'sharey', 'margin_info'] # deprecated + control.bode_plot: ['sharex', 'sharey', 'margin_info'], # deprecated + control.eigensys_realization: ['arg'], # positional } # Decide on the level of verbosity (use -rP when running pytest) diff --git a/control/tests/nyquist_test.py b/control/tests/nyquist_test.py index 823d65732..6fdc89d33 100644 --- a/control/tests/nyquist_test.py +++ b/control/tests/nyquist_test.py @@ -295,7 +295,7 @@ def test_nyquist_indent_dont(indentsys): with pytest.warns() as record: count, contour = ct.nyquist_response( indentsys, omega=[0, 0.2, 0.3, 0.4], indent_radius=.1007, - plot=False, return_contour=True) + return_contour=True) np.testing.assert_allclose(contour[0], .1007+0.j) # second value of omega_vector is larger than indent_radius: not indented assert np.all(contour.real[2:] == 0.) @@ -439,9 +439,11 @@ def test_nyquist_legacy(): response = ct.nyquist_plot(sys) def test_discrete_nyquist(): + # TODO: add tests to make sure plots make sense + # Make sure we can handle discrete time systems with negative poles sys = ct.tf(1, [1, -0.1], dt=1) * ct.tf(1, [1, 0.1], dt=1) - ct.nyquist_response(sys, plot=False) + ct.nyquist_response(sys) # system with a pole at the origin sys = ct.zpk([1,], [.3, 0], 1, dt=True) diff --git a/control/timeplot.py b/control/timeplot.py index d29c212df..c8064e110 100644 --- a/control/timeplot.py +++ b/control/timeplot.py @@ -39,8 +39,8 @@ def time_response_plot( data, *fmt, ax=None, plot_inputs=None, plot_outputs=True, transpose=False, overlay_traces=False, overlay_signals=False, - legend=None, add_initial_zero=True, label=None, - trace_labels=None, title=None, relabel=True, **kwargs): + add_initial_zero=True, label=None, trace_labels=None, title=None, + relabel=True, **kwargs): """Plot the time response of an input/output system. This function creates a standard set of plots for the input/output @@ -126,6 +126,9 @@ def time_response_plot( output_props : array of dicts, optional List of line properties to use when plotting combined outputs. The default values are set by config.defaults['timeplot.output_props']. + rcParams : dict + Override the default parameters used for generating plots. + Default is set by config.default['ctrlplot.rcParams']. relabel : bool, optional [deprecated] By default, existing figures and axes are relabeled when new data are added. If set to `False`, just plot new data on diff --git a/control/xferfcn.py b/control/xferfcn.py index ba9af3913..172eb3be0 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -98,7 +98,7 @@ class TransferFunction(LTI): time, positive number is discrete time with specified sampling time, None indicates unspecified timebase (either continuous or discrete time). - display_format: None, 'poly' or 'zpk' + display_format : None, 'poly' or 'zpk', optional Set the display format used in printing the TransferFunction object. Default behavior is polynomial display and can be changed by changing config.defaults['xferfcn.display_format']. @@ -1578,13 +1578,13 @@ def tf(*args, **kwargs): Parameters ---------- - sys: LTI (StateSpace or TransferFunction) + sys : LTI (StateSpace or TransferFunction) A linear system - num: array_like, or list of list of array_like + num : array_like, or list of list of array_like Polynomial coefficients of the numerator - den: array_like, or list of list of array_like + den : array_like, or list of list of array_like Polynomial coefficients of the denominator - display_format: None, 'poly' or 'zpk' + display_format : None, 'poly' or 'zpk' Set the display format used in printing the TransferFunction object. Default behavior is polynomial display and can be changed by changing config.defaults['xferfcn.display_format'].. @@ -1677,7 +1677,7 @@ def tf(*args, **kwargs): raise ValueError("Needs 1 or 2 arguments; received %i." % len(args)) -def zpk(zeros, poles, gain, *args, **kwargs): +def zpk(zeros, poles, gain, dt=None, **kwargs): """zpk(zeros, poles, gain[, dt]) Create a transfer function from zeros, poles, gain. @@ -1711,7 +1711,7 @@ def zpk(zeros, poles, gain, *args, **kwargs): name : string, optional System name (used for specifying signals). If unspecified, a generic name is generated with a unique integer id. - display_format: None, 'poly' or 'zpk' + display_format : None, 'poly' or 'zpk', optional Set the display format used in printing the TransferFunction object. Default behavior is polynomial display and can be changed by changing config.defaults['xferfcn.display_format']. @@ -1732,7 +1732,7 @@ def zpk(zeros, poles, gain, *args, **kwargs): """ num, den = zpk2tf(zeros, poles, gain) - return TransferFunction(num, den, *args, **kwargs) + return TransferFunction(num, den, dt=dt, **kwargs) def ss2tf(*args, **kwargs): @@ -1755,16 +1755,18 @@ def ss2tf(*args, **kwargs): Parameters ---------- - sys: StateSpace + sys : StateSpace A linear system - A: array_like or string + A : array_like or string System matrix - B: array_like or string + B : array_like or string Control matrix - C: array_like or string + C : array_like or string Output matrix - D: array_like or string + D : array_like or string Feedthrough matrix + **kwargs : keyword arguments + Additional arguments passed to :func:`tf` (e.g., signal names) Returns ------- @@ -1839,7 +1841,7 @@ def tfdata(sys): Parameters ---------- - sys: LTI (StateSpace, or TransferFunction) + sys : LTI (StateSpace, or TransferFunction) LTI system whose data will be returned Returns @@ -1859,7 +1861,7 @@ def _clean_part(data): Parameters ---------- - data: numerator or denominator of a transfer function. + data : numerator or denominator of a transfer function. Returns ------- From 8fcf0b7ebae4261889c93d832cb585c32d56212e Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sun, 4 Aug 2024 08:11:09 -0700 Subject: [PATCH 09/15] make deprecration warnings consistent (as FutureWarning) --- control/bdalg.py | 6 +-- control/config.py | 2 +- control/ctrlplot.py | 5 ++- control/frdata.py | 3 +- control/freqplot.py | 10 ++--- control/lti.py | 2 +- control/optimal.py | 4 +- control/phaseplot.py | 9 +++-- control/pzmap.py | 4 +- control/rlocus.py | 4 +- control/statesp.py | 14 +++---- control/tests/bdalg_test.py | 2 +- control/tests/conftest.py | 6 +-- control/tests/docstrings_test.py | 65 ++++++++++++++++++++++++++++---- control/tests/frd_test.py | 2 +- control/tests/freqresp_test.py | 4 +- control/tests/iosys_test.py | 14 +++---- control/tests/matlab_test.py | 2 + control/tests/nyquist_test.py | 2 +- control/tests/pzmap_test.py | 7 ++-- control/tests/rlocus_test.py | 10 ++--- control/tests/statefbk_test.py | 2 +- control/tests/statesp_test.py | 2 +- control/tests/xferfcn_test.py | 2 +- control/timeplot.py | 2 +- control/xferfcn.py | 4 +- 26 files changed, 124 insertions(+), 65 deletions(-) diff --git a/control/bdalg.py b/control/bdalg.py index b9965777f..024d95fba 100644 --- a/control/bdalg.py +++ b/control/bdalg.py @@ -412,8 +412,8 @@ def connect(sys, Q, inputv, outputv): """Index-based interconnection of an LTI system. .. deprecated:: 0.10.0 - `connect` will be removed in a future version of python-control in - favor of `interconnect`, which works with named signals. + `connect` will be removed in a future version of python-control. + Use :func:`interconnect` instead, which works with named signals. The system `sys` is a system typically constructed with `append`, with multiple inputs and outputs. The inputs and outputs are connected @@ -465,7 +465,7 @@ def connect(sys, Q, inputv, outputv): """ # TODO: maintain `connect` for use in MATLAB submodule (?) - warn("`connect` is deprecated; use `interconnect`", DeprecationWarning) + warn("connect() is deprecated; use interconnect()", FutureWarning) inputv, outputv, Q = \ np.atleast_1d(inputv), np.atleast_1d(outputv), np.atleast_1d(Q) diff --git a/control/config.py b/control/config.py index db0b64374..c5a59250b 100644 --- a/control/config.py +++ b/control/config.py @@ -362,7 +362,7 @@ def _process_legacy_keyword(kwargs, oldkey, newkey, newval): if kwargs.get(oldkey) is not None: warnings.warn( f"keyword '{oldkey}' is deprecated; use '{newkey}'", - DeprecationWarning) + FutureWarning) if newval is not None: raise ControlArgument( f"duplicate keywords '{oldkey}' and '{newkey}'") diff --git a/control/ctrlplot.py b/control/ctrlplot.py index cc97e6885..4655b5e99 100644 --- a/control/ctrlplot.py +++ b/control/ctrlplot.py @@ -212,7 +212,7 @@ def suptitle( """ warnings.warn( - "suptitle is deprecated; use cplt.set_plot_title", FutureWarning) + "suptitle() is deprecated; use cplt.set_plot_title()", FutureWarning) _update_plot_title( title, fig=fig, frame=frame, use_existing=False, **kwargs) @@ -247,7 +247,8 @@ def get_plot_axes(line_array): Only the first element of each array entry is used to determine the axes. """ - warnings.warn("get_plot_axes is deprecated; use cplt.axes", FutureWarning) + warnings.warn( + "get_plot_axes() is deprecated; use cplt.axes()", FutureWarning) _get_axes = np.vectorize(lambda lines: lines[0].axes) if isinstance(line_array, ControlPlot): return _get_axes(line_array.lines) diff --git a/control/frdata.py b/control/frdata.py index 1b35c6b20..c5018babb 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -597,12 +597,13 @@ def freqresp(self, omega): Method has been given the more pythonic name :meth:`FrequencyResponseData.frequency_response`. Or use :func:`freqresp` in the MATLAB compatibility module. + """ warn("FrequencyResponseData.freqresp(omega) will be removed in a " "future release of python-control; use " "FrequencyResponseData.frequency_response(omega), or " "freqresp(sys, omega) in the MATLAB compatibility module " - "instead", DeprecationWarning) + "instead", FutureWarning) return self.frequency_response(omega) def feedback(self, other=1, sign=-1): diff --git a/control/freqplot.py b/control/freqplot.py index dc2318b6f..85f1666e8 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -432,8 +432,8 @@ def bode_plot( if plot is not None: warnings.warn( - "`bode_plot` return values of mag, phase, omega is deprecated; " - "use frequency_response()", DeprecationWarning) + "bode_plot() return value of mag, phase, omega is deprecated; " + "use frequency_response()", FutureWarning) if plot is False: # Process the data to match what we were sent @@ -1796,8 +1796,8 @@ def _parse_linestyle(style_name, allow_false=False): # Legacy return value processing if plot is not None or return_contour is not None: warnings.warn( - "`nyquist_plot` return values of count[, contour] is deprecated; " - "use nyquist_response()", DeprecationWarning) + "nyquist_plot() return value of count[, contour] is deprecated; " + "use nyquist_response()", FutureWarning) # Extract out the values that we will eventually return counts = [response.count for response in nyquist_responses] @@ -2456,7 +2456,7 @@ def singular_values_plot( if plot is not None: warnings.warn( "`singular_values_plot` return values of sigma, omega is " - "deprecated; use singular_values_response()", DeprecationWarning) + "deprecated; use singular_values_response()", FutureWarning) # Warn the user if we got past something that is not real-valued if any([not np.allclose(np.imag(response.fresp[:, 0, :]), 0) diff --git a/control/lti.py b/control/lti.py index dd4bcbced..e9455aed5 100644 --- a/control/lti.py +++ b/control/lti.py @@ -521,7 +521,7 @@ def freqresp(sys, omega): Use `frequency_response` instead. """ - warn("freqresp is deprecated; use frequency_response", DeprecationWarning) + warn("freqresp() is deprecated; use frequency_response()", FutureWarning) return frequency_response(sys, omega) diff --git a/control/optimal.py b/control/optimal.py index 7a641acf4..0eb49c823 100644 --- a/control/optimal.py +++ b/control/optimal.py @@ -1111,14 +1111,14 @@ def solve_ocp( raise ValueError("'minimize_method' specified more than once") warnings.warn( "'method' parameter is deprecated; assuming minimize_method", - DeprecationWarning) + FutureWarning) kwargs['minimize_method'] = method else: if kwargs.get('trajectory_method'): raise ValueError("'trajectory_method' specified more than once") warnings.warn( "'method' parameter is deprecated; assuming trajectory_method", - DeprecationWarning) + FutureWarning) kwargs['trajectory_method'] = method # Set up the optimal control problem diff --git a/control/phaseplot.py b/control/phaseplot.py index e48755080..bcb79c29e 100644 --- a/control/phaseplot.py +++ b/control/phaseplot.py @@ -1013,6 +1013,9 @@ def phase_plot(odefun, X=None, Y=None, scale=1, X0=None, T=None, """(legacy) Phase plot for 2D dynamical systems. + .. deprecated:: 0.10.1 + This function is deprecated; use `phase_plane_plot` instead. + Produces a vector field or stream line plot for a planar system. This function has been replaced by the :func:`~control.phase_plane_map` and :func:`~control.phase_plane_plot` functions. @@ -1072,7 +1075,7 @@ def phase_plot(odefun, X=None, Y=None, scale=1, X0=None, T=None, """ # Generate a deprecation warning warnings.warn( - "phase_plot is deprecated; use phase_plot_plot instead", + "phase_plot() is deprecated; use phase_plane_plot() instead", FutureWarning) # @@ -1272,7 +1275,7 @@ def box_grid(xlimp, ylimp): """box_grid generate list of points on edge of box .. deprecated:: 0.10.0 - Use `phaseplot.boxgrid` instead. + Use :func:`phaseplot.boxgrid` instead. list = box_grid([xmin xmax xnum], [ymin ymax ynum]) generates a list of points that correspond to a uniform grid at the end of the @@ -1282,7 +1285,7 @@ def box_grid(xlimp, ylimp): # Generate a deprecation warning warnings.warn( - "box_grid is deprecated; use phaseplot.boxgrid instead", + "box_grid() is deprecated; use phaseplot.boxgrid() instead", FutureWarning) return boxgrid( diff --git a/control/pzmap.py b/control/pzmap.py index 77f617977..7b0f8d096 100644 --- a/control/pzmap.py +++ b/control/pzmap.py @@ -316,8 +316,8 @@ def pole_zero_plot( # Legacy return value processing if plot is not None: warnings.warn( - "`pole_zero_plot` return values of poles, zeros is deprecated; " - "use pole_zero_map()", DeprecationWarning) + "pole_zero_plot() return value of poles, zeros is deprecated; " + "use pole_zero_map()", FutureWarning) # Extract out the values that we will eventually return poles = [response.poles for response in pzmap_responses] diff --git a/control/rlocus.py b/control/rlocus.py index 6a9e56b32..189c2ccd0 100644 --- a/control/rlocus.py +++ b/control/rlocus.py @@ -208,8 +208,8 @@ def root_locus_plot( # if plot is not None: warnings.warn( - "`root_locus` return values of roots, gains is deprecated; " - "use root_locus_map()", DeprecationWarning) + "root_locus() return value of roots, gains is deprecated; " + "use root_locus_map()", FutureWarning) if plot is False: return responses.loci, responses.gains diff --git a/control/statesp.py b/control/statesp.py index a4cce7835..c2d541ca1 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -355,13 +355,13 @@ def __init__(self, *args, **kwargs): def _get_states(self): warn("The StateSpace `states` attribute will be deprecated in a " "future release. Use `nstates` instead.", - DeprecationWarning, stacklevel=2) + FutureWarning, stacklevel=2) return self.nstates def _set_states(self, value): warn("The StateSpace `states` attribute will be deprecated in a " "future release. Use `nstates` instead.", - DeprecationWarning, stacklevel=2) + FutureWarning, stacklevel=2) self.nstates = value #: Deprecated attribute; use :attr:`nstates` instead. @@ -906,7 +906,7 @@ def freqresp(self, omega): warn("StateSpace.freqresp(omega) will be removed in a " "future release of python-control; use " "sys.frequency_response(omega), or freqresp(sys, omega) in the " - "MATLAB compatibility module instead", DeprecationWarning) + "MATLAB compatibility module instead", FutureWarning) return self.frequency_response(omega) # Compute poles and zeros @@ -1618,8 +1618,8 @@ def ss(*args, **kwargs): if len(args) > 0 and (hasattr(args[0], '__call__') or args[0] is None) \ and not isinstance(args[0], (InputOutputSystem, LTI)): # Function as first (or second) argument => assume nonlinear IO system - warn("using ss to create nonlinear I/O systems is deprecated; " - "use nlsys()", DeprecationWarning) + warn("using ss() to create nonlinear I/O systems is deprecated; " + "use nlsys()", FutureWarning) return NonlinearIOSystem(*args, **kwargs) elif len(args) == 4 or len(args) == 5: @@ -1667,7 +1667,7 @@ def ss2io(*args, **kwargs): Create an :class:`~control.StateSpace` system with the given signal and system names. See :func:`~control.ss` for more details. """ - warn("ss2io is deprecated; use ss()", DeprecationWarning) + warn("ss2io() is deprecated; use ss()", FutureWarning) return StateSpace(*args, **kwargs) @@ -1743,7 +1743,7 @@ def tf2io(*args, **kwargs): (2, 2, 8) """ - warn("tf2io is deprecated; use tf2ss() or tf()", DeprecationWarning) + warn("tf2io() is deprecated; use tf2ss() or tf()", FutureWarning) return tf2ss(*args, **kwargs) diff --git a/control/tests/bdalg_test.py b/control/tests/bdalg_test.py index b9e26e8c0..5629f27f9 100644 --- a/control/tests/bdalg_test.py +++ b/control/tests/bdalg_test.py @@ -269,7 +269,7 @@ def test_feedback_args(self, tsys): def testConnect(self, tsys): sys = append(tsys.sys2, tsys.sys3) # two siso systems - with pytest.warns(DeprecationWarning, match="use `interconnect`"): + with pytest.warns(FutureWarning, match="use interconnect()"): # should not raise error connect(sys, [[1, 2], [2, -2]], [2], [1, 2]) connect(sys, [[1, 2], [2, 0]], [2], [1, 2]) diff --git a/control/tests/conftest.py b/control/tests/conftest.py index 2330e3818..004b96058 100644 --- a/control/tests/conftest.py +++ b/control/tests/conftest.py @@ -65,10 +65,10 @@ def legacy_plot_signature(): import warnings warnings.filterwarnings( 'ignore', message='passing systems .* is deprecated', - category=DeprecationWarning) + category=FutureWarning) warnings.filterwarnings( - 'ignore', message='.* return values of .* is deprecated', - category=DeprecationWarning) + 'ignore', message='.* return value of .* is deprecated', + category=FutureWarning) yield warnings.resetwarnings() diff --git a/control/tests/docstrings_test.py b/control/tests/docstrings_test.py index 03b68b74d..1e5c24dd8 100644 --- a/control/tests/docstrings_test.py +++ b/control/tests/docstrings_test.py @@ -60,7 +60,7 @@ (control, ""), (control.flatsys, "flatsys."), (control.optimal, "optimal."), (control.phaseplot, "phaseplot.") ]) -def test_docstrings(module, prefix): +def test_parameter_docs(module, prefix): checked = set() # Keep track of functions we have checked # Look through every object in the package @@ -78,7 +78,7 @@ def test_docstrings(module, prefix): if verbose > 1: print(f" Checking class {name}") # Check member functions within the class - test_docstrings(obj, prefix + name + '.') + test_parameter_docs(obj, prefix + name + '.') if inspect.isfunction(obj): # Skip anything that is inherited, hidden, deprecated, or checked @@ -105,7 +105,7 @@ def test_docstrings(module, prefix): if verbose > 1: print(" [deprecated]") continue - elif f"{name} is deprecated" in docstring or \ + elif re.search(name + r"(\(\))? is deprecated", docstring) or \ "function is deprecated" in docstring: if verbose > 1: print(" [deprecated, but not numpydoc compliant]") @@ -114,7 +114,7 @@ def test_docstrings(module, prefix): warnings.warn(f"{name} deprecated, but not numpydoc compliant") continue - elif f"{name} is deprecated" in source: + elif re.search(name + r"(\(\))? is deprecated", source): if verbose: print(f" {name} is deprecated, but not documented") warnings.warn(f"{name} deprecated, but not documented") @@ -189,7 +189,7 @@ def test_docstrings(module, prefix): continue if verbose > 3: print(f" Checking keyword argument {kwargname}") - assert _check_docstring( + assert _check_parameter_docs( name, kwargname, inspect.getdoc(obj), prefix=prefix) @@ -197,12 +197,63 @@ def test_docstrings(module, prefix): else: if verbose > 3: print(f" Checking argument {argname}") - assert _check_docstring( + assert _check_parameter_docs( name, argname, docstring, prefix=prefix) +@pytest.mark.parametrize("module, prefix", [ + (control, ""), (control.flatsys, "flatsys."), + (control.optimal, "optimal."), (control.phaseplot, "phaseplot.") +]) +def test_deprecated_functions(module, prefix): + checked = set() # Keep track of functions we have checked + + # Look through every object in the package + for name, obj in inspect.getmembers(module): + # Skip anything that is outside of this module + if inspect.getmodule(obj) is not None and ( + not inspect.getmodule(obj).__name__.startswith('control') + or prefix != "" and inspect.getmodule(obj) != module): + # Skip anything that isn't part of the control package + continue + + if inspect.isclass(obj): + # Check member functions within the class + test_deprecated_functions(obj, prefix + name + '.') + + if inspect.isfunction(obj): + # Skip anything that is inherited, hidden, or checked + if inspect.isclass(module) and name not in module.__dict__ \ + or name[0] == '_' or obj in checked: + continue + else: + checked.add(obj) + + # Get the docstring (skip w/ warning if there isn't one) + if obj.__doc__ is None: + warnings.warn( + f"{module.__name__}.{name} is missing docstring") + continue + else: + docstring = inspect.getdoc(obj) + source = inspect.getsource(obj) + + # Look for functions marked as deprecated in doc string + if ".. deprecated::" in docstring: + # Make sure a FutureWarning is issued + if not re.search("FutureWarning", source): + pytest.fail( + f"{name} deprecated but does not issue FutureWarning") + else: + if re.search(name + r"(\(\))? is deprecated", docstring) or \ + re.search(name + r"(\(\))? is deprecated", source): + pytest.fail( + f"{name} deprecated but w/ non-standard docs/warnings") + assert name != 'ss2io' + + # Utility function to check for an argument in a docstring -def _check_docstring(funcname, argname, docstring, prefix=""): +def _check_parameter_docs(funcname, argname, docstring, prefix=""): funcname = prefix + funcname if re.search( "\n" + r"((\w+|\.{3}), )*" + argname + r"(, (\w+|\.{3}))*:", diff --git a/control/tests/frd_test.py b/control/tests/frd_test.py index e50af3c92..bae0ec47b 100644 --- a/control/tests/frd_test.py +++ b/control/tests/frd_test.py @@ -454,7 +454,7 @@ def test_eval(self): def test_freqresp_deprecated(self): sys_tf = ct.tf([1], [1, 2, 1]) frd_tf = frd(sys_tf, np.logspace(-1, 1, 3)) - with pytest.warns(DeprecationWarning): + with pytest.warns(FutureWarning): frd_tf.freqresp(1.) def test_repr_str(self): diff --git a/control/tests/freqresp_test.py b/control/tests/freqresp_test.py index 555adf332..3ff1a51c5 100644 --- a/control/tests/freqresp_test.py +++ b/control/tests/freqresp_test.py @@ -60,7 +60,7 @@ def test_freqresp_siso(ss_siso): ctrl.frequency_response(ss_siso, omega) -@pytest.mark.filterwarnings("ignore:freqresp is deprecated") +@pytest.mark.filterwarnings(r"ignore:freqresp\(\) is deprecated") @slycotonly def test_freqresp_mimo_legacy(ss_mimo): """Test MIMO frequency response calls""" @@ -112,7 +112,7 @@ def test_nyquist_basic(ss_siso): # Check known warnings happened as expected assert len(record) == 2 assert re.search("encirclements was a non-integer", str(record[0].message)) - assert re.search("return values .* deprecated", str(record[1].message)) + assert re.search("return value .* deprecated", str(record[1].message)) response = nyquist_response(tf_siso, omega=np.logspace(-1, 1, 10)) assert len(response.contour) == 10 diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index cf4e3dd43..dd30ea71e 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -77,7 +77,7 @@ def test_tf2io(self, tsys): # Create a transfer function from the state space system linsys = tsys.siso_linsys tfsys = ct.ss2tf(linsys) - with pytest.warns(DeprecationWarning, match="use tf2ss"): + with pytest.warns(FutureWarning, match="use tf2ss"): iosys = ct.tf2io(tfsys) # Verify correctness via simulation @@ -90,13 +90,13 @@ def test_tf2io(self, tsys): # Make sure that non-proper transfer functions generate an error tfsys = ct.tf('s') with pytest.raises(ValueError): - with pytest.warns(DeprecationWarning, match="use tf2ss"): + with pytest.warns(FutureWarning, match="use tf2ss"): iosys=ct.tf2io(tfsys) def test_ss2io(self, tsys): # Create an input/output system from the linear system linsys = tsys.siso_linsys - with pytest.warns(DeprecationWarning, match="use ss"): + with pytest.warns(FutureWarning, match="use ss"): iosys = ct.ss2io(linsys) np.testing.assert_allclose(linsys.A, iosys.A) np.testing.assert_allclose(linsys.B, iosys.B) @@ -104,7 +104,7 @@ def test_ss2io(self, tsys): np.testing.assert_allclose(linsys.D, iosys.D) # Try adding names to things - with pytest.warns(DeprecationWarning, match="use ss"): + with pytest.warns(FutureWarning, match="use ss"): iosys_named = ct.ss2io(linsys, inputs='u', outputs='y', states=['x1', 'x2'], name='iosys_named') assert iosys_named.find_input('u') == 0 @@ -1942,7 +1942,7 @@ def test_nonuniform_timepts(nstates, noutputs, ninputs): def test_ss_nonlinear(): """Test ss() for creating nonlinear systems""" - with pytest.warns(DeprecationWarning, match="use nlsys()"): + with pytest.warns(FutureWarning, match="use nlsys()"): secord = ct.ss(secord_update, secord_output, inputs='u', outputs='y', states = ['x1', 'x2'], name='secord') assert secord.name == 'secord' @@ -1963,12 +1963,12 @@ def test_ss_nonlinear(): np.testing.assert_almost_equal(ss_response.outputs, io_response.outputs) # Make sure that optional keywords are allowed - with pytest.warns(DeprecationWarning, match="use nlsys()"): + with pytest.warns(FutureWarning, match="use nlsys()"): secord = ct.ss(secord_update, secord_output, dt=True) assert ct.isdtime(secord) # Make sure that state space keywords are flagged - with pytest.warns(DeprecationWarning, match="use nlsys()"): + with pytest.warns(FutureWarning, match="use nlsys()"): with pytest.raises(TypeError, match="unrecognized keyword"): ct.ss(secord_update, remove_useless_states=True) diff --git a/control/tests/matlab_test.py b/control/tests/matlab_test.py index 2ba3d5df8..265bfd9d2 100644 --- a/control/tests/matlab_test.py +++ b/control/tests/matlab_test.py @@ -173,6 +173,7 @@ def testPZmap(self, siso, subsys, mplcleanup): # pzmap(siso.ss1); not implemented # pzmap(siso.ss2); not implemented pzmap(getattr(siso, subsys)) + # TODO: check to make sure a plot got generated pzmap(getattr(siso, subsys), plot=False) def testStep(self, siso): @@ -404,6 +405,7 @@ def testDcgain_mimo(self, mimo): def testBode(self, siso, mplcleanup): """Call bode()""" + # TODO: make sure plots are generated bode(siso.ss1) bode(siso.tf1) bode(siso.tf2) diff --git a/control/tests/nyquist_test.py b/control/tests/nyquist_test.py index 6fdc89d33..0d6907b64 100644 --- a/control/tests/nyquist_test.py +++ b/control/tests/nyquist_test.py @@ -322,7 +322,7 @@ def test_nyquist_indent_do(indentsys): # Make sure that the command also works if called directly as _plot() plt.figure() - with pytest.warns(DeprecationWarning, match=".* use nyquist_response()"): + with pytest.warns(FutureWarning, match=".* use nyquist_response()"): count, contour = ct.nyquist_plot( indentsys, indent_radius=0.01, return_contour=True) assert _Z(indentsys) == count + _P(indentsys) diff --git a/control/tests/pzmap_test.py b/control/tests/pzmap_test.py index 04eb037ab..438732b84 100644 --- a/control/tests/pzmap_test.py +++ b/control/tests/pzmap_test.py @@ -16,7 +16,7 @@ from control import TransferFunction, config, pzmap -@pytest.mark.filterwarnings("ignore:.*return values.*:DeprecationWarning") +@pytest.mark.filterwarnings("ignore:.*return value.*:FutureWarning") @pytest.mark.parametrize("kwargs", [pytest.param(dict(), id="default"), pytest.param(dict(plot=False), id="plot=False"), @@ -53,7 +53,8 @@ def test_pzmap(kwargs, setdefaults, dt, editsdefaults, mplcleanup): if kwargs.get('plot', None) is None: pzkwargs['plot'] = True # use to get legacy return values - P, Z = pzmap(T, **pzkwargs) + with pytest.warns(FutureWarning, match="return value .* is deprecated"): + P, Z = pzmap(T, **pzkwargs) np.testing.assert_allclose(P, Pref, rtol=1e-3) np.testing.assert_allclose(Z, Zref, rtol=1e-3) @@ -96,7 +97,7 @@ def test_polezerodata(): # Legacy return format for plot in [True, False]: - with pytest.warns(DeprecationWarning, match=".* values .* deprecated"): + with pytest.warns(FutureWarning, match=".* value .* deprecated"): poles, zeros = ct.pole_zero_plot(pzdata, plot=False) np.testing.assert_equal(poles, sys.poles()) np.testing.assert_equal(zeros, sys.zeros()) diff --git a/control/tests/rlocus_test.py b/control/tests/rlocus_test.py index 38111e98e..a62bc742b 100644 --- a/control/tests/rlocus_test.py +++ b/control/tests/rlocus_test.py @@ -45,7 +45,7 @@ def check_cl_poles(self, sys, pole_list, k_list): poles = np.sort(poles) np.testing.assert_array_almost_equal(poles, poles_expected) - @pytest.mark.filterwarnings("ignore:.*return values.*:DeprecationWarning") + @pytest.mark.filterwarnings("ignore:.*return value.*:FutureWarning") def testRootLocus(self, sys): """Basic root locus (no plot)""" klist = [-1, 0, 1] @@ -61,7 +61,7 @@ def testRootLocus(self, sys): np.testing.assert_allclose(klist, k_out) self.check_cl_poles(sys, roots, klist) - @pytest.mark.filterwarnings("ignore:.*return values.*:DeprecationWarning") + @pytest.mark.filterwarnings("ignore:.*return value.*:FutureWarning") def test_without_gains(self, sys): roots, kvect = root_locus(sys, plot=False) self.check_cl_poles(sys, roots, kvect) @@ -109,7 +109,7 @@ def test_root_locus_plot_grid(self, sys, grid, method): # TODO: check validity of grid - @pytest.mark.filterwarnings("ignore:.*return values.*:DeprecationWarning") + @pytest.mark.filterwarnings("ignore:.*return value.*:FutureWarning") def test_root_locus_neg_false_gain_nonproper(self): """ Non proper TranferFunction with negative gain: Not implemented""" with pytest.raises(ValueError, match="with equal order"): @@ -147,7 +147,7 @@ def test_root_locus_zoom(self): assert_array_almost_equal(zoom_x, zoom_x_valid) assert_array_almost_equal(zoom_y, zoom_y_valid) - @pytest.mark.filterwarnings("ignore:.*return values.*:DeprecationWarning") + @pytest.mark.filterwarnings("ignore:.*return value.*:FutureWarning") @pytest.mark.timeout(2) def test_rlocus_default_wn(self): """Check that default wn calculation works properly""" @@ -186,7 +186,7 @@ def test_root_locus_plots(sys, grid, xlim, ylim, interactive): @pytest.mark.usefixtures("mplcleanup") def test_root_locus_legacy(keyword): sys = ct.rss(2, 1, 1) - with pytest.warns(DeprecationWarning, match=f"'{keyword}' is deprecated"): + with pytest.warns(FutureWarning, match=f"'{keyword}' is deprecated"): ct.root_locus_plot(sys, **{keyword: [0, 1, 2]}) diff --git a/control/tests/statefbk_test.py b/control/tests/statefbk_test.py index 4a0472de7..2d96ad225 100644 --- a/control/tests/statefbk_test.py +++ b/control/tests/statefbk_test.py @@ -892,7 +892,7 @@ def test_statefbk_errors(self): with pytest.raises(ControlArgument, match="gain must be an array"): ctrl, clsys = ct.create_statefbk_iosystem(sys, "bad argument") - with pytest.warns(DeprecationWarning, match="'type' is deprecated"): + with pytest.warns(FutureWarning, match="'type' is deprecated"): ctrl, clsys = ct.create_statefbk_iosystem(sys, K, type='nonlinear') with pytest.raises(ControlArgument, match="duplicate keywords"): diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index 6ddf9933e..2829d6988 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -392,7 +392,7 @@ def test_freq_resp(self): np.testing.assert_almost_equal(omega, true_omega) # Deprecated version of the call (should return warning) - with pytest.warns(DeprecationWarning, match="will be removed"): + with pytest.warns(FutureWarning, match="will be removed"): mag, phase, omega = sys.freqresp(true_omega) np.testing.assert_almost_equal(mag, true_mag) diff --git a/control/tests/xferfcn_test.py b/control/tests/xferfcn_test.py index cb5b38cba..14a11b669 100644 --- a/control/tests/xferfcn_test.py +++ b/control/tests/xferfcn_test.py @@ -487,7 +487,7 @@ def test_call_mimo(self): def test_freqresp_deprecated(self): sys = TransferFunction([1., 3., 5], [1., 6., 2., -1.]) # Deprecated version of the call (should generate warning) - with pytest.warns(DeprecationWarning): + with pytest.warns(FutureWarning): sys.freqresp(1.) def test_frequency_response_siso(self): diff --git a/control/timeplot.py b/control/timeplot.py index c8064e110..9f389b4ab 100644 --- a/control/timeplot.py +++ b/control/timeplot.py @@ -130,7 +130,7 @@ def time_response_plot( Override the default parameters used for generating plots. Default is set by config.default['ctrlplot.rcParams']. relabel : bool, optional - [deprecated] By default, existing figures and axes are relabeled + (deprecated) By default, existing figures and axes are relabeled when new data are added. If set to `False`, just plot new data on existing axes. show_legend : bool, optional diff --git a/control/xferfcn.py b/control/xferfcn.py index 172eb3be0..ee41cbd2b 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -811,7 +811,7 @@ def __getitem__(self, key): num, den, self.dt, inputs=inputs, outputs=outputs, name=sysname) def freqresp(self, omega): - """(deprecated) Evaluate transfer function at complex frequencies. + """Evaluate transfer function at complex frequencies. .. deprecated::0.9.0 Method has been given the more pythonic name @@ -821,7 +821,7 @@ def freqresp(self, omega): warn("TransferFunction.freqresp(omega) will be removed in a " "future release of python-control; use " "sys.frequency_response(omega), or freqresp(sys, omega) in the " - "MATLAB compatibility module instead", DeprecationWarning) + "MATLAB compatibility module instead", FutureWarning) return self.frequency_response(omega) def poles(self): From 4ef9dd623295ae593d48b9286871aeb5ac233527 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Thu, 8 Aug 2024 19:24:22 -0700 Subject: [PATCH 10/15] update docstring text for new functions --- control/tests/docstrings_test.py | 4 +++- control/tests/modelsimp_test.py | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/control/tests/docstrings_test.py b/control/tests/docstrings_test.py index 1e5c24dd8..80c070204 100644 --- a/control/tests/docstrings_test.py +++ b/control/tests/docstrings_test.py @@ -39,6 +39,8 @@ control.ss2tf: '8d663d474ade2950dd22ec86fe3a53b7', control.tf: '4e8d21e71312d83ba2e15b9c095fd962', control.tf2ss: '0e5da4f3ed4aaf000f3b454c466f9013', + control.markov: '47f3b856ec47df84b2af2c165abaabfc', + control.gangof4: '5e4b4cf815ef76d6c73939070bcd1489', } # List of keywords that we can skip testing (special cases) @@ -50,7 +52,7 @@ control.nyquist_response: ['return_contour'], # deprecated control.create_estimator_iosystem: ['state_labels'], # deprecated control.bode_plot: ['sharex', 'sharey', 'margin_info'], # deprecated - control.eigensys_realization: ['arg'], # positional + control.eigensys_realization: ['arg'], # quasi-positional } # Decide on the level of verbosity (use -rP when running pytest) diff --git a/control/tests/modelsimp_test.py b/control/tests/modelsimp_test.py index b811b5ac7..616ef5f09 100644 --- a/control/tests/modelsimp_test.py +++ b/control/tests/modelsimp_test.py @@ -54,13 +54,13 @@ def testMarkovSignature(self): with pytest.raises(ControlArgument): H = markov() - # to many positional arguments + # too many positional arguments with pytest.raises(ControlArgument): H = markov(Y,U,m,1) with pytest.raises(ControlArgument): H = markov(response,m,1) - # to many positional arguments + # too many positional arguments with pytest.raises(ControlDimension): U2 = np.hstack([U,U]) H = markov(Y,U2,m) From 806e5b392fd5f906807810b0e14aba8e876bccb8 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Mon, 12 Aug 2024 21:58:38 -0700 Subject: [PATCH 11/15] add test for multiple copies of argument documentation --- control/tests/docstrings_test.py | 48 +++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/control/tests/docstrings_test.py b/control/tests/docstrings_test.py index 80c070204..d9b3eb088 100644 --- a/control/tests/docstrings_test.py +++ b/control/tests/docstrings_test.py @@ -191,7 +191,7 @@ def test_parameter_docs(module, prefix): continue if verbose > 3: print(f" Checking keyword argument {kwargname}") - assert _check_parameter_docs( + _check_parameter_docs( name, kwargname, inspect.getdoc(obj), prefix=prefix) @@ -199,7 +199,7 @@ def test_parameter_docs(module, prefix): else: if verbose > 3: print(f" Checking argument {argname}") - assert _check_parameter_docs( + _check_parameter_docs( name, argname, docstring, prefix=prefix) @@ -257,24 +257,46 @@ def test_deprecated_functions(module, prefix): # Utility function to check for an argument in a docstring def _check_parameter_docs(funcname, argname, docstring, prefix=""): funcname = prefix + funcname - if re.search( + + # Find the "Parameters" section of docstring, where we start searching + if not (match := re.search(r"\nParameters\n----", docstring)): + pytest.fail(f"{funcname} docstring missing Parameters section") + else: + start = match.start() + + # Find the "Returns" section of the docstring (to be skipped, if present) + match_returns = re.search(r"\nReturns\n----", docstring) + + # Find the "Other Parameters" section of the docstring, if present + match_other = re.search(r"\nOther Parameters\n----", docstring) + + # Remove the returns section from docstring, in case output arguments + # match input argument names (it happens...) + if match_other and match_returns: + docstring = docstring[start:match_returns.start()] + \ + docstring[match_other.start():] + else: + docstring = docstring[start:] + + # Look for the parameter name in the docstring + if match := re.search( "\n" + r"((\w+|\.{3}), )*" + argname + r"(, (\w+|\.{3}))*:", docstring): # Found the string, but not in numpydoc form if verbose: print(f" {funcname}: {argname} docstring missing space") warnings.warn(f"{funcname} '{argname}' docstring missing space") - return True - elif not re.search( + elif not (match := re.search( "\n" + r"((\w+|\.{3}), )*" + argname + r"(, (\w+|\.{3}))* :", - docstring): - # return False - # - # Just issue a warning for now + docstring)): if verbose: print(f" {funcname}: {argname} not documented") - warnings.warn(f"{funcname} '{argname}' not documented") - return False - - return True + pytest.fail(f"{funcname} '{argname}' not documented") + + # Make sure there isn't another instance + second_match = re.search( + "\n" + r"((\w+|\.{3}), )*" + argname + r"(, (\w+|\.{3}))*[ ]*:", + docstring[match.end():]) + if second_match: + pytest.fail(f"{funcname} '{argname}' documented twice") From 122c405e9fcbeae707b7fa5a5b632a5c944c045e Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Mon, 12 Aug 2024 22:13:09 -0700 Subject: [PATCH 12/15] update docstring position arg hash to include source + docstring --- control/tests/docstrings_test.py | 40 ++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/control/tests/docstrings_test.py b/control/tests/docstrings_test.py index d9b3eb088..c0ca649e5 100644 --- a/control/tests/docstrings_test.py +++ b/control/tests/docstrings_test.py @@ -25,22 +25,22 @@ # Checksums to use for checking whether a docstring has changed function_docstring_hash = { - control.append: 'be014503250ef73253a5372a0d082566', - control.describing_function_plot: '726a10eef8f2b50ef46a203653f398c7', - control.dlqe: '9f637afdf36c7e17b7524f9a736772b6', - control.dlqr: 'a9265c5ed66388661729edb2379fd9a1', - control.lqe: 'd265b0daf0369569e4b755fa35db7a54', - control.lqr: '0b76455c2b873abcbcd959e069d9d241', - control.frd: '7ac3076368f11e407653cd1046bbc98d', - control.margin: '8ee27989f1ca521ce9affe5900605b75', - control.parallel: 'daa3b8708200a364d9b5536b6cbb5c91', - control.series: '7241169911b641c43f9456bd12168271', - control.ss: 'aa77e816305850502c21bc40ce796f40', - control.ss2tf: '8d663d474ade2950dd22ec86fe3a53b7', - control.tf: '4e8d21e71312d83ba2e15b9c095fd962', - control.tf2ss: '0e5da4f3ed4aaf000f3b454c466f9013', - control.markov: '47f3b856ec47df84b2af2c165abaabfc', - control.gangof4: '5e4b4cf815ef76d6c73939070bcd1489', + control.append: '48548c4c4e0083312b3ea9e56174b0b5', + control.describing_function_plot: '95f894706b1d3eeb3b854934596af09f', + control.dlqe: '9db995ed95c2214ce97074b0616a3191', + control.dlqr: '896cfa651dbbd80e417635904d13c9d6', + control.lqe: '567bf657538935173f2e50700ba87168', + control.lqr: 'a3e0a85f781fc9c0f69a4b7da4f0bd22', + control.frd: '099464bf2d14f25a8769ef951adf658b', + control.margin: 'f02b3034f5f1d44ce26f916cc3e51600', + control.parallel: '025c5195a34c57392223374b6244a8c4', + control.series: '9aede1459667738f05cf4fc46603a4f6', + control.ss: '1b9cfad5dbdf2f474cfdeadf5cb1ad80', + control.ss2tf: '48ff25d22d28e7b396e686dd5eb58831', + control.tf: '53a13f4a7f75a31c81800e10c88730ef', + control.tf2ss: '086a3692659b7321c2af126f79f4bc11', + control.markov: '753309de348132ef238e78ac756412c1', + control.gangof4: '0e52eb6cf7ce024f9a41f3ae3ebf04f7', } # List of keywords that we can skip testing (special cases) @@ -138,8 +138,12 @@ def test_parameter_docs(module, prefix): if obj in function_docstring_hash: import hashlib hash = hashlib.md5( - docstring.encode('utf-8')).hexdigest() - assert function_docstring_hash[obj] == hash + (docstring + source).encode('utf-8')).hexdigest() + if function_docstring_hash[obj] != hash: + pytest.fail( + f"source/docstring for {name}() modified; " + f"recheck docstring and update hash to " + f"{hash=}") continue # Too complicated to check From fb2086c29f36f629b54f716b491692bbe09aaa68 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Mon, 12 Aug 2024 22:26:22 -0700 Subject: [PATCH 13/15] address @slivingston review comments --- control/ctrlplot.py | 4 ++-- control/dtime.py | 2 +- control/freqplot.py | 14 +++++--------- control/iosys.py | 2 +- control/nlsys.py | 8 ++++---- control/phaseplot.py | 2 +- control/statesp.py | 15 ++++++--------- control/tests/docstrings_test.py | 7 ++++--- doc/plotting.rst | 4 +++- 9 files changed, 27 insertions(+), 31 deletions(-) diff --git a/control/ctrlplot.py b/control/ctrlplot.py index 4655b5e99..7f6a37af4 100644 --- a/control/ctrlplot.py +++ b/control/ctrlplot.py @@ -223,7 +223,7 @@ def get_plot_axes(line_array): .. deprecated:: 0.10.1 This function will be removed in a future version of python-control. - Use `cplt.axes` to obtain axes for a control plot `cplt`. + Use `cplt.axes` to obtain axes for an instance of :class:`ControlPlot`. This function can be used to return the set of axes corresponding to the line array that is returned by `time_response_plot`. This @@ -276,7 +276,7 @@ def pole_zero_subplots( Figure to use for creating subplots. rcParams : dict Override the default parameters used for generating plots. - Default is set up config.default['freqplot.rcParams']. + Default is set up config.default['ctrlplot.rcParams']. Returns ------- diff --git a/control/dtime.py b/control/dtime.py index 9b91eabd3..39b207e02 100644 --- a/control/dtime.py +++ b/control/dtime.py @@ -82,7 +82,7 @@ def sample_system(sysc, Ts, method='zoh', alpha=None, prewarp_frequency=None, Other Parameters ---------------- inputs : int, list of str or None, optional - Description of the system inputs. If not specified, the origional + Description of the system inputs. If not specified, the original system inputs are used. See :class:`InputOutputSystem` for more information. outputs : int, list of str or None, optional diff --git a/control/freqplot.py b/control/freqplot.py index 85f1666e8..d80ee5a8f 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -171,8 +171,6 @@ def bode_plot( legend_loc : int or str, optional Include a legend in the given location. Default is 'center right', with no legend for a single response. Use False to suppress legend. - magnitude_label : str, optional - Label to use for magnitude axis. Defaults to "Magnitude". margins_method : str, optional Method to use in computing margins (see :func:`stability_margins`). omega_limits : array_like of two values @@ -187,8 +185,6 @@ def bode_plot( overlay_inputs, overlay_outputs : bool, optional If set to True, combine input and/or output signals onto a single plot and use line colors, labels, and a legend to distinguish them. - phase_label : str, optional - Label to use for phase axis. Defaults to "Phase [rad]". plot : bool, optional (legacy) If given, `bode_plot` returns the legacy return values of magnitude, phase, and frequency. If False, just return the @@ -211,8 +207,8 @@ def bode_plot( Set the title of the plot. Defaults to plot type and system name(s). title_frame : str, optional Set the frame of reference used to center the plot title. If set to - 'axes' (default), the horizontal position of the title will - centered relative to the axes. If set to `figure`, it will be + 'axes' (default), the horizontal position of the title will be + centered relative to the axes. If set to 'figure', it will be centered with respect to the figure (faster execution). wrap_phase : bool or float If wrap_phase is `False` (default), then the phase will be unwrapped @@ -1658,7 +1654,7 @@ def nyquist_plot( title_frame : str, optional Set the frame of reference used to center the plot title. If set to 'axes' (default), the horizontal position of the title will - centered relative to the axes. If set to `figure`, it will be + centered relative to the axes. If set to 'figure', it will be centered with respect to the figure (faster execution). warn_nyquist : bool, optional If set to 'False', turn off warnings about frequencies above Nyquist. @@ -2386,7 +2382,7 @@ def singular_values_plot( the values with no plot. rcParams : dict Override the default parameters used for generating plots. - Default is set up config.default['freqplot.rcParams']. + Default is set up config.default['ctrlplot.rcParams']. show_legend : bool, optional Force legend to be shown if ``True`` or hidden if ``False``. If ``None``, then show legend when there is more than one line on an @@ -2396,7 +2392,7 @@ def singular_values_plot( title_frame : str, optional Set the frame of reference used to center the plot title. If set to 'axes' (default), the horizontal position of the title will - centered relative to the axes. If set to `figure`, it will be + centered relative to the axes. If set to 'figure', it will be centered with respect to the figure (faster execution). See Also diff --git a/control/iosys.py b/control/iosys.py index d76f65488..9092b672b 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -56,7 +56,7 @@ class InputOutputSystem(object): Description of the system inputs. This can be given as an integer count or a list of strings that name the individual signals. If an integer count is specified, the names of the signal will be of the - form `s[i]` (where `s` is given by the `input_prefix` parameter and + form 's[i]' (where 's' is given by the `input_prefix` parameter and has default value 'u'). If this parameter is not given or given as `None`, the relevant quantity will be determined when possible based on other information provided to functions using the system. diff --git a/control/nlsys.py b/control/nlsys.py index 4dd184583..accd24c0f 100644 --- a/control/nlsys.py +++ b/control/nlsys.py @@ -66,7 +66,7 @@ class NonlinearIOSystem(InputOutputSystem): Description of the system inputs. This can be given as an integer count or as a list of strings that name the individual signals. If an integer count is specified, the names of the signal will be - of the form `s[i]` (where `s` is one of `u`, `y`, or `x`). If + of the form 's[i]' (where 's' is one of 'u', 'y', or 'x'). If this parameter is not given or given as `None`, the relevant quantity will be determined when possible based on other information provided to functions using the system. @@ -1243,7 +1243,7 @@ def nlsys( Description of the system inputs. This can be given as an integer count or as a list of strings that name the individual signals. If an integer count is specified, the names of the signal will be - of the form `s[i]` (where `s` is one of `u`, `y`, or `x`). If + of the form 's[i]' (where 's' is one of 'u', 'y', or 'x'). If this parameter is not given or given as `None`, the relevant quantity will be determined when possible based on other information provided to functions using the system. @@ -1969,7 +1969,7 @@ def linearize(sys, xeq, ueq=None, t=0, params=None, **kw): Other Parameters ---------------- inputs : int, list of str or None, optional - Description of the system inputs. If not specified, the origional + Description of the system inputs. If not specified, the original system inputs are used. See :class:`InputOutputSystem` for more information. outputs : int, list of str or None, optional @@ -2101,7 +2101,7 @@ def interconnect( Description of the system inputs. This can be given as an integer count or as a list of strings that name the individual signals. If an integer count is specified, the names of the signal will be of the - form `s[i]` (where `s` is one of `u`, `y`, or `x`). If this parameter + form 's[i]' (where 's' is one of 'u', 'y', or 'x'). If this parameter is not given or given as `None`, the relevant quantity will be determined when possible based on other information provided to functions using the system. diff --git a/control/phaseplot.py b/control/phaseplot.py index bcb79c29e..abc050ffe 100644 --- a/control/phaseplot.py +++ b/control/phaseplot.py @@ -1014,7 +1014,7 @@ def phase_plot(odefun, X=None, Y=None, scale=1, X0=None, T=None, """(legacy) Phase plot for 2D dynamical systems. .. deprecated:: 0.10.1 - This function is deprecated; use `phase_plane_plot` instead. + This function is deprecated; use :func:`phase_plane_plot` instead. Produces a vector field or stream line plot for a planar system. This function has been replaced by the :func:`~control.phase_plane_map` and diff --git a/control/statesp.py b/control/statesp.py index c2d541ca1..aa1c7221b 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -1919,15 +1919,12 @@ def rss(states=1, outputs=1, inputs=1, strictly_proper=False, **kwargs): Parameters ---------- - states : int, list of str, or None - Description of the system states. Same format as `inputs`. - outputs : int, list of str, or None - Description of the system outputs. This can be given as an integer - count or as a list of strings that name the individual signals. If an - integer count is specified, the names of the signal will be of the - form `s[i]` (where `s` is one of `u`, `y`, or `x`). - inputs : int, list of str, or None - Description of the system inputs. Same format as `outputs`. + states, outputs, inputs : int, list of str, or None + Description of the system states, outputs, and inputs. This can be + given as an integer count or as a list of strings that name the + individual signals. If an integer count is specified, the names of + the signal will be of the form 's[i]' (where 's' is one of 'x', + 'y', or 'u'). strictly_proper : bool, optional If set to 'True', returns a proper system (no direct term). dt : None, True or float, optional diff --git a/control/tests/docstrings_test.py b/control/tests/docstrings_test.py index c0ca649e5..28bd0f38c 100644 --- a/control/tests/docstrings_test.py +++ b/control/tests/docstrings_test.py @@ -147,9 +147,10 @@ def test_parameter_docs(module, prefix): continue # Too complicated to check - if f"*{argname}" not in docstring and verbose: - print(f" {name} has positional arguments; " - "check manually") + if f"*{argname}" not in docstring: + if verbose: + print(f" {name} has positional arguments; " + "check manually") warnings.warn( f"{name} {argname} has positional arguments; " "docstring not checked") diff --git a/doc/plotting.rst b/doc/plotting.rst index 6832122af..167f5d001 100644 --- a/doc/plotting.rst +++ b/doc/plotting.rst @@ -654,11 +654,13 @@ Utility functions ----------------- These additional functions can be used to manipulate response data or -returned values from plotting routines. +carry out other operations in creating control plots. + .. autosummary:: :toctree: generated/ + ~control.box_grid ~control.combine_time_responses ~control.pole_zero_subplots ~control.reset_rcParams From a201504038a418e2eb1e6528fd67f87323f257ab Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Mon, 12 Aug 2024 22:54:06 -0700 Subject: [PATCH 14/15] filter known FutureWarning's from unit tests --- control/tests/matlab_test.py | 1 + control/tests/robust_test.py | 2 ++ control/tests/sisotool_test.py | 2 ++ 3 files changed, 5 insertions(+) diff --git a/control/tests/matlab_test.py b/control/tests/matlab_test.py index 265bfd9d2..26ff16774 100644 --- a/control/tests/matlab_test.py +++ b/control/tests/matlab_test.py @@ -83,6 +83,7 @@ class tsystems: @pytest.mark.usefixtures("fixedseed") +@pytest.mark.filterwarnings("ignore::FutureWarning") class TestMatlab: """Test matlab style functions""" diff --git a/control/tests/robust_test.py b/control/tests/robust_test.py index 146ae9e41..dde2423be 100644 --- a/control/tests/robust_test.py +++ b/control/tests/robust_test.py @@ -48,6 +48,7 @@ def testH2syn(self): np.testing.assert_array_almost_equal(k.D, [[0]]) +@pytest.mark.filterwarnings("ignore:connect:FutureWarning") class TestAugw: # tolerance for system equality @@ -324,6 +325,7 @@ def testErrors(self): augw(g1by1, w3=g2by2) +@pytest.mark.filterwarnings("ignore:connect:FutureWarning") class TestMixsyn: """Test control.robust.mixsyn""" diff --git a/control/tests/sisotool_test.py b/control/tests/sisotool_test.py index 325b9c180..1fc744daa 100644 --- a/control/tests/sisotool_test.py +++ b/control/tests/sisotool_test.py @@ -153,6 +153,7 @@ def test_sisotool_initial_gain(self, tsys): with pytest.warns(FutureWarning): sisotool(tsys, kvect=1.2) + @pytest.mark.filterwarnings("ignore:connect:FutureWarning") def test_sisotool_mimo(self, sys222, sys221): # a 2x2 should not raise an error: sisotool(sys222) @@ -196,6 +197,7 @@ def test_pid_designer_1(self, plant, gain, sign, input_signal, Kp0, Ki0, Kd0, de {'input_signal':'r', 'Kp0':0.01, 'derivative_in_feedback_path':True}, {'input_signal':'d', 'Kp0':0.01, 'derivative_in_feedback_path':True}, {'input_signal':'r', 'Kd0':0.01, 'derivative_in_feedback_path':True}]) + @pytest.mark.filterwarnings("ignore:connect:FutureWarning") def test_pid_designer_2(self, plant, kwargs): rootlocus_pid_designer(plant, **kwargs) From c3ea6dcb7a300526367419fb0080c2f8a7dff038 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Tue, 13 Aug 2024 06:32:00 -0700 Subject: [PATCH 15/15] add back missing image examples/vehicle-steering.png --- examples/vehicle-steering.png | Bin 0 -> 13510 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 examples/vehicle-steering.png diff --git a/examples/vehicle-steering.png b/examples/vehicle-steering.png new file mode 100644 index 0000000000000000000000000000000000000000..f10aab853f4fe6a445a2e71a91dfbbd82ccddccc GIT binary patch literal 13510 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 literal 0 HcmV?d00001