diff --git a/.coveragerc b/.coveragerc index 1a7311855..971e393ef 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,6 +1,7 @@ [run] source = control omit = control/tests/* +relative_files = True [report] exclude_lines = diff --git a/.github/workflows/control-slycot-src.yml b/.github/workflows/control-slycot-src.yml new file mode 100644 index 000000000..13a66e426 --- /dev/null +++ b/.github/workflows/control-slycot-src.yml @@ -0,0 +1,41 @@ +name: Slycot from source + +on: [push, pull_request] + +jobs: + build-linux: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + - name: Install Python dependencies + run: | + # Set up conda + echo $CONDA/bin >> $GITHUB_PATH + + # Set up (virtual) X11 + sudo apt install -y xvfb + + # Install test tools + conda install pip pytest pytest-timeout + + # Install python-control dependencies + conda install numpy matplotlib scipy + + - name: Install slycot from source + run: | + # Install compilers, libraries, and development environment + sudo apt-get -y install gfortran cmake --fix-missing + sudo apt-get -y install libblas-dev liblapack-dev + conda install -c conda-forge scikit-build; + + # Compile and install slycot + git clone https://github.com/python-control/Slycot.git slycot + cd slycot + git submodule update --init + python setup.py build_ext install -DBLA_VENDOR=Generic + + - name: Test with pytest + run: xvfb-run --auto-servernum pytest control/tests diff --git a/.github/workflows/install_examples.yml b/.github/workflows/install_examples.yml new file mode 100644 index 000000000..b36ff3e7f --- /dev/null +++ b/.github/workflows/install_examples.yml @@ -0,0 +1,35 @@ +name: setup.py, examples + +on: [push, pull_request] + +jobs: + build-linux: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + - name: Install Python dependencies + run: | + # Set up conda + echo $CONDA/bin >> $GITHUB_PATH + + # Set up (virtual) X11 + sudo apt install -y xvfb + + # Install test tools + conda install pip pytest + + # Install python-control dependencies + conda install numpy matplotlib scipy jupyter + conda install -c conda-forge slycot pmw + + - name: Install with setup.py + run: python setup.py install + + - name: Run examples + run: | + cd examples + ./run_examples.sh + ./run_notebooks.sh diff --git a/.github/workflows/python-package-conda.yml b/.github/workflows/python-package-conda.yml new file mode 100644 index 000000000..67f782048 --- /dev/null +++ b/.github/workflows/python-package-conda.yml @@ -0,0 +1,68 @@ +name: Conda-based pytest + +on: [push, pull_request] + +jobs: + test-linux: + name: Python ${{ matrix.python-version }}${{ matrix.slycot && format(' with Slycot from {0}', matrix.slycot) || ' without Slycot' }}${{ matrix.array-and-matrix == 1 && ', array and matrix' || '' }} + runs-on: ubuntu-latest + + strategy: + max-parallel: 5 + matrix: + python-version: [3.6, 3.9] + slycot: ["", "conda"] + array-and-matrix: [0] + include: + - python-version: 3.9 + slycot: conda + array-and-matrix: 1 + + steps: + - uses: actions/checkout@v2 + + - name: Install dependencies + run: | + # Set up conda + echo $CONDA/bin >> $GITHUB_PATH + conda create -q -n test-environment python=${{matrix.python-version}} + source $CONDA/bin/activate test-environment + + # Set up (virtual) X11 + sudo apt install -y xvfb + + # Install test tools + conda install pip coverage pytest pytest-timeout + pip install coveralls + + # Install python-control dependencies + conda install numpy matplotlib scipy + if [[ '${{matrix.slycot}}' == 'conda' ]]; then + conda install -c conda-forge slycot + fi + + - name: Test with pytest + env: + PYTHON_CONTROL_ARRAY_AND_MATRIX: ${{ matrix.array-and-matrix }} + run: | + source $CONDA/bin/activate test-environment + # Use xvfb-run instead of pytest-xvfb to get proper mpl backend + # Use coverage instead of pytest-cov to get .coverage file + # See https://github.com/python-control/python-control/pull/504 + xvfb-run --auto-servernum coverage run -m pytest control/tests + + - name: Coveralls parallel + # https://github.com/coverallsapp/github-action + uses: AndreMiras/coveralls-python-action@develop + with: + parallel: true + + coveralls: + name: coveralls completion + needs: test-linux + runs-on: ubuntu-latest + steps: + - name: Coveralls Finished + uses: AndreMiras/coveralls-python-action@develop + with: + parallel-finished: true diff --git a/.gitignore b/.gitignore index 0262ab46f..9f0a11c21 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ MANIFEST control/_version.py __conda_*.txt record.txt -build.log +*.log *.egg-info/ .eggs/ .coverage @@ -23,3 +23,9 @@ Untitled*.ipynb # Files created by or for emacs (RMM, 29 Dec 2017) *~ TAGS + +# Files created by or for asv (airspeed velocity) +.asv/ + +# Files created by Spyder +.spyproject/ diff --git a/.travis.yml b/.travis.yml index ec615501d..8d8c76262 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,73 +12,35 @@ cache: - $HOME/.cache/pip - $HOME/.local +# Test against earliest supported (Python 3) release and latest stable release python: - - "3.7" + - "3.9" - "3.6" - - "2.7" -# Test against multiple version of SciPy, with and without slycot -# -# Because there were significant changes in SciPy between v0 and v1, we -# test against both of these using the Travis CI environment capability -# -# We also want to test with and without slycot env: - SCIPY=scipy SLYCOT=conda # default, with slycot via conda - SCIPY=scipy SLYCOT= # default, w/out slycot - - SCIPY="scipy==0.19.1" SLYCOT= # legacy support, w/out slycot # Add optional builds that test against latest version of slycot, python jobs: include: - - name: "linux, Python 2.7, slycot=source" - os: linux - dist: xenial - services: xvfb + - name: "Python 3.9, slycot=source, array and matrix" + python: "3.9" + env: SCIPY=scipy SLYCOT=source PYTHON_CONTROL_ARRAY_AND_MATRIX=1 + # Because there were significant changes in SciPy between v0 and v1, we + # also test against the latest v0 (without Slycot) for old pythons. + # newer pythons should always use newer SciPy. + - name: "Python 2.7, Scipy 0.19.1" python: "2.7" - env: SCIPY=scipy SLYCOT=source - - name: "linux, Python 3.7, slycot=source" - os: linux - dist: xenial - services: xvfb - python: "3.7" - env: SCIPY=scipy SLYCOT=source - - name: "linux, Python 3.8, slycot=source" - os: linux - dist: xenial - services: xvfb - python: "3.8" - env: SCIPY=scipy SLYCOT=source - - name: "use numpy matrix" - dist: xenial - services: xvfb - python: "3.8" - env: SCIPY=scipy SLYCOT=source PYTHON_CONTROL_STATESPACE_ARRAY=1 - - # Exclude combinations that are very unlikely (and don't work) - exclude: - - python: "3.7" # python3.7 should use latest scipy + env: SCIPY="scipy==0.19.1" SLYCOT= + - name: "Python 3.6, Scipy 0.19.1" + python: "3.6" env: SCIPY="scipy==0.19.1" SLYCOT= allow_failures: - - name: "linux, Python 2.7, slycot=source" - os: linux - dist: xenial - services: xvfb - python: "2.7" - env: SCIPY=scipy SLYCOT=source - - name: "linux, Python 3.7, slycot=source" - os: linux - dist: xenial - services: xvfb - python: "3.7" - env: SCIPY=scipy SLYCOT=source - - name: "linux, Python 3.8, slycot=source" - os: linux - dist: xenial - services: xvfb - python: "3.8" - env: SCIPY=scipy SLYCOT=source + - env: SCIPY=scipy SLYCOT=source + - env: SCIPY=scipy SLYCOT=source PYTHON_CONTROL_ARRAY_AND_MATRIX=1 + # install required system libraries before_install: diff --git a/README.rst b/README.rst index d7c1306b5..6ebed1d78 100644 --- a/README.rst +++ b/README.rst @@ -131,3 +131,7 @@ Your contributions are welcome! Simply fork the GitHub repository and send a .. _pull request: https://github.com/python-control/python-control/pulls +Please see the `Developer's Wiki`_ for detailed instructions. + +.. _Developer's Wiki: https://github.com/python-control/python-control/wiki + diff --git a/asv.conf.json b/asv.conf.json new file mode 100644 index 000000000..590c24db0 --- /dev/null +++ b/asv.conf.json @@ -0,0 +1,161 @@ +{ + // The version of the config file format. Do not change, unless + // you know what you are doing. + "version": 1, + + // The name of the project being benchmarked + "project": "python-control", + + // The project's homepage + "project_url": "http://python-control.org/", + + // The URL or local path of the source code repository for the + // project being benchmarked + "repo": ".", + + // The Python project's subdirectory in your repo. If missing or + // the empty string, the project is assumed to be located at the root + // of the repository. + // "repo_subdir": ".", + + // Customizable commands for building, installing, and + // uninstalling the project. See asv.conf.json documentation. + // + // "install_command": ["in-dir={env_dir} python -mpip install {wheel_file}"], + // "uninstall_command": ["return-code=any python -mpip uninstall -y {project}"], + "build_command": [ + "python make_version.py", + "python setup.py build", + "PIP_NO_BUILD_ISOLATION=false python -mpip wheel --no-deps --no-index -w {build_cache_dir} {build_dir}" + ], + + // List of branches to benchmark. If not provided, defaults to "master" + // (for git) or "default" (for mercurial). + // "branches": ["master"], // for git + // "branches": ["default"], // for mercurial + + // The DVCS being used. If not set, it will be automatically + // determined from "repo" by looking at the protocol in the URL + // (if remote), or by looking for special directories, such as + // ".git" (if local). + // "dvcs": "git", + + // The tool to use to create environments. May be "conda", + // "virtualenv" or other value depending on the plugins in use. + // If missing or the empty string, the tool will be automatically + // determined by looking for tools on the PATH environment + // variable. + "environment_type": "conda", + + // timeout in seconds for installing any dependencies in environment + // defaults to 10 min + //"install_timeout": 600, + + // the base URL to show a commit for the project. + "show_commit_url": "http://github.com/python-control/python-control/commit/", + + // The Pythons you'd like to test against. If not provided, defaults + // to the current version of Python used to run `asv`. + // "pythons": ["2.7", "3.6"], + + // The list of conda channel names to be searched for benchmark + // dependency packages in the specified order + // "conda_channels": ["conda-forge", "defaults"], + + // The matrix of dependencies to test. Each key is the name of a + // package (in PyPI) and the values are version numbers. An empty + // list or empty string indicates to just test against the default + // (latest) version. null indicates that the package is to not be + // installed. If the package to be tested is only available from + // PyPi, and the 'environment_type' is conda, then you can preface + // the package name by 'pip+', and the package will be installed via + // pip (with all the conda available packages installed first, + // followed by the pip installed packages). + // + // "matrix": { + // "numpy": ["1.6", "1.7"], + // "six": ["", null], // test with and without six installed + // "pip+emcee": [""], // emcee is only available for install with pip. + // }, + + // Combinations of libraries/python versions can be excluded/included + // from the set to test. Each entry is a dictionary containing additional + // key-value pairs to include/exclude. + // + // An exclude entry excludes entries where all values match. The + // values are regexps that should match the whole string. + // + // An include entry adds an environment. Only the packages listed + // are installed. The 'python' key is required. The exclude rules + // do not apply to includes. + // + // In addition to package names, the following keys are available: + // + // - python + // Python version, as in the *pythons* variable above. + // - environment_type + // Environment type, as above. + // - sys_platform + // Platform, as in sys.platform. Possible values for the common + // cases: 'linux2', 'win32', 'cygwin', 'darwin'. + // + // "exclude": [ + // {"python": "3.2", "sys_platform": "win32"}, // skip py3.2 on windows + // {"environment_type": "conda", "six": null}, // don't run without six on conda + // ], + // + // "include": [ + // // additional env for python2.7 + // {"python": "2.7", "numpy": "1.8"}, + // // additional env if run on windows+conda + // {"platform": "win32", "environment_type": "conda", "python": "2.7", "libpython": ""}, + // ], + + // The directory (relative to the current directory) that benchmarks are + // stored in. If not provided, defaults to "benchmarks" + // "benchmark_dir": "benchmarks", + + // The directory (relative to the current directory) to cache the Python + // environments in. If not provided, defaults to "env" + "env_dir": ".asv/env", + + // The directory (relative to the current directory) that raw benchmark + // results are stored in. If not provided, defaults to "results". + "results_dir": ".asv/results", + + // The directory (relative to the current directory) that the html tree + // should be written to. If not provided, defaults to "html". + "html_dir": ".asv/html", + + // The number of characters to retain in the commit hashes. + // "hash_length": 8, + + // `asv` will cache results of the recent builds in each + // environment, making them faster to install next time. This is + // the number of builds to keep, per environment. + // "build_cache_size": 2, + + // The commits after which the regression search in `asv publish` + // should start looking for regressions. Dictionary whose keys are + // regexps matching to benchmark names, and values corresponding to + // the commit (exclusive) after which to start looking for + // regressions. The default is to start from the first commit + // with results. If the commit is `null`, regression detection is + // skipped for the matching benchmark. + // + // "regressions_first_commits": { + // "some_benchmark": "352cdf", // Consider regressions only after this commit + // "another_benchmark": null, // Skip regression detection altogether + // }, + + // The thresholds for relative change in results, after which `asv + // publish` starts reporting regressions. Dictionary of the same + // form as in ``regressions_first_commits``, with values + // indicating the thresholds. If multiple entries match, the + // maximum is taken. If no entry matches, the default is 5%. + // + // "regressions_thresholds": { + // "some_benchmark": 0.01, // Threshold of 1% + // "another_benchmark": 0.5, // Threshold of 50% + // }, +} diff --git a/benchmarks/README b/benchmarks/README new file mode 100644 index 000000000..a10bbfc21 --- /dev/null +++ b/benchmarks/README @@ -0,0 +1,39 @@ +This directory contains various scripts that can be used to measure the +performance of the python-control package. The scripts are intended to be +used with the airspeed velocity package (https://pypi.org/project/asv/) and +are mainly intended for use by developers in identfying potential +improvements to their code. + +Running benchmarks +------------------ +To run the benchmarks listed here against the current (uncommitted) code, +you can use the following command from the root directory of the repository: + + PYTHONPATH=`pwd` asv run --python=python + +You can also run benchmarks against specific commits usuing + + asv run + +where is a range of commits to benchmark. To check against the HEAD +of the branch that is currently checked out, use + + asv run HEAD^! + +Code profiling +-------------- +You can also use the benchmarks to profile code and look for bottlenecks. +To profile a given test against the current (uncommitted) code use + + PYTHONPATH=`pwd` asv profile --python=python . + +where is the name of one of the files in the benchmark/ subdirectory +and is the name of a test function in that file. + +If you have the `snakeviz` profiling visualization package installed, the +following command will profile a test against the HEAD of the current branch +and open a graphical representation of the profiled code: + + asv profile --gui snakeviz . HEAD + +RMM, 27 Feb 2021 diff --git a/benchmarks/__init__.py b/benchmarks/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/benchmarks/flatsys_bench.py b/benchmarks/flatsys_bench.py new file mode 100644 index 000000000..0c0a5e53a --- /dev/null +++ b/benchmarks/flatsys_bench.py @@ -0,0 +1,107 @@ +# flatsys_bench.py - benchmarks for flat systems package +# RMM, 2 Mar 2021 +# +# This benchmark tests the timing for the flat system module +# (control.flatsys) and is intended to be used for helping tune the +# performance of the functions used for optimization-based control. + +import numpy as np +import math +import control as ct +import control.flatsys as flat +import control.optimal as opt + +# Vehicle steering dynamics +def vehicle_update(t, x, u, params): + # Get the parameters for the model + l = params.get('wheelbase', 3.) # vehicle wheelbase + phimax = params.get('maxsteer', 0.5) # max steering angle (rad) + + # Saturate the steering input (use min/max instead of clip for speed) + phi = max(-phimax, min(u[1], phimax)) + + # Return the derivative of the state + return np.array([ + math.cos(x[2]) * u[0], # xdot = cos(theta) v + math.sin(x[2]) * u[0], # ydot = sin(theta) v + (u[0] / l) * math.tan(phi) # thdot = v/l tan(phi) + ]) + +def vehicle_output(t, x, u, params): + return x # return x, y, theta (full state) + +# Flatness structure +def vehicle_forward(x, u, params={}): + b = params.get('wheelbase', 3.) # get parameter values + zflag = [np.zeros(3), np.zeros(3)] # list for flag arrays + zflag[0][0] = x[0] # flat outputs + zflag[1][0] = x[1] + zflag[0][1] = u[0] * np.cos(x[2]) # first derivatives + zflag[1][1] = u[0] * np.sin(x[2]) + thdot = (u[0]/b) * np.tan(u[1]) # dtheta/dt + zflag[0][2] = -u[0] * thdot * np.sin(x[2]) # second derivatives + zflag[1][2] = u[0] * thdot * np.cos(x[2]) + return zflag + +def vehicle_reverse(zflag, params={}): + b = params.get('wheelbase', 3.) # get parameter values + x = np.zeros(3); u = np.zeros(2) # vectors to store x, u + x[0] = zflag[0][0] # x position + x[1] = zflag[1][0] # y position + x[2] = np.arctan2(zflag[1][1], zflag[0][1]) # angle + u[0] = zflag[0][1] * np.cos(x[2]) + zflag[1][1] * np.sin(x[2]) + thdot_v = zflag[1][2] * np.cos(x[2]) - zflag[0][2] * np.sin(x[2]) + u[1] = np.arctan2(thdot_v, u[0]**2 / b) + return x, u + +vehicle = flat.FlatSystem( + vehicle_forward, vehicle_reverse, vehicle_update, + vehicle_output, inputs=('v', 'delta'), outputs=('x', 'y', 'theta'), + states=('x', 'y', 'theta')) + +# Initial and final conditions +x0 = [0., -2., 0.]; u0 = [10., 0.] +xf = [100., 2., 0.]; uf = [10., 0.] +Tf = 10 + +# Define the time points where the cost/constraints will be evaluated +timepts = np.linspace(0, Tf, 10, endpoint=True) + +def time_steering_point_to_point(basis_name, basis_size): + if basis_name == 'poly': + basis = flat.PolyFamily(basis_size) + elif basis_name == 'bezier': + basis = flat.BezierFamily(basis_size) + + # Find trajectory between initial and final conditions + traj = flat.point_to_point(vehicle, Tf, x0, u0, xf, uf, basis=basis) + + # Verify that the trajectory computation is correct + x, u = traj.eval([0, Tf]) + np.testing.assert_array_almost_equal(x0, x[:, 0]) + np.testing.assert_array_almost_equal(u0, u[:, 0]) + np.testing.assert_array_almost_equal(xf, x[:, 1]) + np.testing.assert_array_almost_equal(uf, u[:, 1]) + +time_steering_point_to_point.params = (['poly', 'bezier'], [6, 8]) +time_steering_point_to_point.param_names = ["basis", "size"] + +def time_steering_cost(): + # Define cost and constraints + traj_cost = opt.quadratic_cost( + vehicle, None, np.diag([0.1, 1]), u0=uf) + constraints = [ + opt.input_range_constraint(vehicle, [8, -0.1], [12, 0.1]) ] + + traj = flat.point_to_point( + vehicle, timepts, x0, u0, xf, uf, + cost=traj_cost, constraints=constraints, basis=flat.PolyFamily(8) + ) + + # Verify that the trajectory computation is correct + x, u = traj.eval([0, Tf]) + np.testing.assert_array_almost_equal(x0, x[:, 0]) + np.testing.assert_array_almost_equal(u0, u[:, 0]) + np.testing.assert_array_almost_equal(xf, x[:, 1]) + np.testing.assert_array_almost_equal(uf, u[:, 1]) + diff --git a/benchmarks/optimal_bench.py b/benchmarks/optimal_bench.py new file mode 100644 index 000000000..21cabef7e --- /dev/null +++ b/benchmarks/optimal_bench.py @@ -0,0 +1,220 @@ +# optimal_bench.py - benchmarks for optimal control package +# RMM, 27 Feb 2021 +# +# This benchmark tests the timing for the optimal control module +# (control.optimal) and is intended to be used for helping tune the +# performance of the functions used for optimization-base control. + +import numpy as np +import math +import control as ct +import control.flatsys as flat +import control.optimal as opt + +# Vehicle steering dynamics +def vehicle_update(t, x, u, params): + # Get the parameters for the model + l = params.get('wheelbase', 3.) # vehicle wheelbase + phimax = params.get('maxsteer', 0.5) # max steering angle (rad) + + # Saturate the steering input (use min/max instead of clip for speed) + phi = max(-phimax, min(u[1], phimax)) + + # Return the derivative of the state + return np.array([ + math.cos(x[2]) * u[0], # xdot = cos(theta) v + math.sin(x[2]) * u[0], # ydot = sin(theta) v + (u[0] / l) * math.tan(phi) # thdot = v/l tan(phi) + ]) + +def vehicle_output(t, x, u, params): + return x # return x, y, theta (full state) + +vehicle = ct.NonlinearIOSystem( + vehicle_update, vehicle_output, states=3, name='vehicle', + inputs=('v', 'phi'), outputs=('x', 'y', 'theta')) + +# Initial and final conditions +x0 = [0., -2., 0.]; u0 = [10., 0.] +xf = [100., 2., 0.]; uf = [10., 0.] +Tf = 10 + +# Define the time horizon (and spacing) for the optimization +horizon = np.linspace(0, Tf, 10, endpoint=True) + +# Provide an intial guess (will be extended to entire horizon) +bend_left = [10, 0.01] # slight left veer + +def time_steering_integrated_cost(): + # Set up the cost functions + Q = np.diag([.1, 10, .1]) # keep lateral error low + R = np.diag([.1, 1]) # minimize applied inputs + quad_cost = opt.quadratic_cost( + vehicle, Q, R, x0=xf, u0=uf) + + res = opt.solve_ocp( + vehicle, horizon, x0, quad_cost, + initial_guess=bend_left, print_summary=False, + # solve_ivp_kwargs={'atol': 1e-2, 'rtol': 1e-2}, + minimize_method='trust-constr', + minimize_options={'finite_diff_rel_step': 0.01}, + ) + + # Only count this as a benchmark if we converged + assert res.success + +def time_steering_terminal_cost(): + # Define cost and constraints + traj_cost = opt.quadratic_cost( + vehicle, None, np.diag([0.1, 1]), u0=uf) + term_cost = opt.quadratic_cost( + vehicle, np.diag([1, 10, 10]), None, x0=xf) + constraints = [ + opt.input_range_constraint(vehicle, [8, -0.1], [12, 0.1]) ] + + res = opt.solve_ocp( + vehicle, horizon, x0, traj_cost, constraints, + terminal_cost=term_cost, initial_guess=bend_left, print_summary=False, + solve_ivp_kwargs={'atol': 1e-4, 'rtol': 1e-2}, + # minimize_method='SLSQP', minimize_options={'eps': 0.01} + minimize_method='trust-constr', + minimize_options={'finite_diff_rel_step': 0.01}, + ) + # Only count this as a benchmark if we converged + assert res.success + +# Define integrator and minimizer methods and options/keywords +integrator_table = { + 'RK23_default': ('RK23', {'atol': 1e-4, 'rtol': 1e-2}), + 'RK23_sloppy': ('RK23', {}), + 'RK45_default': ('RK45', {}), + 'RK45_sloppy': ('RK45', {'atol': 1e-4, 'rtol': 1e-2}), +} + +minimizer_table = { + 'trust_default': ('trust-constr', {}), + 'trust_bigstep': ('trust-constr', {'finite_diff_rel_step': 0.01}), + 'SLSQP_default': ('SLSQP', {}), + 'SLSQP_bigstep': ('SLSQP', {'eps': 0.01}), +} + + +def time_steering_terminal_constraint(integrator_name, minimizer_name): + # Get the integrator and minimizer parameters to use + integrator = integrator_table[integrator_name] + minimizer = minimizer_table[minimizer_name] + + # Input cost and terminal constraints + R = np.diag([1, 1]) # minimize applied inputs + cost = opt.quadratic_cost(vehicle, np.zeros((3,3)), R, u0=uf) + constraints = [ + opt.input_range_constraint(vehicle, [8, -0.1], [12, 0.1]) ] + terminal = [ opt.state_range_constraint(vehicle, xf, xf) ] + + res = opt.solve_ocp( + vehicle, horizon, x0, cost, constraints, + terminal_constraints=terminal, initial_guess=bend_left, log=False, + solve_ivp_method=integrator[0], solve_ivp_kwargs=integrator[1], + minimize_method=minimizer[0], minimize_options=minimizer[1], + ) + # Only count this as a benchmark if we converged + assert res.success + +# Reset the timeout value to allow for longer runs +time_steering_terminal_constraint.timeout = 120 + +# Parameterize the test against different choices of integrator and minimizer +time_steering_terminal_constraint.param_names = ['integrator', 'minimizer'] +time_steering_terminal_constraint.params = ( + ['RK23_default', 'RK23_sloppy', 'RK45_default', 'RK45_sloppy'], + ['trust_default', 'trust_bigstep', 'SLSQP_default', 'SLSQP_bigstep'] +) + +def time_steering_bezier_basis(nbasis, ntimes): + # Set up costs and constriants + Q = np.diag([.1, 10, .1]) # keep lateral error low + R = np.diag([1, 1]) # minimize applied inputs + cost = opt.quadratic_cost(vehicle, Q, R, x0=xf, u0=uf) + constraints = [ opt.input_range_constraint(vehicle, [0, -0.1], [20, 0.1]) ] + terminal = [ opt.state_range_constraint(vehicle, xf, xf) ] + + # Set up horizon + horizon = np.linspace(0, Tf, ntimes, endpoint=True) + + # Set up the optimal control problem + res = opt.solve_ocp( + vehicle, horizon, x0, cost, + constraints, + terminal_constraints=terminal, + initial_guess=bend_left, + basis=flat.BezierFamily(nbasis, T=Tf), + # solve_ivp_kwargs={'atol': 1e-4, 'rtol': 1e-2}, + minimize_method='trust-constr', + minimize_options={'finite_diff_rel_step': 0.01}, + # minimize_method='SLSQP', minimize_options={'eps': 0.01}, + return_states=True, print_summary=False + ) + t, u, x = res.time, res.inputs, res.states + + # Make sure we found a valid solution + assert res.success + +# Reset the timeout value to allow for longer runs +time_steering_bezier_basis.timeout = 120 + +# Set the parameter values for the number of times and basis vectors +time_steering_bezier_basis.param_names = ['nbasis', 'ntimes'] +time_steering_bezier_basis.params = ([2, 4, 6], [5, 10, 20]) + +def time_aircraft_mpc(): + # model of an aircraft discretized with 0.2s sampling time + # Source: https://www.mpt3.org/UI/RegulationProblem + A = [[0.99, 0.01, 0.18, -0.09, 0], + [ 0, 0.94, 0, 0.29, 0], + [ 0, 0.14, 0.81, -0.9, 0], + [ 0, -0.2, 0, 0.95, 0], + [ 0, 0.09, 0, 0, 0.9]] + B = [[ 0.01, -0.02], + [-0.14, 0], + [ 0.05, -0.2], + [ 0.02, 0], + [-0.01, 0]] + C = [[0, 1, 0, 0, -1], + [0, 0, 1, 0, 0], + [0, 0, 0, 1, 0], + [1, 0, 0, 0, 0]] + model = ct.ss2io(ct.ss(A, B, C, 0, 0.2)) + + # For the simulation we need the full state output + sys = ct.ss2io(ct.ss(A, B, np.eye(5), 0, 0.2)) + + # compute the steady state values for a particular value of the input + ud = np.array([0.8, -0.3]) + xd = np.linalg.inv(np.eye(5) - A) @ B @ ud + yd = C @ xd + + # provide constraints on the system signals + constraints = [opt.input_range_constraint(sys, [-5, -6], [5, 6])] + + # provide penalties on the system signals + Q = model.C.transpose() @ np.diag([10, 10, 10, 10]) @ model.C + R = np.diag([3, 2]) + cost = opt.quadratic_cost(model, Q, R, x0=xd, u0=ud) + + # online MPC controller object is constructed with a horizon 6 + ctrl = opt.create_mpc_iosystem( + model, np.arange(0, 6) * 0.2, cost, constraints) + + # Define an I/O system implementing model predictive control + loop = ct.feedback(sys, ctrl, 1) + + # Choose a nearby initial condition to speed up computation + X0 = np.hstack([xd, np.kron(ud, np.ones(6))]) * 0.99 + + Nsim = 12 + tout, xout = ct.input_output_response( + loop, np.arange(0, Nsim) * 0.2, 0, X0) + + # Make sure the system converged to the desired state + np.testing.assert_allclose( + xout[0:sys.nstates, -1], xd, atol=0.1, rtol=0.01) diff --git a/control/__init__.py b/control/__init__.py index 7daa39b3e..57f2d2690 100644 --- a/control/__init__.py +++ b/control/__init__.py @@ -48,6 +48,7 @@ # Note: the functions we use are specified as __all__ variables in the modules from .bdalg import * from .delay import * +from .descfcn import * from .dtime import * from .freqplot import * from .lti import * diff --git a/control/bdalg.py b/control/bdalg.py index a9ba6cd16..9650955a3 100644 --- a/control/bdalg.py +++ b/control/bdalg.py @@ -76,7 +76,7 @@ def series(sys1, *sysn): Raises ------ ValueError - if `sys2.inputs` does not equal `sys1.outputs` + if `sys2.ninputs` does not equal `sys1.noutputs` if `sys1.dt` is not compatible with `sys2.dt` See Also @@ -174,7 +174,7 @@ def negate(sys): >>> sys2 = negate(sys1) # Same as sys2 = -sys1. """ - return -sys; + return -sys #! TODO: expand to allow sys2 default to work in MIMO case? def feedback(sys1, sys2=1, sign=-1): @@ -242,9 +242,9 @@ def feedback(sys1, sys2=1, sign=-1): if isinstance(sys2, tf.TransferFunction): sys1 = tf._convert_to_transfer_function(sys1) elif isinstance(sys2, ss.StateSpace): - sys1 = ss._convertToStateSpace(sys1) + sys1 = ss._convert_to_statespace(sys1) elif isinstance(sys2, frd.FRD): - sys1 = frd._convertToFRD(sys1, sys2.omega) + sys1 = frd._convert_to_FRD(sys1, sys2.omega) else: # sys2 is a scalar. sys1 = tf._convert_to_transfer_function(sys1) sys2 = tf._convert_to_transfer_function(sys2) @@ -280,7 +280,7 @@ def append(*sys): >>> sys = append(sys1, sys2) """ - s1 = sys[0] + s1 = ss._convert_to_statespace(sys[0]) for s in sys[1:]: s1 = s1.append(s) return s1 @@ -326,28 +326,36 @@ def connect(sys, Q, inputv, outputv): >>> Q = [[1, 2], [2, -1]] # negative feedback interconnection >>> sysc = connect(sys, Q, [2], [1, 2]) + Notes + ----- + The :func:`~control.interconnect` function in the + :ref:`input/output systems ` module allows the use + of named signals and provides an alternative method for + interconnecting multiple systems. + """ - inputv, outputv, Q = np.asarray(inputv), np.asarray(outputv), np.asarray(Q) + inputv, outputv, Q = \ + np.atleast_1d(inputv), np.atleast_1d(outputv), np.atleast_1d(Q) # check indices - index_errors = (inputv - 1 > sys.inputs) | (inputv < 1) + index_errors = (inputv - 1 > sys.ninputs) | (inputv < 1) if np.any(index_errors): raise IndexError( "inputv index %s out of bounds" % inputv[np.where(index_errors)]) - index_errors = (outputv - 1 > sys.outputs) | (outputv < 1) + index_errors = (outputv - 1 > sys.noutputs) | (outputv < 1) if np.any(index_errors): raise IndexError( "outputv index %s out of bounds" % outputv[np.where(index_errors)]) - index_errors = (Q[:,0:1] - 1 > sys.inputs) | (Q[:,0:1] < 1) + index_errors = (Q[:,0:1] - 1 > sys.ninputs) | (Q[:,0:1] < 1) if np.any(index_errors): raise IndexError( "Q input index %s out of bounds" % Q[np.where(index_errors)]) - index_errors = (np.abs(Q[:,1:]) - 1 > sys.outputs) + index_errors = (np.abs(Q[:,1:]) - 1 > sys.noutputs) if np.any(index_errors): raise IndexError( "Q output index %s out of bounds" % Q[np.where(index_errors)]) # first connect - K = np.zeros((sys.inputs, sys.outputs)) + K = np.zeros((sys.ninputs, sys.noutputs)) for r in np.array(Q).astype(int): inp = r[0]-1 for outp in r[1:]: @@ -358,8 +366,8 @@ def connect(sys, Q, inputv, outputv): sys = sys.feedback(np.array(K), sign=1) # now trim - Ytrim = np.zeros((len(outputv), sys.outputs)) - Utrim = np.zeros((sys.inputs, len(inputv))) + Ytrim = np.zeros((len(outputv), sys.noutputs)) + Utrim = np.zeros((sys.ninputs, len(inputv))) for i,u in enumerate(inputv): Utrim[u-1,i] = 1. for i,y in enumerate(outputv): diff --git a/control/bench/time_freqresp.py b/control/bench/time_freqresp.py index 1945cbc24..3ae837082 100644 --- a/control/bench/time_freqresp.py +++ b/control/bench/time_freqresp.py @@ -8,7 +8,7 @@ sys_tf = tf(sys) w = logspace(-1,1,50) ntimes = 1000 -time_ss = timeit("sys.freqresp(w)", setup="from __main__ import sys, w", number=ntimes) -time_tf = timeit("sys_tf.freqresp(w)", setup="from __main__ import sys_tf, w", number=ntimes) +time_ss = timeit("sys.freqquency_response(w)", setup="from __main__ import sys, w", number=ntimes) +time_tf = timeit("sys_tf.frequency_response(w)", setup="from __main__ import sys_tf, w", number=ntimes) print("State-space model on %d states: %f" % (nstates, time_ss)) print("Transfer-function model on %d states: %f" % (nstates, time_tf)) diff --git a/control/canonical.py b/control/canonical.py index bd9ee4a94..45846147f 100644 --- a/control/canonical.py +++ b/control/canonical.py @@ -1,17 +1,21 @@ # canonical.py - functions for converting systems to canonical forms # RMM, 10 Nov 2012 -from .exception import ControlNotImplemented +from .exception import ControlNotImplemented, ControlSlycot from .lti import issiso -from .statesp import StateSpace +from .statesp import StateSpace, _convert_to_statespace from .statefbk import ctrb, obsv +import numpy as np + from numpy import zeros, zeros_like, shape, poly, iscomplex, vstack, hstack, dot, \ - transpose, empty + transpose, empty, finfo, float64 from numpy.linalg import solve, matrix_rank, eig +from scipy.linalg import schur + __all__ = ['canonical_form', 'reachable_form', 'observable_form', 'modal_form', - 'similarity_transform'] + 'similarity_transform', 'bdschur'] def canonical_form(xsys, form='reachable'): """Convert a system into canonical form @@ -20,7 +24,7 @@ def canonical_form(xsys, form='reachable'): ---------- xsys : StateSpace object System to be transformed, with state 'x' - form : String + form : str Canonical form for transformation. Chosen from: * 'reachable' - reachable canonical form * 'observable' - observable canonical form @@ -30,7 +34,7 @@ def canonical_form(xsys, form='reachable'): ------- zsys : StateSpace object System in desired canonical form, with state 'z' - T : matrix + T : (M, M) real ndarray Coordinate transformation matrix, z = T * x """ @@ -59,7 +63,7 @@ def reachable_form(xsys): ------- zsys : StateSpace object System in reachable canonical form, with state `z` - T : matrix + T : (M, M) real ndarray Coordinate transformation: z = T * x """ # Check to make sure we have a SISO system @@ -75,16 +79,16 @@ def reachable_form(xsys): zsys.B[0, 0] = 1.0 zsys.A = zeros_like(xsys.A) Apoly = poly(xsys.A) # characteristic polynomial - for i in range(0, xsys.states): + for i in range(0, xsys.nstates): zsys.A[0, i] = -Apoly[i+1] / Apoly[0] - if (i+1 < xsys.states): + if (i+1 < xsys.nstates): zsys.A[i+1, i] = 1.0 # Compute the reachability matrices for each set of states Wrx = ctrb(xsys.A, xsys.B) Wrz = ctrb(zsys.A, zsys.B) - if matrix_rank(Wrx) != xsys.states: + if matrix_rank(Wrx) != xsys.nstates: raise ValueError("System not controllable to working precision.") # Transformation from one form to another @@ -92,7 +96,7 @@ def reachable_form(xsys): # Check to make sure inversion was OK. Note that since we are inverting # Wrx and we already checked its rank, this exception should never occur - if matrix_rank(Tzx) != xsys.states: # pragma: no cover + if matrix_rank(Tzx) != xsys.nstates: # pragma: no cover raise ValueError("Transformation matrix singular to working precision.") # Finally, compute the output matrix @@ -113,7 +117,7 @@ def observable_form(xsys): ------- zsys : StateSpace object System in observable canonical form, with state `z` - T : matrix + T : (M, M) real ndarray Coordinate transformation: z = T * x """ # Check to make sure we have a SISO system @@ -129,9 +133,9 @@ def observable_form(xsys): zsys.C[0, 0] = 1 zsys.A = zeros_like(xsys.A) Apoly = poly(xsys.A) # characteristic polynomial - for i in range(0, xsys.states): + for i in range(0, xsys.nstates): zsys.A[i, 0] = -Apoly[i+1] / Apoly[0] - if (i+1 < xsys.states): + if (i+1 < xsys.nstates): zsys.A[i, i+1] = 1 # Compute the observability matrices for each set of states @@ -141,7 +145,7 @@ def observable_form(xsys): # Transformation from one form to another Tzx = solve(Wrz, Wrx) # matrix left division, Tzx = inv(Wrz) * Wrx - if matrix_rank(Tzx) != xsys.states: + if matrix_rank(Tzx) != xsys.nstates: raise ValueError("Transformation matrix singular to working precision.") # Finally, compute the output matrix @@ -149,97 +153,298 @@ def observable_form(xsys): return zsys, Tzx -def modal_form(xsys): - """Convert a system into modal canonical form + +def similarity_transform(xsys, T, timescale=1, inverse=False): + """Perform a similarity transformation, with option time rescaling. + + Transform a linear state space system to a new state space representation + z = T x, or x = T z, where T is an invertible matrix. Parameters ---------- xsys : StateSpace object - System to be transformed, with state `x` + System to transform + T : (M, M) array_like + 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 + If True (default), transform so z = T x. If False, transform + so x = T z. Returns ------- zsys : StateSpace object - System in modal canonical form, with state `z` - T : matrix - Coordinate transformation: z = T * x - """ - # Check to make sure we have a SISO system - if not issiso(xsys): - raise ControlNotImplemented( - "Canonical forms for MIMO systems not yet supported") + System in transformed coordinates, with state 'z' + """ # Create a new system, starting with a copy of the old one zsys = StateSpace(xsys) - # Calculate eigenvalues and matrix of eigenvectors Tzx, - eigval, eigvec = eig(xsys.A) + T = np.atleast_2d(T) - # Eigenvalues and corresponding eigenvectors are not sorted, - # thus modal transformation is ambiguous - # Sort eigenvalues and vectors from largest to smallest eigenvalue - idx = eigval.argsort()[::-1] - eigval = eigval[idx] - eigvec = eigvec[:,idx] + # Define a function to compute the right inverse (solve x M = y) + def rsolve(M, y): + return transpose(solve(transpose(M), transpose(y))) - # If all eigenvalues are real, the matrix of eigenvectors is Tzx directly - if not iscomplex(eigval).any(): - Tzx = eigvec + # Update the system matrices + if not inverse: + zsys.A = rsolve(T, dot(T, zsys.A)) / timescale + zsys.B = dot(T, zsys.B) / timescale + zsys.C = rsolve(T, zsys.C) else: - # A is an arbitrary semisimple matrix - - # Keep track of complex conjugates (need only one) - lst_conjugates = [] - Tzx = empty((0, xsys.A.shape[0])) # empty zero-height row matrix - for val, vec in zip(eigval, eigvec.T): - if iscomplex(val): - if val not in lst_conjugates: - lst_conjugates.append(val.conjugate()) - Tzx = vstack((Tzx, vec.real, vec.imag)) - else: - # if conjugate has already been seen, skip this eigenvalue - lst_conjugates.remove(val) - else: - Tzx = vstack((Tzx, vec.real)) - Tzx = Tzx.T + zsys.A = solve(T, zsys.A).dot(T) / timescale + zsys.B = solve(T, zsys.B) / timescale + zsys.C = zsys.C.dot(T) - # Generate the system matrices for the desired canonical form - zsys.A = solve(Tzx, xsys.A).dot(Tzx) - zsys.B = solve(Tzx, xsys.B) - zsys.C = xsys.C.dot(Tzx) + return zsys - return zsys, Tzx +_IM_ZERO_TOL = np.finfo(np.float64).eps ** 0.5 +_PMAX_SEARCH_TOL = 1.001 -def similarity_transform(xsys, T, timescale=1): - """Perform a similarity transformation, with option time rescaling. - Transform a linear state space system to a new state space representation - z = T x, where T is an invertible matrix. +def _bdschur_defective(blksizes, eigvals): + """Check for defective modal decomposition Parameters ---------- - T : 2D invertible array - The matrix `T` defines the new set of coordinates z = T x. - timescale : float - If present, also rescale the time unit to tau = timescale * t + blksizes: (N,) int ndarray + size of Schur blocks + eigvals: (M,) real or complex ndarray + Eigenvalues Returns ------- - zsys : StateSpace object - System in transformed coordinates, with state 'z' + True iff Schur blocks are defective. + blksizes, eigvals are the 3rd and 4th results returned by mb03rd. """ - # Create a new system, starting with a copy of the old one - zsys = StateSpace(xsys) + if any(blksizes > 2): + return True - # Define a function to compute the right inverse (solve x M = y) - def rsolve(M, y): - return transpose(solve(transpose(M), transpose(y))) + if all(blksizes == 1): + return False - # Update the system matrices - zsys.A = rsolve(T, dot(T, zsys.A)) / timescale - zsys.B = dot(T, zsys.B) / timescale - zsys.C = rsolve(T, zsys.C) + # check eigenvalues associated with blocks of size 2 + init_idxs = np.cumsum(np.hstack([0, blksizes[:-1]])) + blk_idx2 = blksizes == 2 - return zsys + im = eigvals[init_idxs[blk_idx2]].imag + re = eigvals[init_idxs[blk_idx2]].real + + if any(abs(im) < _IM_ZERO_TOL * abs(re)): + return True + + return False + + +def _bdschur_condmax_search(aschur, tschur, condmax): + """Block-diagonal Schur decomposition search up to condmax + + Iterates mb03rd with different pmax values until: + - result is non-defective; + - or condition number of similarity transform is unchanging despite large pmax; + - or condition number of similarity transform is close to condmax. + + Parameters + ---------- + aschur: (N, N) real ndarray + Real Schur-form matrix + tschur: (N, N) real ndarray + Orthogonal transformation giving aschur from some initial matrix a + condmax: float + Maximum condition number of final transformation. Must be >= 1. + + Returns + ------- + amodal: (N, N) real ndarray + block diagonal Schur form + tmodal: (N, N) real ndarray + similarity transformation give amodal from aschur + blksizes: (M,) int ndarray + Array of Schur block sizes + eigvals: (N,) real or complex ndarray + Eigenvalues of amodal (and a, etc.) + + Notes + ----- + Outputs as for slycot.mb03rd + + aschur, tschur are as returned by scipy.linalg.schur. + """ + try: + from slycot import mb03rd + except ImportError: + raise ControlSlycot("can't find slycot module 'mb03rd'") + + # see notes on RuntimeError below + pmaxlower = None + + # get lower bound; try condmax ** 0.5 first + pmaxlower = condmax ** 0.5 + amodal, tmodal, blksizes, eigvals = mb03rd(aschur.shape[0], aschur, tschur, pmax=pmaxlower) + if np.linalg.cond(tmodal) <= condmax: + reslower = amodal, tmodal, blksizes, eigvals + else: + pmaxlower = 1.0 + amodal, tmodal, blksizes, eigvals = mb03rd(aschur.shape[0], aschur, tschur, pmax=pmaxlower) + cond = np.linalg.cond(tmodal) + if cond > condmax: + msg = 'minimum cond={} > condmax={}; try increasing condmax'.format(cond, condmax) + raise RuntimeError(msg) + + pmax = pmaxlower + + # phase 1: search for upper bound on pmax + for i in range(50): + amodal, tmodal, blksizes, eigvals = mb03rd(aschur.shape[0], aschur, tschur, pmax=pmax) + cond = np.linalg.cond(tmodal) + if cond < condmax: + pmaxlower = pmax + reslower = amodal, tmodal, blksizes, eigvals + else: + # upper bound found; go to phase 2 + pmaxupper = pmax + break + + if _bdschur_defective(blksizes, eigvals): + pmax *= 2 + else: + return amodal, tmodal, blksizes, eigvals + else: + # no upper bound found; return current result + return reslower + + # phase 2: bisection search + for i in range(50): + pmax = (pmaxlower * pmaxupper) ** 0.5 + amodal, tmodal, blksizes, eigvals = mb03rd(aschur.shape[0], aschur, tschur, pmax=pmax) + cond = np.linalg.cond(tmodal) + + if cond < condmax: + if not _bdschur_defective(blksizes, eigvals): + return amodal, tmodal, blksizes, eigvals + pmaxlower = pmax + reslower = amodal, tmodal, blksizes, eigvals + else: + pmaxupper = pmax + + if pmaxupper / pmaxlower < _PMAX_SEARCH_TOL: + # hit search limit + return reslower + else: + raise ValueError('bisection failed to converge; pmaxlower={}, pmaxupper={}'.format(pmaxlower, pmaxupper)) + + +def bdschur(a, condmax=None, sort=None): + """Block-diagonal Schur decomposition + + 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. + + 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 + + Notes + ----- + If `sort` is None, the blocks are not sorted. + + If `sort` is 'continuous', the blocks are sorted according to + associated eigenvalues. The ordering is first by real part of + eigenvalue, in descending order, then by absolute value of + imaginary part of eigenvalue, also in decreasing order. + + If `sort` is 'discrete', the blocks are sorted as for + 'continuous', but applied to log of eigenvalues + (i.e., continuous-equivalent eigenvalues). + """ + if condmax is None: + condmax = np.finfo(np.float64).eps ** -0.5 + + if not (np.isscalar(condmax) and condmax >= 1.0): + raise ValueError('condmax="{}" must be a scalar >= 1.0'.format(condmax)) + + a = np.atleast_2d(a) + if a.shape[0] == 0 or a.shape[1] == 0: + return a.copy(), np.eye(a.shape[1], a.shape[0]), np.array([]) + + aschur, tschur = schur(a) + amodal, tmodal, blksizes, eigvals = _bdschur_condmax_search(aschur, tschur, condmax) + + if sort in ('continuous', 'discrete'): + + idxs = np.cumsum(np.hstack([0, blksizes[:-1]])) + + ev_per_blk = [complex(eigvals[i].real, abs(eigvals[i].imag)) + for i in idxs] + + if sort == 'discrete': + ev_per_blk = np.log(ev_per_blk) + + # put most unstable first + sortidx = np.argsort(ev_per_blk)[::-1] + + # block indices + blkidxs = [np.arange(i0, i0+ilen) + for i0, ilen in zip(idxs, blksizes)] + + # reordered + permidx = np.hstack([blkidxs[i] for i in sortidx]) + rperm = np.eye(amodal.shape[0])[permidx] + + tmodal = tmodal.dot(rperm) + amodal = rperm.dot(amodal).dot(rperm.T) + blksizes = blksizes[sortidx] + + elif sort is None: + pass + + else: + raise ValueError('unknown sort value "{}"'.format(sort)) + + return amodal, tmodal, blksizes + + +def modal_form(xsys, condmax=None, sort=False): + """Convert a system into modal canonical form + + Parameters + ---------- + xsys : StateSpace object + System to be transformed, with state `x` + condmax : None or float, optional + An upper bound on individual transformations. If None, use `bdschur` default. + sort : bool, optional + If False (default), Schur blocks will not be sorted. See `bdschur` for sort order. + + Returns + ------- + zsys : StateSpace object + System in modal canonical form, with state `z` + T : (M, M) ndarray + Coordinate transformation: z = T * x + """ + + if sort: + discrete = xsys.dt is not None and xsys.dt > 0 + bd_sort = 'discrete' if discrete else 'continuous' + else: + bd_sort = None + + xsys = _convert_to_statespace(xsys) + amodal, tmodal, _ = bdschur(xsys.A, condmax=condmax, sort=bd_sort) + + return similarity_transform(xsys, tmodal, inverse=True), tmodal diff --git a/control/config.py b/control/config.py index 21840231b..2d2cc6248 100644 --- a/control/config.py +++ b/control/config.py @@ -15,7 +15,10 @@ # Package level default values _control_defaults = { - # No package level defaults (yet) + 'control.default_dt': 0, + 'control.squeeze_frequency_response': None, + 'control.squeeze_time_response': None, + 'forced_response.return_x': False, } defaults = dict(_control_defaults) @@ -40,9 +43,10 @@ def reset_defaults(): # System level defaults defaults.update(_control_defaults) - from .freqplot import _bode_defaults, _freqplot_defaults + from .freqplot import _bode_defaults, _freqplot_defaults, _nyquist_defaults defaults.update(_bode_defaults) defaults.update(_freqplot_defaults) + defaults.update(_nyquist_defaults) from .nichols import _nichols_defaults defaults.update(_nichols_defaults) @@ -59,8 +63,11 @@ def reset_defaults(): from .statesp import _statesp_defaults defaults.update(_statesp_defaults) + from .iosys import _iosys_defaults + defaults.update(_iosys_defaults) -def _get_param(module, param, argval=None, defval=None, pop=False): + +def _get_param(module, param, argval=None, defval=None, pop=False, last=False): """Return the default value for a configuration option. The _get_param() function is a utility function used to get the value of a @@ -84,11 +91,13 @@ def _get_param(module, param, argval=None, defval=None, pop=False): `config.defaults` dictionary. If a dictionary is provided, then `module.param` is used to determine the default value. Defaults to None. - pop : bool + pop : bool, optional If True and if argval is a dict, then pop the remove the parameter entry from the argval dict after retreiving it. This allows the use of a keyword argument list to be passed through to other functions internal to the function being called. + last : bool, optional + If True, check to make sure dictionary is empy after processing. """ @@ -101,7 +110,10 @@ def _get_param(module, param, argval=None, defval=None, pop=False): # If we were passed a dict for the argval, get the param value from there if isinstance(argval, dict): - argval = argval.pop(param, None) if pop else argval.get(param, None) + val = argval.pop(param, None) if pop else argval.get(param, None) + if last and argval: + raise TypeError("unrecognized keywords: " + str(argval)) + argval = val # If we were passed a dict for the defval, get the param value from there if isinstance(defval, dict): @@ -132,9 +144,11 @@ def use_fbs_defaults(): The following conventions are used: * Bode plots plot gain in powers of ten, phase in degrees, frequency in rad/sec, no grid + * Nyquist plots use dashed lines for mirror image of Nyquist curve """ set_defaults('bode', dB=False, deg=True, Hz=False, grid=False) + set_defaults('nyquist', mirror_style='--') # Decide whether to use numpy.matrix for state space operations @@ -144,7 +158,7 @@ def use_numpy_matrix(flag=True, warn=True): Parameters ---------- flag : bool - If flag is `True` (default), use the Numpy (soon to be deprecated) + If flag is `True` (default), use the deprecated Numpy `matrix` class to represent matrices in the `~control.StateSpace` class and functions. If flat is `False`, then matrices are represented by a 2D `ndarray` object. @@ -161,8 +175,8 @@ class and functions. If flat is `False`, then matrices are space operations is a 2D array. """ if flag and warn: - warnings.warn("Return type numpy.matrix is soon to be deprecated.", - stacklevel=2) + warnings.warn("Return type numpy.matrix is deprecated.", + stacklevel=2, category=DeprecationWarning) set_defaults('statesp', use_numpy_matrix=flag) def use_legacy_defaults(version): @@ -171,17 +185,69 @@ def use_legacy_defaults(version): Parameters ---------- version : string - version number of the defaults desired. ranges from '0.1' to '0.8.4'. + Version number of the defaults desired. Ranges from '0.1' to '0.8.4'. """ - numbers_list = version.split(".") - first_digit = int(numbers_list[0]) - second_digit = int(numbers_list[1].strip('abcdef')) # remove trailing letters - if second_digit < 8: - # TODO: anything for 0.7 and below if needed - pass - elif second_digit == 8: - if len(version) > 4: - third_digit = int(version[4]) - use_numpy_matrix(True) # alternatively: set_defaults('statesp', use_numpy_matrix=True) - else: - raise ValueError('''version number not recognized. Possible values range from '0.1' to '0.8.4'.''') + import re + (major, minor, patch) = (None, None, None) # default values + + # Early release tag format: REL-0.N + match = re.match("REL-0.([12])", version) + if match: (major, minor, patch) = (0, int(match.group(1)), 0) + + # Early release tag format: control-0.Np + match = re.match("control-0.([3-6])([a-d])", version) + if match: (major, minor, patch) = \ + (0, int(match.group(1)), ord(match.group(2)) - ord('a') + 1) + + # Early release tag format: v0.Np + match = re.match("[vV]?0.([3-6])([a-d])", version) + if match: (major, minor, patch) = \ + (0, int(match.group(1)), ord(match.group(2)) - ord('a') + 1) + + # Abbreviated version format: vM.N or M.N + match = re.match("([vV]?[0-9]).([0-9])", version) + if match: (major, minor, patch) = \ + (int(match.group(1)), int(match.group(2)), 0) + + # Standard version format: vM.N.P or M.N.P + match = re.match("[vV]?([0-9]).([0-9]).([0-9])", version) + if match: (major, minor, patch) = \ + (int(match.group(1)), int(match.group(2)), int(match.group(3))) + + # Make sure we found match + if major is None or minor is None: + raise ValueError("Version number not recognized. Try M.N.P format.") + + # + # Go backwards through releases and reset defaults + # + reset_defaults() # start from a clean slate + + # Version 0.9.0: + if major == 0 and minor < 9: + # switched to 'array' as default for state space objects + set_defaults('statesp', use_numpy_matrix=True) + + # switched to 0 (=continuous) as default timestep + set_defaults('control', default_dt=None) + + # changed iosys naming conventions + set_defaults('iosys', state_name_delim='.', + duplicate_system_name_prefix='copy of ', + duplicate_system_name_suffix='', + linearized_system_name_prefix='', + linearized_system_name_suffix='_linearized') + + # turned off _remove_useless_states + set_defaults('statesp', remove_useless_states=True) + + # forced_response no longer returns x by default + set_defaults('forced_response', return_x=True) + + # time responses are only squeezed if SISO + set_defaults('control', squeeze_time_response=True) + + # switched mirror_style of nyquist from '-' to '--' + set_defaults('nyqist', mirror_style='-') + + return (major, minor, patch) diff --git a/control/descfcn.py b/control/descfcn.py new file mode 100644 index 000000000..14a345495 --- /dev/null +++ b/control/descfcn.py @@ -0,0 +1,484 @@ +# descfcn.py - describing function analysis +# +# RMM, 23 Jan 2021 +# +# This module adds functions for carrying out analysis of systems with +# memoryless nonlinear feedback functions using describing functions. +# + +"""The :mod:~control.descfcn` module contains function for performing +closed loop analysis of systems with memoryless nonlinearities using +describing function analysis. + +""" + +import math +import numpy as np +import matplotlib.pyplot as plt +import scipy +from warnings import warn + +from .freqplot import nyquist_plot + +__all__ = ['describing_function', 'describing_function_plot', + 'DescribingFunctionNonlinearity', 'friction_backlash_nonlinearity', + 'relay_hysteresis_nonlinearity', 'saturation_nonlinearity'] + +# Class for nonlinearities with a built-in describing function +class DescribingFunctionNonlinearity(): + """Base class for nonlinear systems with a describing function + + This class is intended to be used as a base class for nonlinear functions + that have an analytically defined describing function. Subclasses should + override the `__call__` and `describing_function` methods and (optionally) + the `_isstatic` method (should be `False` if `__call__` updates the + instance state). + + """ + def __init__(self): + """Initailize a describing function nonlinearity (optional)""" + pass + + def __call__(self, A): + """Evaluate the nonlinearity at a (scalar) input value""" + raise NotImplementedError( + "__call__() not implemented for this function (internal error)") + + def describing_function(self, A): + """Return the describing function for a nonlinearity + + This method is used to allow analytical representations of the + describing function for a nonlinearity. It turns the (complex) value + of the describing function for sinusoidal input of amplitude `A`. + + """ + raise NotImplementedError( + "describing function not implemented for this function") + + def _isstatic(self): + """Return True if the function has no internal state (memoryless) + + This internal function is used to optimize numerical computation of + the describing function. It can be set to `True` if the instance + maintains no internal memory of the instance state. Assumed False by + default. + + """ + return False + + # Utility function used to compute common describing functions + def _f(self, x): + return math.copysign(1, x) if abs(x) > 1 else \ + (math.asin(x) + x * math.sqrt(1 - x**2)) * 2 / math.pi + + +def describing_function( + F, A, num_points=100, zero_check=True, try_method=True): + """Numerical compute the describing function of a nonlinear function + + The describing function of a nonlinearity is given by magnitude and phase + of the first harmonic of the function when evaluated along a sinusoidal + input :math:`A \\sin \\omega t`. This function returns the magnitude and + phase of the describing function at amplitude :math:`A`. + + Parameters + ---------- + F : callable + The function F() should accept a scalar number as an argument and + return a scalar number. For compatibility with (static) nonlinear + input/output systems, the output can also return a 1D array with a + single element. + + If the function is an object with a method `describing_function` + then this method will be used to computing the describing function + instead of a nonlinear computation. Some common nonlinearities + use the :class:`~control.DescribingFunctionNonlinearity` class, + which provides this functionality. + + A : array_like + The amplitude(s) at which the describing function should be calculated. + + 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 + is raised. If zero_check is `False`, no check is made on the value of + the function at zero. + + try_method : bool, optional + If `True` (default), check the `F` argument to see if it is an object + with a `describing_function` method and use this to compute the + describing function. More information in the `describing_function` + method for the :class:`~control.DescribingFunctionNonlinearity` class. + + Returns + ------- + df : array of complex + The (complex) value of the describing function at the given amplitudes. + + Raises + ------ + TypeError + If A[i] < 0 or if A[i] = 0 and the function F(0) is non-zero. + + """ + # If there is an analytical solution, trying using that first + if try_method and hasattr(F, 'describing_function'): + try: + return np.vectorize(F.describing_function, otypes=[complex])(A) + except NotImplementedError: + # Drop through and do the numerical computation + pass + + # + # The describing function of a nonlinear function F() can be computed by + # evaluating the nonlinearity over a sinusoid. The Fourier series for a + # static nonlinear function evaluated on a sinusoid can be written as + # + # F(A\sin\omega t) = \sum_{k=1}^\infty M_k(A) \sin(k\omega t + \phi_k(A)) + # + # The describing function is given by the complex number + # + # N(A) = M_1(A) e^{j \phi_1(A)} / A + # + # To compute this, we compute F(A \sin\theta) for \theta between 0 and 2 + # \pi, use the identities + # + # \sin(\theta + \phi) = \sin\theta \cos\phi + \cos\theta \sin\phi + # \int_0^{2\pi} \sin^2 \theta d\theta = \pi + # \int_0^{2\pi} \cos^2 \theta d\theta = \pi + # + # and then integrate the product against \sin\theta and \cos\theta to obtain + # + # \int_0^{2\pi} F(A\sin\theta) \sin\theta d\theta = M_1 \pi \cos\phi + # \int_0^{2\pi} F(A\sin\theta) \cos\theta d\theta = M_1 \pi \sin\phi + # + # From these we can compute M1 and \phi. + # + + # Evaluate over a full range of angles (leave off endpoint a la DFT) + theta, dtheta = np.linspace( + 0, 2*np.pi, num_points, endpoint=False, retstep=True) + sin_theta = np.sin(theta) + cos_theta = np.cos(theta) + + # See if this is a static nonlinearity (assume not, just in case) + if not hasattr(F, '_isstatic') or not F._isstatic(): + # Initialize any internal state by going through an initial cycle + for x in np.atleast_1d(A).min() * sin_theta: + F(x) # ignore the result + + # Go through all of the amplitudes we were given + retdf = np.empty(np.shape(A), dtype=complex) + df = retdf # Access to the return array + df.shape = (-1, ) # as a 1D array + for i, a in enumerate(np.atleast_1d(A)): + # Make sure we got a valid argument + if a == 0: + # Check to make sure the function has zero output with zero input + if zero_check and np.squeeze(F(0.)) != 0: + raise ValueError("function must evaluate to zero at zero") + df[i] = 1. + continue + elif a < 0: + raise ValueError("cannot evaluate describing function for A < 0") + + # Save the scaling factor to make the formulas simpler + scale = dtheta / np.pi / a + + # Evaluate the function along a sinusoid + F_eval = np.array([F(x) for x in a*sin_theta]).squeeze() + + # Compute the prjections onto sine and cosine + df_real = (F_eval @ sin_theta) * scale # = M_1 \cos\phi / a + df_imag = (F_eval @ cos_theta) * scale # = M_1 \sin\phi / a + + df[i] = df_real + 1j * df_imag + + # Return the values in the same shape as they were requested + return retdf + + +def describing_function_plot( + H, F, A, omega=None, refine=True, label="%5.2g @ %-5.2g", **kwargs): + """Plot a Nyquist plot with a describing function for a nonlinear system. + + This function generates a Nyquist plot for a closed loop system consisting + of a linear system with a static nonlinear function in the feedback path. + + Parameters + ---------- + H : LTI system + Linear time-invariant (LTI) system (state space, transfer function, or + FRD) + F : static nonlinear function + A static nonlinearity, either a scalar function or a single-input, + single-output, static input/output system. + A : list + List of amplitudes to be used for the describing function plot. + omega : list, optional + List of frequencies to be used for the linear system Nyquist curve. + 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. + + Returns + ------- + intersections : 1D array of 2-tuples or None + A list of all amplitudes and frequencies in which :math:`H(j\\omega) + N(a) = -1`, where :math:`N(a)` is the describing function associated + with `F`, or `None` if there are no such points. Each pair represents + a potential limit cycle for the closed loop system with amplitude + given by the first value of the tuple and frequency given by the + second value. + + Example + ------- + >>> H_simple = ct.tf([8], [1, 2, 2, 1]) + >>> F_saturation = ct.descfcn.saturation_nonlinearity(1) + >>> amp = np.linspace(1, 4, 10) + >>> ct.describing_function_plot(H_simple, F_saturation, amp) + [(3.344008947853124, 1.414213099755523)] + + """ + # Start by drawing a Nyquist curve + count, contour = nyquist_plot( + H, omega, plot=True, return_contour=True, **kwargs) + H_omega, H_vals = contour.imag, H(contour) + + # Compute the describing function + df = describing_function(F, A) + N_vals = -1/df + + # Now add the describing function curve to the plot + plt.plot(N_vals.real, N_vals.imag) + + # Look for intersection points + intersections = [] + for i in range(N_vals.size - 1): + for j in range(H_vals.size - 1): + intersect = _find_intersection( + N_vals[i], N_vals[i+1], H_vals[j], H_vals[j+1]) + if intersect == None: + continue + + # Found an intersection, compute a and omega + s_amp, s_omega = intersect + a_guess = (1 - s_amp) * A[i] + s_amp * A[i+1] + omega_guess = (1 - s_omega) * H_omega[j] + s_omega * H_omega[j+1] + + # Refine the coarse estimate to get better intersection point + a_final, omega_final = a_guess, omega_guess + if refine: + # Refine the answer to get more accuracy + def _cost(x): + # If arguments are invalid, return a "large" value + # Note: imposing bounds messed up the optimization (?) + if x[0] < 0 or x[1] < 0: + return 1 + return abs(1 + H(1j * x[1]) * + describing_function(F, x[0]))**2 + res = scipy.optimize.minimize( + _cost, [a_guess, omega_guess]) + # bounds=[(A[i], A[i+1]), (H_omega[j], H_omega[j+1])]) + + if not res.success: + warn("not able to refine result; returning estimate") + else: + a_final, omega_final = res.x[0], res.x[1] + + # Add labels to the intersection points + if isinstance(label, str): + pos = H(1j * omega_final) + plt.text(pos.real, pos.imag, label % (a_final, omega_final)) + elif label is not None or label is not False: + raise ValueError("label must be formatting string or None") + + # Save the final estimate + intersections.append((a_final, omega_final)) + + return intersections + + +# Utility function to figure out whether two line segments intersection +def _find_intersection(L1a, L1b, L2a, L2b): + # Compute the tangents for the segments + L1t = L1b - L1a + L2t = L2b - L2a + + # Set up components of the solution: b = M s + b = L1a - L2a + detM = L1t.imag * L2t.real - L1t.real * L2t.imag + if abs(detM) < 1e-8: # TODO: fix magic number + return None + + # Solve for the intersection points on each line segment + s1 = (L2t.imag * b.real - L2t.real * b.imag) / detM + if s1 < 0 or s1 > 1: + return None + + s2 = (L1t.imag * b.real - L1t.real * b.imag) / detM + if s2 < 0 or s2 > 1: + return None + + # Debugging test + # np.testing.assert_almost_equal(L1a + s1 * L1t, L2a + s2 * L2t) + + # Intersection is within segments; return proportional distance + return (s1, s2) + + +# Saturation nonlinearity +class saturation_nonlinearity(DescribingFunctionNonlinearity): + """Create a saturation nonlinearity for use in describing function analysis + + This class creates a nonlinear function representing a saturation with + given upper and lower bounds, including the describing function for the + nonlinearity. The following call creates a nonlinear function suitable + for describing function analysis: + + F = saturation_nonlinearity(ub[, lb]) + + By default, the lower bound is set to the negative of the upper bound. + Asymmetric saturation functions can be created, but note that these + functions will not have zero bias and hence care must be taken in using + the nonlinearity for analysis. + + """ + def __init__(self, ub=1, lb=None): + # Create the describing function nonlinearity object + super(saturation_nonlinearity, self).__init__() + + # Process arguments + if lb == None: + # Only received one argument; assume symmetric around zero + lb, ub = -abs(ub), abs(ub) + + # Make sure the bounds are sensible + if lb > 0 or ub < 0 or lb + ub != 0: + warn("asymmetric saturation; ignoring non-zero bias term") + + self.lb = lb + self.ub = ub + + def __call__(self, x): + return np.clip(x, self.lb, self.ub) + + def _isstatic(self): + return True + + def describing_function(self, A): + # Check to make sure the amplitude is positive + if A < 0: + raise ValueError("cannot evaluate describing function for A < 0") + + if self.lb <= A and A <= self.ub: + return 1. + else: + alpha, beta = math.asin(self.ub/A), math.asin(-self.lb/A) + return (math.sin(alpha + beta) * math.cos(alpha - beta) + + (alpha + beta)) / math.pi + + +# Relay with hysteresis (FBS2e, Example 10.12) +class relay_hysteresis_nonlinearity(DescribingFunctionNonlinearity): + """Relay w/ hysteresis nonlinearity for use in describing function analysis + + This class creates a nonlinear function representing a a relay with + symmetric upper and lower bounds of magnitude `b` and a hysteretic region + of width `c` (using the notation from [FBS2e](https://fbsbook.org), + Example 10.12, including the describing function for the nonlinearity. + The following call creates a nonlinear function suitable for describing + function analysis: + + F = relay_hysteresis_nonlinearity(b, c) + + The output of this function is `b` if `x > c` and `-b` if `x < -c`. For + `-c <= x <= c`, the value depends on the branch of the hysteresis loop (as + illustrated in Figure 10.20 of FBS2e). + + """ + def __init__(self, b, c): + # Create the describing function nonlinearity object + super(relay_hysteresis_nonlinearity, self).__init__() + + # Initialize the state to bottom branch + self.branch = -1 # lower branch + self.b = b # relay output value + self.c = c # size of hysteresis region + + def __call__(self, x): + if x > self.c: + y = self.b + self.branch = 1 + elif x < -self.c: + y = -self.b + self.branch = -1 + elif self.branch == -1: + y = -self.b + elif self.branch == 1: + y = self.b + return y + + def _isstatic(self): + return False + + def describing_function(self, A): + # Check to make sure the amplitude is positive + if A < 0: + raise ValueError("cannot evaluate describing function for A < 0") + + if A < self.c: + return np.nan + + df_real = 4 * self.b * math.sqrt(1 - (self.c/A)**2) / (A * math.pi) + df_imag = -4 * self.b * self.c / (math.pi * A**2) + return df_real + 1j * df_imag + + +# Friction-dominated backlash nonlinearity (#48 in Gelb and Vander Velde, 1968) +class friction_backlash_nonlinearity(DescribingFunctionNonlinearity): + """Backlash nonlinearity for use in describing function analysis + + This class creates a nonlinear function representing a friction-dominated + backlash nonlinearity ,including the describing function for the + nonlinearity. The following call creates a nonlinear function suitable + for describing function analysis: + + F = friction_backlash_nonlinearity(b) + + This function maintains an internal state representing the 'center' of a + mechanism with backlash. If the new input is within `b/2` of the current + center, the output is unchanged. Otherwise, the output is given by the + input shifted by `b/2`. + + """ + + def __init__(self, b): + # Create the describing function nonlinearity object + super(friction_backlash_nonlinearity, self).__init__() + + self.b = b # backlash distance + self.center = 0 # current center position + + def __call__(self, x): + # If we are outside the backlash, move and shift the center + if x - self.center > self.b/2: + self.center = x - self.b/2 + elif x - self.center < -self.b/2: + self.center = x + self.b/2 + return self.center + + def _isstatic(self): + return False + + def describing_function(self, A): + # Check to make sure the amplitude is positive + if A < 0: + raise ValueError("cannot evaluate describing function for A < 0") + + if A <= self.b/2: + return 0 + + df_real = (1 + self._f(1 - self.b/A)) / 2 + df_imag = -(2 * self.b/A - (self.b/A)**2) / math.pi + return df_real + 1j * df_imag diff --git a/control/dtime.py b/control/dtime.py index 89f17c4af..8c0fe53e9 100644 --- a/control/dtime.py +++ b/control/dtime.py @@ -47,27 +47,25 @@ """ from .lti import isctime -from .statesp import StateSpace, _convertToStateSpace +from .statesp import StateSpace __all__ = ['sample_system', 'c2d'] # Sample a continuous time system def sample_system(sysc, Ts, method='zoh', alpha=None, prewarp_frequency=None): - """Convert a continuous time system to discrete time - - Creates a discrete time system from a continuous time system by - sampling. Multiple methods of conversion are supported. + """ + Convert a continuous time system to discrete time by sampling Parameters ---------- - sysc : linsys + sysc : LTI (StateSpace or TransferFunction) Continuous time system to be converted - Ts : real + Ts : real > 0 Sampling period method : string - Method to use for conversion: 'matched', 'tustin', 'zoh' (default) + Method to use for conversion, e.g. 'bilinear', 'zoh' (default) - prewarp_frequency : float within [0, infinity) + prewarp_frequency : real within [0, infinity) The frequency [rad/s] at which to match with the input continuous- time system's magnitude and phase @@ -78,13 +76,13 @@ def sample_system(sysc, Ts, method='zoh', alpha=None, prewarp_frequency=None): Notes ----- - See `TransferFunction.sample` and `StateSpace.sample` for + See :meth:`StateSpace.sample` or :meth:`TransferFunction.sample`` for further details. Examples -------- >>> sysc = TransferFunction([1], [1, 2, 1]) - >>> sysd = sample_system(sysc, 1, method='matched') + >>> sysd = sample_system(sysc, 1, method='bilinear') """ # Make sure we have a continuous time system @@ -95,35 +93,39 @@ def sample_system(sysc, Ts, method='zoh', alpha=None, prewarp_frequency=None): def c2d(sysc, Ts, method='zoh', prewarp_frequency=None): - ''' - Return a discrete-time system + """ + Convert a continuous time system to discrete time by sampling Parameters ---------- - sysc: LTI (StateSpace or TransferFunction), continuous - System to be converted + sysc : LTI (StateSpace or TransferFunction) + Continuous time system to be converted + Ts : real > 0 + Sampling period + method : string + Method to use for conversion, e.g. 'bilinear', 'zoh' (default) - Ts: number - Sample time for the conversion + prewarp_frequency : real within [0, infinity) + The frequency [rad/s] at which to match with the input continuous- + time system's magnitude and phase - method: string, optional - Method to be applied, - 'zoh' Zero-order hold on the inputs (default) - 'foh' First-order hold, currently not implemented - 'impulse' Impulse-invariant discretization, currently not implemented - 'tustin' Bilinear (Tustin) approximation, only SISO - 'matched' Matched pole-zero method, only SISO + Returns + ------- + sysd : linsys + Discrete time system, with sampling rate Ts + + Notes + ----- + See :meth:`StateSpace.sample` or :meth:`TransferFunction.sample`` for + further details. - prewarp_frequency : float within [0, infinity) - The frequency [rad/s] at which to match with the input continuous- - time system's magnitude and phase + Examples + -------- + >>> sysc = TransferFunction([1], [1, 2, 1]) + >>> sysd = sample_system(sysc, 1, method='bilinear') + """ - ''' # Call the sample_system() function to do the work sysd = sample_system(sysc, Ts, method, prewarp_frequency) - # TODO: is this check needed? If sysc is StateSpace, sysd is too? - if isinstance(sysc, StateSpace) and not isinstance(sysd, StateSpace): - return _convertToStateSpace(sysd) # pragma: no cover - return sysd diff --git a/control/flatsys/__init__.py b/control/flatsys/__init__.py index 9ff1e2337..0926fa81a 100644 --- a/control/flatsys/__init__.py +++ b/control/flatsys/__init__.py @@ -53,6 +53,7 @@ # Basis function families from .basis import BasisFamily from .poly import PolyFamily +from .bezier import BezierFamily # Classes from .systraj import SystemTrajectory diff --git a/control/flatsys/basis.py b/control/flatsys/basis.py index 83ea89cbd..7592b79a2 100644 --- a/control/flatsys/basis.py +++ b/control/flatsys/basis.py @@ -51,3 +51,10 @@ class BasisFamily: def __init__(self, N): """Create a basis family of order N.""" self.N = N # save number of basis functions + + def __call__(self, i, t): + """Evaluate the ith basis function at a point in time""" + return self.eval_deriv(i, 0, t) + + def eval_deriv(self, i, j, t): + raise NotImplementedError("Internal error; improper basis functions") diff --git a/control/flatsys/bezier.py b/control/flatsys/bezier.py new file mode 100644 index 000000000..5d0d551de --- /dev/null +++ b/control/flatsys/bezier.py @@ -0,0 +1,83 @@ +# bezier.m - 1D Bezier curve basis functions +# RMM, 24 Feb 2021 +# +# This class implements a set of basis functions based on Bezier curves: +# +# \phi_i(t) = \sum_{i=0}^n {n \choose i} (T - t)^{n-i} t^i +# + +# Copyright (c) 2012 by California Institute of Technology +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# 3. Neither the name of the California Institute of Technology nor +# the names of its contributors may be used to endorse or promote +# products derived from this software without specific prior +# written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL CALTECH +# OR THE CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF +# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT +# OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. + +import numpy as np +from scipy.special import binom, factorial +from .basis import BasisFamily + +class BezierFamily(BasisFamily): + r"""Polynomial basis functions. + + This class represents the family of polynomials of the form + + .. math:: + \phi_i(t) = \sum_{i=0}^n {n \choose i} + \left( \frac{t}{T_\text{f}} - t \right)^{n-i} + \left( \frac{t}{T_f} \right)^i + + """ + def __init__(self, N, T=1): + """Create a polynomial basis of order N.""" + self.N = N # save number of basis functions + self.T = T # save end of time interval + + # Compute the kth derivative of the ith basis function at time t + def eval_deriv(self, i, k, t): + """Evaluate the kth derivative of the ith basis function at time t.""" + if i >= self.N: + raise ValueError("Basis function index too high") + elif k >= self.N: + # Higher order derivatives are zero + return np.zeros(t.shape) + + # Compute the variables used in Bezier curve formulas + n = self.N - 1 + u = t/self.T + + if k == 0: + # No derivative => avoid expansion for speed + return binom(n, i) * u**i * (1-u)**(n-i) + + # Return the kth derivative of the ith Bezier basis function + return binom(n, i) * sum([ + (-1)**(j-i) * + binom(n-i, j-i) * factorial(j)/factorial(j-k) * np.power(u, j-k) + for j in range(max(i, k), n+1) + ]) diff --git a/control/flatsys/flatsys.py b/control/flatsys/flatsys.py index a5dec2950..1905c4cb8 100644 --- a/control/flatsys/flatsys.py +++ b/control/flatsys/flatsys.py @@ -38,10 +38,15 @@ # OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF # SUCH DAMAGE. +import itertools import numpy as np +import scipy as sp +import scipy.optimize +import warnings from .poly import PolyFamily from .systraj import SystemTrajectory from ..iosys import NonlinearIOSystem +from ..timeresp import _check_convert_array # Flat system class (for use as a base class) @@ -150,6 +155,8 @@ def __init__(self, if forward is not None: self.forward = forward if reverse is not None: self.reverse = reverse + # Save the length of the flat flag + def forward(self, x, u, params={}): """Compute the flat flag given the states and input. @@ -176,7 +183,7 @@ def forward(self, x, u, params={}): output and its first :math:`q_i` derivatives. """ - pass + raise NotImplementedError("internal error; forward method not defined") def reverse(self, zflag, params={}): """Compute the states and input given the flat flag. @@ -200,7 +207,7 @@ def reverse(self, zflag, params={}): The input to the system corresponding to the flat flag. """ - pass + raise NotImplementedError("internal error; reverse method not defined") def _flat_updfcn(self, t, x, u, params={}): # TODO: implement state space update using flat coordinates @@ -212,8 +219,33 @@ def _flat_outfcn(self, t, x, u, params={}): return np.array(zflag[:][0]) -# Solve a point to point trajectory generation problem for a linear system -def point_to_point(sys, x0, u0, xf, uf, Tf, T0=0, basis=None, cost=None): +# Utility function to compute flag matrix given a basis +def _basis_flag_matrix(sys, basis, flag, t, params={}): + """Compute the matrix of basis functions and their derivatives + + This function computes the matrix ``M`` that is used to solve for the + coefficients of the basis functions given the state and input. Each + column of the matrix corresponds to a basis function and each row is a + derivative, with the derivatives (flag) for each output stacked on top + of each other. + + """ + flagshape = [len(f) for f in flag] + M = np.zeros((sum(flagshape), basis.N * sys.ninputs)) + flag_off = 0 + coeff_off = 0 + for i, flag_len in enumerate(flagshape): + for j, k in itertools.product(range(basis.N), range(flag_len)): + M[flag_off + k, coeff_off + j] = basis.eval_deriv(j, k, t) + flag_off += flag_len + coeff_off += basis.N + return M + + +# Solve a point to point trajectory generation problem for a flat system +def point_to_point( + sys, timepts, x0=0, u0=0, xf=0, uf=0, T0=0, basis=None, cost=None, + constraints=None, initial_guess=None, minimize_kwargs={}, **kwargs): """Compute trajectory between an initial and final conditions. Compute a feasible trajectory for a differentially flat system between an @@ -223,57 +255,109 @@ def point_to_point(sys, x0, u0, xf, uf, Tf, T0=0, basis=None, cost=None): ---------- flatsys : FlatSystem object Description of the differentially flat system. This object must - define a function flatsys.forward() that takes the system state and - produceds the flag of flat outputs and a system flatsys.reverse() + define a function `flatsys.forward()` that takes the system state and + produceds the flag of flat outputs and a system `flatsys.reverse()` that takes the flag of the flat output and prodes the state and input. + timepts : float or 1D array_like + The list of points for evaluating cost and constraints, as well as + the time horizon. If given as a float, indicates the final time for + the trajectory (corresponding to xf) + x0, u0, xf, uf : 1D arrays Define the desired initial and final conditions for the system. If any of the values are given as None, they are replaced by a vector of zeros of the appropriate dimension. - Tf : float - The final time for the trajectory (corresponding to xf) - - T0 : float (optional) + T0 : float, optional The initial time for the trajectory (corresponding to x0). If not specified, its value is taken to be zero. - basis : BasisFamily object (optional) + basis : :class:`~control.flatsys.BasisFamily` object, optional The basis functions to use for generating the trajectory. If not - specified, the PolyFamily basis family will be used, with the minimal - number of elements required to find a feasible trajectory (twice - the number of system states) + specified, the :class:`~control.flatsys.PolyFamily` basis family + will be used, with the minimal number of elements required to find a + feasible trajectory (twice the number of system states) + + cost : callable + Function that returns the integral cost given the current state + and input. Called as `cost(x, u)`. + + constraints : list of tuples, optional + List of constraints that should hold at each point in the time vector. + Each element of the list should consist of a tuple with first element + given by :class:`scipy.optimize.LinearConstraint` or + :class:`scipy.optimize.NonlinearConstraint` and the remaining + elements of the tuple are the arguments that would be passed to those + functions. The following tuples are supported: + + * (LinearConstraint, A, lb, ub): The matrix A is multiplied by stacked + vector of the state and input at each point on the trajectory for + comparison against the upper and lower bounds. + + * (NonlinearConstraint, fun, lb, ub): a user-specific constraint + function `fun(x, u)` is called at each point along the trajectory + and compared against the upper and lower bounds. + + The constraints are applied at each time point along the trajectory. + + minimize_kwargs : str, optional + Pass additional keywords to :func:`scipy.optimize.minimize`. Returns ------- - traj : SystemTrajectory object + traj : :class:`~control.flatsys.SystemTrajectory` object The system trajectory is returned as an object that implements the - eval() function, we can be used to compute the value of the state + `eval()` function, we can be used to compute the value of the state and input and a given time t. + Notes + ----- + Additional keyword parameters can be used to fine tune the behavior of + the underlying optimization function. See `minimize_*` keywords in + :func:`OptimalControlProblem` for more information. + """ # # Make sure the problem is one that we can handle # - # TODO: put in tests for flat system input - # TODO: process initial and final conditions to allow x0 or (x0, u0) - if x0 is None: x0 = np.zeros(sys.nstates) - if u0 is None: u0 = np.zeros(sys.ninputs) - if xf is None: xf = np.zeros(sys.nstates) - if uf is None: uf = np.zeros(sys.ninputs) + x0 = _check_convert_array(x0, [(sys.nstates,), (sys.nstates, 1)], + 'Initial state: ', squeeze=True) + u0 = _check_convert_array(u0, [(sys.ninputs,), (sys.ninputs, 1)], + 'Initial input: ', squeeze=True) + xf = _check_convert_array(xf, [(sys.nstates,), (sys.nstates, 1)], + 'Final state: ', squeeze=True) + uf = _check_convert_array(uf, [(sys.ninputs,), (sys.ninputs, 1)], + 'Final input: ', squeeze=True) + + # Process final time + timepts = np.atleast_1d(timepts) + Tf = timepts[-1] + T0 = timepts[0] if len(timepts) > 1 else T0 + + # Process keyword arguments + minimize_kwargs['method'] = kwargs.pop('minimize_method', None) + minimize_kwargs['options'] = kwargs.pop('minimize_options', {}) + if kwargs: + raise TypeError("unrecognized keywords: ", str(kwargs)) # # Determine the basis function set to use and make sure it is big enough # # If no basis set was specified, use a polynomial basis (poor choice...) - if (basis is None): basis = PolyFamily(2*sys.nstates, Tf) + if basis is None: + basis = PolyFamily(2 * (sys.nstates + sys.ninputs)) # Make sure we have enough basis functions to solve the problem - if (basis.N * sys.ninputs < 2 * (sys.nstates + sys.ninputs)): + if basis.N * sys.ninputs < 2 * (sys.nstates + sys.ninputs): raise ValueError("basis set is too small") + elif (cost is not None or constraints is not None) and \ + basis.N * sys.ninputs == 2 * (sys.nstates + sys.ninputs): + warnings.warn("minimal basis specified; optimization not possible") + cost = None + constraints = None # # Map the initial and final conditions to flat output conditions @@ -281,8 +365,7 @@ def point_to_point(sys, x0, u0, xf, uf, Tf, T0=0, basis=None, cost=None): # We need to compute the output "flag": [z(t), z'(t), z''(t), ...] # and then evaluate this at the initial and final condition. # - # TODO: should be able to represent flag variables as 1D arrays - # TODO: need inputs to fully define the flag + zflag_T0 = sys.forward(x0, u0) zflag_Tf = sys.forward(xf, uf) @@ -293,54 +376,139 @@ def point_to_point(sys, x0, u0, xf, uf, Tf, T0=0, basis=None, cost=None): # essentially amounts to evaluating the basis functions and their # derivatives at the initial and final conditions. - # Figure out the size of the problem we are solving - flag_tot = np.sum([len(zflag_T0[i]) for i in range(sys.ninputs)]) - - # Start by creating an empty matrix that we can fill up - # TODO: allow a different number of basis elements for each flat output - M = np.zeros((2 * flag_tot, basis.N * sys.ninputs)) - - # Now fill in the rows for the initial and final states - flag_off = 0 - coeff_off = 0 - for i in range(sys.ninputs): - flag_len = len(zflag_T0[i]) - for j in range(basis.N): - for k in range(flag_len): - M[flag_off + k, coeff_off + j] = basis.eval_deriv(j, k, T0) - M[flag_tot + flag_off + k, coeff_off + j] = \ - basis.eval_deriv(j, k, Tf) - flag_off += flag_len - coeff_off += basis.N - - # Create an empty matrix that we can fill up - Z = np.zeros(2 * flag_tot) + # Compute the flags for the initial and final states + M_T0 = _basis_flag_matrix(sys, basis, zflag_T0, T0) + M_Tf = _basis_flag_matrix(sys, basis, zflag_Tf, Tf) - # Compute the flag vector to use for the right hand side by - # stacking up the flags for each input - # TODO: make this more pythonic - flag_off = 0 - for i in range(sys.ninputs): - flag_len = len(zflag_T0[i]) - for j in range(flag_len): - Z[flag_off + j] = zflag_T0[i][j] - Z[flag_tot + flag_off + j] = zflag_Tf[i][j] - flag_off += flag_len + # Stack the initial and final matrix/flag for the point to point problem + M = np.vstack([M_T0, M_Tf]) + Z = np.hstack([np.hstack(zflag_T0), np.hstack(zflag_Tf)]) # # Solve for the coefficients of the flat outputs # # At this point, we need to solve the equation M alpha = zflag, where M # is the matrix constrains for initial and final conditions and zflag = - # [zflag_T0; zflag_tf]. Since everything is linear, just compute the - # least squares solution for now. + # [zflag_T0; zflag_tf]. # - # TODO: need to allow cost and constraints... + # If there are no constraints, then we just need to solve a linear + # system of equations => use least squares. Otherwise, we have a + # nonlinear optimal control problem with equality constraints => use + # scipy.optimize.minimize(). + # + + # Start by solving the least squares problem alpha, residuals, rank, s = np.linalg.lstsq(M, Z, rcond=None) + if cost is not None or constraints is not None: + # Search over the null space to minimize cost/satisfy constraints + N = sp.linalg.null_space(M) + + # Define a function to evaluate the cost along a trajectory + def traj_cost(null_coeffs): + # Add this to the existing solution + coeffs = alpha + N @ null_coeffs + + # Evaluate the costs at the listed time points + costval = 0 + for t in timepts: + M_t = _basis_flag_matrix(sys, basis, zflag_T0, t) + + # Compute flag at this time point + zflag = (M_t @ coeffs).reshape(sys.ninputs, -1) + + # Find states and inputs at the time points + x, u = sys.reverse(zflag) + + # Evaluate the cost at this time point + costval += cost(x, u) + return costval + + # If no cost given, override with magnitude of the coefficients + if cost is None: + traj_cost = lambda coeffs: coeffs @ coeffs + + # Process the constraints we were given + traj_constraints = constraints + if constraints is None: + traj_constraints = [] + elif isinstance(constraints, tuple): + # TODO: Check to make sure this is really a constraint + traj_constraints = [constraints] + elif not isinstance(constraints, list): + raise TypeError("trajectory constraints must be a list") + + # Process constraints + minimize_constraints = [] + if len(traj_constraints) > 0: + # Set up a nonlinear function to evaluate the constraints + def traj_const(null_coeffs): + # Add this to the existing solution + coeffs = alpha + N @ null_coeffs + + # Evaluate the constraints at the listed time points + values = [] + for i, t in enumerate(timepts): + # Calculate the states and inputs for the flat output + M_t = _basis_flag_matrix(sys, basis, zflag_T0, t) + + # Compute flag at this time point + zflag = (M_t @ coeffs).reshape(sys.ninputs, -1) + + # Find states and inputs at the time points + states, inputs = sys.reverse(zflag) + + # Evaluate the constraint function along the trajectory + for type, fun, lb, ub in traj_constraints: + if type == sp.optimize.LinearConstraint: + # `fun` is A matrix associated with polytope... + values.append( + np.dot(fun, np.hstack([states, inputs]))) + elif type == sp.optimize.NonlinearConstraint: + values.append(fun(states, inputs)) + else: + raise TypeError( + "unknown constraint type %s" % type) + return np.array(values).flatten() + + # Store upper and lower bounds + const_lb, const_ub = [], [] + for t in timepts: + for type, fun, lb, ub in traj_constraints: + const_lb.append(lb) + const_ub.append(ub) + const_lb = np.array(const_lb).flatten() + const_ub = np.array(const_ub).flatten() + + # Store the constraint as a nonlinear constraint + minimize_constraints = [sp.optimize.NonlinearConstraint( + traj_const, const_lb, const_ub)] + + # Add initial and terminal constraints + # minimize_constraints += [sp.optimize.LinearConstraint(M, Z, Z)] + + # Process the initial condition + if initial_guess is None: + initial_guess = np.zeros(M.shape[1] - M.shape[0]) + else: + raise NotImplementedError("Initial guess not yet implemented.") + + # Find the optimal solution + res = sp.optimize.minimize( + traj_cost, initial_guess, constraints=minimize_constraints, + **minimize_kwargs) + if res.success: + alpha += N @ res.x + else: + raise RuntimeError( + "Unable to solve optimal control problem\n" + + "scipy.optimize.minimize returned " + res.message) + # # Transform the trajectory from flat outputs to states and inputs # + + # Createa trajectory object to store the resul systraj = SystemTrajectory(sys, basis) # Store the flag lengths and coefficients diff --git a/control/flatsys/linflat.py b/control/flatsys/linflat.py index 41a68537a..6e74ed581 100644 --- a/control/flatsys/linflat.py +++ b/control/flatsys/linflat.py @@ -97,13 +97,14 @@ def __init__(self, linsys, inputs=None, outputs=None, states=None, name=name) # Find the transformation to chain of integrators form + # Note: store all array as ndarray, not matrix zsys, Tr = control.reachable_form(linsys) - Tr = Tr[::-1, ::] # flip rows + Tr = np.array(Tr[::-1, ::]) # flip rows # Extract the information that we need - self.F = zsys.A[0, ::-1] # input function coeffs - self.T = Tr # state space transformation - self.Tinv = np.linalg.inv(Tr) # compute inverse once + self.F = np.array(zsys.A[0, ::-1]) # input function coeffs + self.T = Tr # state space transformation + self.Tinv = np.linalg.inv(Tr) # compute inverse once # Compute the flat output variable z = C x Cfz = np.zeros(np.shape(linsys.C)); Cfz[0, 0] = 1 diff --git a/control/frdata.py b/control/frdata.py index 8ca9dfd9d..c620984f6 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -48,9 +48,10 @@ from warnings import warn import numpy as np from numpy import angle, array, empty, ones, \ - real, imag, absolute, eye, linalg, where, dot + real, imag, absolute, eye, linalg, where, dot, sort from scipy.interpolate import splprep, splev -from .lti import LTI +from .lti import LTI, _process_frequency_response +from . import config __all__ = ['FrequencyResponseData', 'FRD', 'frd'] @@ -100,6 +101,7 @@ def __init__(self, *args, **kwargs): object, other than an FRD, call FRD(sys, omega) """ + # TODO: discrete-time FRD systems? smooth = kwargs.get('smooth', False) if len(args) == 2: @@ -107,16 +109,14 @@ def __init__(self, *args, **kwargs): # not an FRD, but still a system, second argument should be # the frequency range otherlti = args[0] - self.omega = array(args[1], dtype=float) - self.omega.sort() - numfreq = len(self.omega) - + self.omega = sort(np.asarray(args[1], dtype=float)) # calculate frequency response at my points - self.fresp = empty( - (otherlti.outputs, otherlti.inputs, numfreq), - dtype=complex) - for k, w in enumerate(self.omega): - self.fresp[:, :, k] = otherlti._evalfr(w) + if otherlti.isctime(): + s = 1j * self.omega + self.fresp = otherlti(s, squeeze=False) + else: + z = np.exp(1j * self.omega * otherlti.dt) + self.fresp = otherlti(z, squeeze=False) else: # The user provided a response and a freq vector @@ -141,7 +141,7 @@ def __init__(self, *args, **kwargs): self.omega = args[0].omega self.fresp = args[0].fresp else: - raise ValueError("Needs 1 or 2 arguments; receivd %i." % len(args)) + raise ValueError("Needs 1 or 2 arguments; received %i." % len(args)) # create interpolation functions if smooth: @@ -160,20 +160,20 @@ def __init__(self, *args, **kwargs): def __str__(self): """String representation of the transfer function.""" - mimo = self.inputs > 1 or self.outputs > 1 + mimo = self.ninputs > 1 or self.noutputs > 1 outstr = ['Frequency response data'] - mt, pt, wt = self.freqresp(self.omega) - for i in range(self.inputs): - for j in range(self.outputs): + for i in range(self.ninputs): + for j in range(self.noutputs): if mimo: outstr.append("Input %i to output %i:" % (i + 1, j + 1)) outstr.append('Freq [rad/s] Response') outstr.append('------------ ---------------------') outstr.extend( - ['%12.3f %10.4g%+10.4gj' % (w, m, p) - for m, p, w in zip(real(self.fresp[j, i, :]), - imag(self.fresp[j, i, :]), wt)]) + ['%12.3f %10.4g%+10.4gj' % (w, re, im) + for w, re, im in zip(self.omega, + real(self.fresp[j, i, :]), + imag(self.fresp[j, i, :]))]) return '\n'.join(outstr) @@ -203,15 +203,15 @@ def __add__(self, other): # Convert the second argument to a frequency response function. # or re-base the frd to the current omega (if needed) - other = _convertToFRD(other, omega=self.omega) + other = _convert_to_FRD(other, omega=self.omega) # Check that the input-output sizes are consistent. - if self.inputs != other.inputs: + if self.ninputs != other.ninputs: raise ValueError("The first summand has %i input(s), but the \ -second has %i." % (self.inputs, other.inputs)) - if self.outputs != other.outputs: +second has %i." % (self.ninputs, other.ninputs)) + if self.noutputs != other.noutputs: raise ValueError("The first summand has %i output(s), but the \ -second has %i." % (self.outputs, other.outputs)) +second has %i." % (self.noutputs, other.noutputs)) return FRD(self.fresp + other.fresp, other.omega) @@ -238,17 +238,17 @@ def __mul__(self, other): return FRD(self.fresp * other, self.omega, smooth=(self.ifunc is not None)) else: - other = _convertToFRD(other, omega=self.omega) + other = _convert_to_FRD(other, omega=self.omega) # Check that the input-output sizes are consistent. - if self.inputs != other.outputs: + if self.ninputs != other.noutputs: raise ValueError( "H = G1*G2: input-output size mismatch: " "G1 has %i input(s), G2 has %i output(s)." % - (self.inputs, other.outputs)) + (self.ninputs, other.noutputs)) - inputs = other.inputs - outputs = self.outputs + inputs = other.ninputs + outputs = self.noutputs fresp = empty((outputs, inputs, len(self.omega)), dtype=self.fresp.dtype) for i in range(len(self.omega)): @@ -265,17 +265,17 @@ def __rmul__(self, other): return FRD(self.fresp * other, self.omega, smooth=(self.ifunc is not None)) else: - other = _convertToFRD(other, omega=self.omega) + other = _convert_to_FRD(other, omega=self.omega) # Check that the input-output sizes are consistent. - if self.outputs != other.inputs: + if self.noutputs != other.ninputs: raise ValueError( "H = G1*G2: input-output size mismatch: " "G1 has %i input(s), G2 has %i output(s)." % - (other.inputs, self.outputs)) + (other.ninputs, self.noutputs)) - inputs = self.inputs - outputs = other.outputs + inputs = self.ninputs + outputs = other.noutputs fresp = empty((outputs, inputs, len(self.omega)), dtype=self.fresp.dtype) @@ -293,10 +293,10 @@ def __truediv__(self, other): return FRD(self.fresp * (1/other), self.omega, smooth=(self.ifunc is not None)) else: - other = _convertToFRD(other, omega=self.omega) + other = _convert_to_FRD(other, omega=self.omega) - if (self.inputs > 1 or self.outputs > 1 or - other.inputs > 1 or other.outputs > 1): + if (self.ninputs > 1 or self.noutputs > 1 or + other.ninputs > 1 or other.noutputs > 1): raise NotImplementedError( "FRD.__truediv__ is currently only implemented for SISO " "systems.") @@ -316,10 +316,10 @@ def __rtruediv__(self, other): return FRD(other / self.fresp, self.omega, smooth=(self.ifunc is not None)) else: - other = _convertToFRD(other, omega=self.omega) + other = _convert_to_FRD(other, omega=self.omega) - if (self.inputs > 1 or self.outputs > 1 or - other.inputs > 1 or other.outputs > 1): + if (self.ninputs > 1 or self.noutputs > 1 or + other.ninputs > 1 or other.noutputs > 1): raise NotImplementedError( "FRD.__rtruediv__ is currently only implemented for " "SISO systems.") @@ -342,120 +342,147 @@ def __pow__(self, other): return (FRD(ones(self.fresp.shape), self.omega) / self) * \ (self**(other+1)) - def evalfr(self, omega): - """Evaluate a transfer function at a single angular frequency. - - self._evalfr(omega) returns the value of the frequency response - at frequency omega. - - Note that a "normal" FRD only returns values for which there is an - entry in the omega vector. An interpolating FRD can return - intermediate values. - - """ - warn("FRD.evalfr(omega) will be deprecated in a future release " - "of python-control; use sys.eval(omega) instead", - PendingDeprecationWarning) # pragma: no coverage - return self._evalfr(omega) - # Define the `eval` function to evaluate an FRD at a given (real) # frequency. Note that we choose to use `eval` instead of `evalfr` to # avoid confusion with :func:`evalfr`, which takes a complex number as its # argument. Similarly, we don't use `__call__` to avoid confusion between # G(s) for a transfer function and G(omega) for an FRD object. - def eval(self, omega): - """Evaluate a transfer function at a single angular frequency. - - self.evalfr(omega) returns the value of the frequency response - at frequency omega. + # update Sawyer B. Fuller 2020.08.14: __call__ added to provide a uniform + # interface to systems in general and the lti.frequency_response method + def eval(self, omega, squeeze=None): + """Evaluate a transfer function at angular frequency omega. Note that a "normal" FRD only returns values for which there is an entry in the omega vector. An interpolating FRD can return intermediate values. + Parameters + ---------- + omega : float or 1D array_like + Frequencies in radians per second + squeeze : bool, optional + If squeeze=True, remove single-dimensional entries from the shape + of the output even if the system is not SISO. If squeeze=False, + keep all indices (output, input and, if omega is array_like, + frequency) even if the system is SISO. The default value can be + set using config.defaults['control.squeeze_frequency_response']. + + Returns + ------- + fresp : complex ndarray + The frequency response of the system. If the system is SISO and + squeeze is not True, the shape of the array matches the shape of + omega. If the system is not SISO or squeeze is False, the first + two dimensions of the array are indices for the output and input + and the remaining dimensions match omega. If ``squeeze`` is True + then single-dimensional axes are removed. + """ - return self._evalfr(omega) - - # Internal function to evaluate the frequency responses - def _evalfr(self, omega): - """Evaluate a transfer function at a single angular frequency.""" - # Preallocate the output. - if getattr(omega, '__iter__', False): - out = empty((self.outputs, self.inputs, len(omega)), dtype=complex) - else: - out = empty((self.outputs, self.inputs), dtype=complex) + omega_array = np.array(omega, ndmin=1) # array-like version of omega + + # Make sure that we are operating on a simple list + if len(omega_array.shape) > 1: + raise ValueError("input list must be 1D") + + # Make sure that frequencies are all real-valued + if any(omega_array.imag > 0): + raise ValueError("FRD.eval can only accept real-valued omega") if self.ifunc is None: - try: - out = self.fresp[:, :, where(self.omega == omega)[0][0]] - except Exception: + elements = np.isin(self.omega, omega) # binary array + if sum(elements) < len(omega_array): raise ValueError( - "Frequency %f not in frequency list, try an interpolating" - " FRD if you want additional points" % omega) - else: - if getattr(omega, '__iter__', False): - for i in range(self.outputs): - for j in range(self.inputs): - for k, w in enumerate(omega): - frraw = splev(w, self.ifunc[i, j], der=0) - out[i, j, k] = frraw[0] + 1.0j * frraw[1] + "not all frequencies omega are in frequency list of FRD " + "system. Try an interpolating FRD for additional points.") else: - for i in range(self.outputs): - for j in range(self.inputs): - frraw = splev(omega, self.ifunc[i, j], der=0) - out[i, j] = frraw[0] + 1.0j * frraw[1] + out = self.fresp[:, :, elements] + else: + out = empty((self.noutputs, self.ninputs, len(omega_array)), + dtype=complex) + for i in range(self.noutputs): + for j in range(self.ninputs): + for k, w in enumerate(omega_array): + frraw = splev(w, self.ifunc[i, j], der=0) + out[i, j, k] = frraw[0] + 1.0j * frraw[1] - return out + return _process_frequency_response(self, omega, out, squeeze=squeeze) - # Method for generating the frequency response of the system - def freqresp(self, omega): - """Evaluate the frequency response at a list of angular frequencies. + def __call__(self, s, squeeze=None): + """Evaluate system's transfer function at complex frequencies. + + Returns the complex frequency response `sys(s)` of system `sys` with + `m = sys.ninputs` number of inputs and `p = sys.noutputs` number of + outputs. - Reports the value of the magnitude, phase, and angular frequency of - the requency response evaluated at omega, where omega is a list of - angular frequencies, and is a sorted version of the input omega. + To evaluate at a frequency omega in radians per second, enter + ``s = omega * 1j`` or use ``sys.eval(omega)`` Parameters ---------- - omega : array_like - A list of frequencies in radians/sec at which the system should be - evaluated. The list can be either a python list or a numpy array - and will be sorted before evaluation. + s : complex scalar or 1D array_like + Complex frequencies + squeeze : bool, optional (default=True) + If squeeze=True, remove single-dimensional entries from the shape + of the output even if the system is not SISO. If squeeze=False, + keep all indices (output, input and, if omega is array_like, + frequency) even if the system is SISO. The default value can be + set using config.defaults['control.squeeze_frequency_response']. Returns ------- - mag : (self.outputs, self.inputs, len(omega)) ndarray - The magnitude (absolute value, not dB or log10) of the system - frequency response. - phase : (self.outputs, self.inputs, len(omega)) ndarray - The wrapped phase in radians of the system frequency response. - omega : ndarray or list or tuple - The list of sorted frequencies at which the response was - evaluated. + fresp : complex ndarray + The frequency response of the system. If the system is SISO and + squeeze is not True, the shape of the array matches the shape of + omega. If the system is not SISO or squeeze is False, the first + two dimensions of the array are indices for the output and input + and the remaining dimensions match omega. If ``squeeze`` is True + then single-dimensional axes are removed. + + Raises + ------ + ValueError + If `s` is not purely imaginary, because + :class:`FrequencyDomainData` systems are only defined at imaginary + frequency values. """ - # Preallocate outputs. - numfreq = len(omega) - mag = empty((self.outputs, self.inputs, numfreq)) - phase = empty((self.outputs, self.inputs, numfreq)) + # Make sure that we are operating on a simple list + if len(np.atleast_1d(s).shape) > 1: + raise ValueError("input list must be 1D") - omega.sort() + if any(abs(np.atleast_1d(s).real) > 0): + raise ValueError("__call__: FRD systems can only accept " + "purely imaginary frequencies") + + # need to preserve array or scalar status + if hasattr(s, '__len__'): + return self.eval(np.asarray(s).imag, squeeze=squeeze) + else: + return self.eval(complex(s).imag, squeeze=squeeze) - for k, w in enumerate(omega): - fresp = self._evalfr(w) - mag[:, :, k] = abs(fresp) - phase[:, :, k] = angle(fresp) + def freqresp(self, omega): + """(deprecated) Evaluate transfer function at complex frequencies. - return mag, phase, omega + .. deprecated::0.9.0 + 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) + return self.frequency_response(omega) def feedback(self, other=1, sign=-1): """Feedback interconnection between two FRD objects.""" - other = _convertToFRD(other, omega=self.omega) + other = _convert_to_FRD(other, omega=self.omega) - if (self.outputs != other.inputs or self.inputs != other.outputs): + if (self.noutputs != other.ninputs or self.ninputs != other.noutputs): raise ValueError( "FRD.feedback, inputs/outputs mismatch") - fresp = empty((self.outputs, self.inputs, len(other.omega)), + fresp = empty((self.noutputs, self.ninputs, len(other.omega)), dtype=complex) # TODO: vectorize this # TODO: handle omega re-mapping @@ -465,9 +492,9 @@ def feedback(self, other=1, sign=-1): fresp[:, :, k] = np.dot( self.fresp[:, :, k], linalg.solve( - eye(self.inputs) + eye(self.ninputs) + np.dot(other.fresp[:, :, k], self.fresp[:, :, k]), - eye(self.inputs)) + eye(self.ninputs)) ) return FRD(fresp, other.omega, smooth=(self.ifunc is not None)) @@ -486,7 +513,7 @@ def feedback(self, other=1, sign=-1): FRD = FrequencyResponseData -def _convertToFRD(sys, omega, inputs=1, outputs=1): +def _convert_to_FRD(sys, omega, inputs=1, outputs=1): """Convert a system to frequency response data form (if needed). If sys is already an frd, and its frequency range matches or @@ -496,8 +523,8 @@ def _convertToFRD(sys, omega, inputs=1, outputs=1): scalar, then the number of inputs and outputs can be specified manually, as in: - >>> frd = _convertToFRD(3., omega) # Assumes inputs = outputs = 1 - >>> frd = _convertToFRD(1., omegs, inputs=3, outputs=2) + >>> frd = _convert_to_FRD(3., omega) # Assumes inputs = outputs = 1 + >>> frd = _convert_to_FRD(1., omegs, inputs=3, outputs=2) In the latter example, sys's matrix transfer function is [[1., 1., 1.] [1., 1., 1.]]. @@ -515,11 +542,13 @@ def _convertToFRD(sys, omega, inputs=1, outputs=1): "Frequency ranges of FRD do not match, conversion not implemented") elif isinstance(sys, LTI): - omega.sort() - fresp = empty((sys.outputs, sys.inputs, len(omega)), dtype=complex) - for k, w in enumerate(omega): - fresp[:, :, k] = sys._evalfr(w) - + omega = np.sort(omega) + if sys.isctime(): + fresp = sys(1j * omega) + else: + fresp = sys(np.exp(1j * omega * sys.dt)) + if len(fresp.shape) == 1: + fresp = fresp[np.newaxis, np.newaxis, :] return FRD(fresp, omega, smooth=True) elif isinstance(sys, (int, float, complex, np.number)): diff --git a/control/freqplot.py b/control/freqplot.py index 448814a55..fe18ea27d 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -46,10 +46,14 @@ import matplotlib as mpl import matplotlib.pyplot as plt import numpy as np +import warnings from .ctrlutil import unwrap from .bdalg import feedback from .margins import stability_margins +from .exception import ControlMIMONotImplemented +from .statesp import StateSpace +from .xferfcn import TransferFunction from . import config __all__ = ['bode_plot', 'nyquist_plot', 'gangof4_plot', @@ -58,7 +62,7 @@ # Default values for module parameter variables _freqplot_defaults = { 'freqplot.feature_periphery_decades': 1, - 'freqplot.number_of_samples': None, + 'freqplot.number_of_samples': 1000, } # @@ -78,12 +82,13 @@ 'bode.deg': True, # Plot phase in degrees 'bode.Hz': False, # Plot frequency in Hertz 'bode.grid': True, # Turn on grid for gain and phase + 'bode.wrap_phase': False, # Wrap the phase plot at a given value } def bode_plot(syslist, omega=None, plot=True, omega_limits=None, omega_num=None, - margins=None, *args, **kwargs): + margins=None, method='best', *args, **kwargs): """Bode plot for a system Plots a Bode plot for the system over a (optional) frequency range. @@ -92,7 +97,7 @@ def bode_plot(syslist, omega=None, ---------- syslist : linsys List of linear input/output systems (single system is OK) - omega : list + omega : array_like List of frequencies in rad/sec to be used for frequency response dB : bool If True, plot result in dB. Default is false. @@ -104,14 +109,15 @@ def bode_plot(syslist, omega=None, config.defaults['bode.deg'] plot : bool If True (default), plot magnitude and phase - omega_limits: tuple, list, ... of two values + omega_limits : array_like of two values Limits of the to generate frequency vector. If Hz=True the limits are in Hz otherwise in rad/s. - omega_num: int + omega_num : int Number of samples to plot. Defaults to config.defaults['freqplot.number_of_samples']. margins : bool If True, plot gain and phase margin. + method : method to use in computing margins (see :func:`stability_margins`) *args : :func:`matplotlib.pyplot.plot` positional properties, optional Additional arguments for `matplotlib` plots (color, linestyle, etc) **kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional @@ -119,11 +125,11 @@ def bode_plot(syslist, omega=None, Returns ------- - mag : array (list if len(syslist) > 1) + mag : ndarray (or list of ndarray if len(syslist) > 1)) magnitude - phase : array (list if len(syslist) > 1) + phase : ndarray (or list of ndarray if len(syslist) > 1)) phase in radians - omega : array (list if len(syslist) > 1) + omega : ndarray (or list of ndarray if len(syslist) > 1)) frequency in rad/sec Other Parameters @@ -131,21 +137,34 @@ def bode_plot(syslist, omega=None, grid : bool If True, plot grid lines on gain and phase plots. Default is set by `config.defaults['bode.grid']`. - + initial_phase : float + Set the reference phase to use for the lowest frequency. If set, the + initial phase of the Bode plot will be set to the value closest to the + value specified. Units are in either degrees or radians, depending on + the `deg` parameter. Default is -180 if wrap_phase is False, 0 if + wrap_phase is True. + wrap_phase : bool or float + If wrap_phase is `False`, then the phase will be unwrapped so that it + is continuously increasing or decreasing. If wrap_phase is `True` the + phase will be restricted to the range [-180, 180) (or [:math:`-\\pi`, + :math:`\\pi`) radians). If `wrap_phase` is specified as a float, the + phase will be offset by 360 degrees if it falls below the specified + value. Default to `False`, set by config.defaults['bode.wrap_phase']. The default values for Bode plot configuration parameters can be reset using the `config.defaults` dictionary, with module name 'bode'. Notes ----- - 1. Alternatively, you may use the lower-level method - ``(mag, phase, freq) = sys.freqresp(freq)`` to generate the frequency - response for a system, but it returns a MIMO response. + 1. Alternatively, you may use the lower-level methods + :meth:`LTI.frequency_response` or ``sys(s)`` or ``sys(z)`` or to + generate the frequency response for a single system. 2. If a discrete time model is given, the frequency response is plotted - along the upper branch of the unit circle, using the mapping z = exp(j - \\omega dt) where omega ranges from 0 to pi/dt and dt is the discrete - timebase. If not timebase is specified (dt = True), dt is set to 1. + along the upper branch of the unit circle, using the mapping ``z = + exp(1j * omega * dt)`` where `omega` ranges from 0 to `pi/dt` and `dt` + is the discrete timebase. If timebase not specified (``dt=True``), + `dt` is set to 1. Examples -------- @@ -171,49 +190,128 @@ def bode_plot(syslist, omega=None, grid = config._get_param('bode', 'grid', kwargs, _bode_defaults, pop=True) plot = config._get_param('bode', 'grid', plot, True) margins = config._get_param('bode', 'margins', margins, False) + wrap_phase = config._get_param( + 'bode', 'wrap_phase', kwargs, _bode_defaults, pop=True) + initial_phase = config._get_param( + 'bode', 'initial_phase', kwargs, None, pop=True) - # If argument was a singleton, turn it into a list - if not getattr(syslist, '__iter__', False): + # If argument was a singleton, turn it into a tuple + if not hasattr(syslist, '__iter__'): syslist = (syslist,) + # Decide whether to go above Nyquist frequency + omega_range_given = True if omega is not None else False + if omega is None: + omega_num = config._get_param( + 'freqplot', 'number_of_samples', omega_num) if omega_limits is None: # Select a default range if none is provided - omega = default_frequency_range(syslist, Hz=Hz, - number_of_samples=omega_num) + omega = _default_frequency_range(syslist, + number_of_samples=omega_num) else: - omega_limits = np.array(omega_limits) + omega_range_given = True + omega_limits = np.asarray(omega_limits) + if len(omega_limits) != 2: + raise ValueError("len(omega_limits) must be 2") if Hz: omega_limits *= 2. * math.pi - if omega_num: - omega = np.logspace(np.log10(omega_limits[0]), - np.log10(omega_limits[1]), - num=omega_num, - endpoint=True) - else: - omega = np.logspace(np.log10(omega_limits[0]), - np.log10(omega_limits[1]), - endpoint=True) + omega = np.logspace(np.log10(omega_limits[0]), + np.log10(omega_limits[1]), num=omega_num, + endpoint=True) + + if plot: + # Set up the axes with labels so that multiple calls to + # bode_plot will superimpose the data. This was implicit + # before matplotlib 2.1, but changed after that (See + # https://github.com/matplotlib/matplotlib/issues/9024). + # The code below should work on all cases. + + # Get the current figure + + if 'sisotool' in kwargs: + fig = kwargs['fig'] + ax_mag = fig.axes[0] + ax_phase = fig.axes[2] + sisotool = kwargs['sisotool'] + del kwargs['fig'] + del kwargs['sisotool'] + else: + fig = plt.gcf() + ax_mag = None + ax_phase = None + sisotool = False + + # Get the current axes if they already exist + for ax in fig.axes: + if ax.get_label() == 'control-bode-magnitude': + ax_mag = ax + elif ax.get_label() == 'control-bode-phase': + ax_phase = ax + + # If no axes present, create them from scratch + if ax_mag is None or ax_phase is None: + plt.clf() + ax_mag = plt.subplot(211, label='control-bode-magnitude') + ax_phase = plt.subplot( + 212, label='control-bode-phase', sharex=ax_mag) mags, phases, omegas, nyquistfrqs = [], [], [], [] for sys in syslist: - if sys.inputs > 1 or sys.outputs > 1: + if not sys.issiso(): # TODO: Add MIMO bode plots. - raise NotImplementedError( + raise ControlMIMONotImplemented( "Bode is currently only implemented for SISO systems.") else: - omega_sys = np.array(omega) - if sys.isdtime(True): - nyquistfrq = 2. * math.pi * 1. / sys.dt / 2. - omega_sys = omega_sys[omega_sys < nyquistfrq] - # TODO: What distance to the Nyquist frequency is appropriate? + omega_sys = np.asarray(omega) + if sys.isdtime(strict=True): + nyquistfrq = math.pi / sys.dt + if not omega_range_given: + # limit up to and including nyquist frequency + omega_sys = np.hstack(( + omega_sys[omega_sys < nyquistfrq], nyquistfrq)) else: nyquistfrq = None - # Get the magnitude and phase of the system - mag_tmp, phase_tmp, omega_sys = sys.freqresp(omega_sys) - mag = np.atleast_1d(np.squeeze(mag_tmp)) - phase = np.atleast_1d(np.squeeze(phase_tmp)) - phase = unwrap(phase) + + mag, phase, omega_sys = sys.frequency_response(omega_sys) + mag = np.atleast_1d(mag) + phase = np.atleast_1d(phase) + + # + # Post-process the phase to handle initial value and wrapping + # + + if initial_phase is None: + # Start phase in the range 0 to -360 w/ initial phase = -180 + # If wrap_phase is true, use 0 instead (phase \in (-pi, pi]) + initial_phase = -math.pi if wrap_phase is not True else 0 + elif isinstance(initial_phase, (int, float)): + # Allow the user to override the default calculation + if deg: + initial_phase = initial_phase/180. * math.pi + else: + raise ValueError("initial_phase must be a number.") + + # Shift the phase if needed + if abs(phase[0] - initial_phase) > math.pi: + phase -= 2*math.pi * \ + round((phase[0] - initial_phase) / (2*math.pi)) + + # Phase wrapping + if wrap_phase is False: + phase = unwrap(phase) # unwrap the phase + elif wrap_phase is True: + pass # default calculation OK + elif isinstance(wrap_phase, (int, float)): + phase = unwrap(phase) # unwrap the phase first + if deg: + wrap_phase *= math.pi/180. + + # Shift the phase if it is below the wrap_phase + phase += 2*math.pi * np.maximum( + 0, np.ceil((wrap_phase - phase)/(2*math.pi))) + else: + raise ValueError("wrap_phase must be bool or float.") mags.append(mag) phases.append(phase) @@ -232,72 +330,55 @@ def bode_plot(syslist, omega=None, omega_plot = omega_sys if nyquistfrq: nyquistfrq_plot = nyquistfrq + phase_plot = phase * 180. / math.pi if deg else phase + mag_plot = mag - # Set up the axes with labels so that multiple calls to - # bode_plot will superimpose the data. This was implicit - # before matplotlib 2.1, but changed after that (See - # https://github.com/matplotlib/matplotlib/issues/9024). - # The code below should work on all cases. - - # Get the current figure - - if 'sisotool' in kwargs: - fig = kwargs['fig'] - ax_mag = fig.axes[0] - ax_phase = fig.axes[2] - sisotool = kwargs['sisotool'] - del kwargs['fig'] - del kwargs['sisotool'] - else: - fig = plt.gcf() - ax_mag = None - ax_phase = None - sisotool = False - - # Get the current axes if they already exist - for ax in fig.axes: - if ax.get_label() == 'control-bode-magnitude': - ax_mag = ax - elif ax.get_label() == 'control-bode-phase': - ax_phase = ax - - # If no axes present, create them from scratch - if ax_mag is None or ax_phase is None: - plt.clf() - ax_mag = plt.subplot(211, - label='control-bode-magnitude') - ax_phase = plt.subplot(212, - label='control-bode-phase', - sharex=ax_mag) - + if nyquistfrq_plot: + # append data for vertical nyquist freq indicator line. + # if this extra nyquist lime is is plotted in a single plot + # command then line order is preserved when + # creating a legend eg. legend(('sys1', 'sys2')) + omega_nyq_line = np.array((np.nan, nyquistfrq, nyquistfrq)) + omega_plot = np.hstack((omega_plot, omega_nyq_line)) + mag_nyq_line = np.array(( + np.nan, 0.7*min(mag_plot), 1.3*max(mag_plot))) + mag_plot = np.hstack((mag_plot, mag_nyq_line)) + phase_range = max(phase_plot) - min(phase_plot) + phase_nyq_line = np.array( + (np.nan, + min(phase_plot) - 0.2 * phase_range, + max(phase_plot) + 0.2 * phase_range)) + phase_plot = np.hstack((phase_plot, phase_nyq_line)) + + # # Magnitude plot + # + if dB: - pltline = ax_mag.semilogx(omega_plot, 20 * np.log10(mag), - *args, **kwargs) + ax_mag.semilogx(omega_plot, 20 * np.log10(mag_plot), + *args, **kwargs) else: - pltline = ax_mag.loglog(omega_plot, mag, *args, **kwargs) - - if nyquistfrq_plot: - ax_mag.axvline(nyquistfrq_plot, - color=pltline[0].get_color()) + ax_mag.loglog(omega_plot, mag_plot, *args, **kwargs) # Add a grid to the plot + labeling ax_mag.grid(grid and not margins, which='both') ax_mag.set_ylabel("Magnitude (dB)" if dB else "Magnitude") + # # Phase plot - if deg: - phase_plot = phase * 180. / math.pi - else: - phase_plot = phase + # + + # Plot the data ax_phase.semilogx(omega_plot, phase_plot, *args, **kwargs) # Show the phase and gain margins in the plot if margins: - margin = stability_margins(sys) - gm, pm, Wcg, Wcp = \ - margin[0], margin[1], margin[3], margin[4] - # TODO: add some documentation describing why this is here + # Compute stability margins for the system + margin = stability_margins(sys, method=method) + gm, pm, Wcg, Wcp = (margin[i] for i in (0, 1, 3, 4)) + + # Figure out sign of the phase at the first gain crossing + # (needed if phase_wrap is True) phase_at_cp = phases[0][(np.abs(omegas[0] - Wcp)).argmin()] if phase_at_cp >= 0.: phase_limit = 180. @@ -307,6 +388,7 @@ def bode_plot(syslist, omega=None, if Hz: Wcg, Wcp = Wcg/(2*math.pi), Wcp/(2*math.pi) + # Draw lines at gain and phase limits ax_mag.axhline(y=0 if dB else 1, color='k', linestyle=':', zorder=-20) ax_phase.axhline(y=phase_limit if deg else @@ -315,6 +397,7 @@ def bode_plot(syslist, omega=None, mag_ylim = ax_mag.get_ylim() phase_ylim = ax_phase.get_ylim() + # Annotate the phase margin (if it exists) if pm != float('inf') and Wcp != float('nan'): if dB: ax_mag.semilogx( @@ -327,7 +410,7 @@ def bode_plot(syslist, omega=None, if deg: ax_phase.semilogx( - [Wcp, Wcp], [1e5, phase_limit+pm], + [Wcp, Wcp], [1e5, phase_limit + pm], color='k', linestyle=':', zorder=-20) ax_phase.semilogx( [Wcp, Wcp], [phase_limit + pm, phase_limit], @@ -343,6 +426,7 @@ def bode_plot(syslist, omega=None, math.radians(phase_limit)], color='k', zorder=-20) + # Annotate the gain margin (if it exists) if gm != float('inf') and Wcg != float('nan'): if dB: ax_mag.semilogx( @@ -360,11 +444,11 @@ def bode_plot(syslist, omega=None, if deg: ax_phase.semilogx( - [Wcg, Wcg], [1e-8, phase_limit], + [Wcg, Wcg], [0, phase_limit], color='k', linestyle=':', zorder=-20) else: ax_phase.semilogx( - [Wcg, Wcg], [1e-8, math.radians(phase_limit)], + [Wcg, Wcg], [0, math.radians(phase_limit)], color='k', linestyle=':', zorder=-20) ax_mag.set_ylim(mag_ylim) @@ -396,16 +480,12 @@ def bode_plot(syslist, omega=None, "Gm = %.2f %s(at %.2f %s), " "Pm = %.2f %s (at %.2f %s)" % (20*np.log10(gm) if dB else gm, - 'dB ' if dB else '\b', + 'dB ' if dB else '', Wcg, 'Hz' if Hz else 'rad/s', pm if deg else math.radians(pm), 'deg' if deg else 'rad', Wcp, 'Hz' if Hz else 'rad/s')) - if nyquistfrq_plot: - ax_phase.axvline( - nyquistfrq_plot, color=pltline[0].get_color()) - # Add a grid to the plot + labeling ax_phase.set_ylabel("Phase (deg)" if deg else "Phase (rad)") @@ -445,51 +525,133 @@ def gen_zero_centered_series(val_min, val_max, period): # Nyquist plot # -def nyquist_plot(syslist, omega=None, plot=True, label_freq=0, - arrowhead_length=0.1, arrowhead_width=0.1, - color=None, *args, **kwargs): - """ - Nyquist plot for a system +# Default values for module parameter variables +_nyquist_defaults = { + 'nyquist.mirror_style': '--', + 'nyquist.arrows': 2, + 'nyquist.arrow_size': 8, + 'nyquist.indent_radius': 1e-1, + 'nyquist.indent_direction': 'right', +} + + +def nyquist_plot(syslist, omega=None, plot=True, omega_limits=None, + omega_num=None, label_freq=0, color=None, + return_contour=False, warn_nyquist=True, *args, **kwargs): + """Nyquist plot for a system Plots a Nyquist plot for the system over a (optional) frequency range. + The curve is computed by evaluating the Nyqist segment along the positive + imaginary axis, with a mirror image generated to reflect the negative + imaginary axis. Poles on or near the imaginary axis are avoided using a + small indentation. The portion of the Nyquist contour at infinity is not + explicitly computed (since it maps to a constant value for any system with + a proper transfer function). Parameters ---------- syslist : list of LTI - List of linear input/output systems (single system is OK) - omega : freq_range - Range of frequencies (list or bounds) in rad/sec - Plot : boolean + List of linear input/output systems (single system is OK). Nyquist + curves for each system are plotted on the same graph. + + plot : boolean If True, plot magnitude + + omega : array_like + Set of frequencies to be evaluated, in rad/sec. + + omega_limits : array_like of two values + Limits to the range of frequencies. Ignored if omega is provided, and + auto-generated if omitted. + + omega_num : int + Number of frequency samples to plot. Defaults to + config.defaults['freqplot.number_of_samples']. + color : string - Used to specify the color of the plot + Used to specify the color of the line and arrowhead. + + mirror_style : string or False + Linestyle for mirror image of the Nyquist curve. If `False` then + omit completely. Default linestyle ('--') is determined by + config.defaults['nyquist.mirror_style']. + + return_contour : bool + If 'True', return the contour used to evaluate the Nyquist plot. + label_freq : int - Label every nth frequency on the plot - arrowhead_width : arrow head width - arrowhead_length : arrow head length + Label every nth frequency on the plot. If not specified, no labels + are generated. + + arrows : int or 1D/2D array of floats + Specify the number of arrows to plot on the Nyquist curve. If an + integer is passed. that number of equally spaced arrows will be + plotted on each of the primary segment and the mirror image. If a 1D + array is passed, it should consist of a sorted list of floats between + 0 and 1, indicating the location along the curve to plot an arrow. If + a 2D array is passed, the first row will be used to specify arrow + locations for the primary curve and the second row will be used for + the mirror image. + + arrow_size : float + Arrowhead width and length (in display coordinates). Default value is + 8 and can be set using config.defaults['nyquist.arrow_size']. + + arrow_style : matplotlib.patches.ArrowStyle + Define style used for Nyquist curve arrows (overrides `arrow_size`). + + indent_radius : float + Amount to indent the Nyquist contour around poles that are at or near + the imaginary axis. + + indent_direction : str + For poles on the imaginary axis, set the direction of indentation to + be 'right' (default), 'left', or 'none'. + + warn_nyquist : bool, optional + If set to 'False', turn off warnings about frequencies above Nyquist. + *args : :func:`matplotlib.pyplot.plot` positional properties, optional Additional arguments for `matplotlib` plots (color, linestyle, etc) + **kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional Additional keywords (passed to `matplotlib`) Returns ------- - real : array - real part of the frequency response array - imag : array - imaginary part of the frequency response array - freq : array - frequencies + count : int (or list of int if len(syslist) > 1) + Number of encirclements of the point -1 by the Nyquist curve. If + multiple systems are given, an array of counts is returned. + + contour : ndarray (or list of ndarray if len(syslist) > 1)), optional + The contour used to create the primary Nyquist curve segment. To + obtain the Nyquist curve values, evaluate system(s) along contour. + + Notes + ----- + 1. If a discrete time model is given, the frequency response is computed + along the upper branch of the unit circle, using the mapping ``z = + exp(1j * omega * dt)`` where `omega` ranges from 0 to `pi/dt` and `dt` + is the discrete timebase. If timebase not specified (``dt=True``), + `dt` is set to 1. + + 2. If a continuous-time system contains poles on or near the imaginary + axis, a small indentation will be used to avoid the pole. The radius + of the indentation is given by `indent_radius` and it is taken the the + 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. Examples -------- - >>> sys = ss("1. -2; 3. -4", "5.; 7", "6. 8", "9.") - >>> real, imag, freq = nyquist_plot(sys) + >>> sys = ss([[1, -2], [3, -4]], [[5], [7]], [[6, 8]], [[9]]) + >>> count = nyquist_plot(sys) """ # Check to see if legacy 'Plot' keyword was used if 'Plot' in kwargs: - import warnings warnings.warn("'Plot' keyword is deprecated in nyquist_plot; " "use 'plot'", FutureWarning) # Map 'Plot' keyword to 'plot' keyword @@ -497,67 +659,165 @@ def nyquist_plot(syslist, omega=None, plot=True, label_freq=0, # Check to see if legacy 'labelFreq' keyword was used if 'labelFreq' in kwargs: - import warnings warnings.warn("'labelFreq' keyword is deprecated in nyquist_plot; " "use 'label_freq'", FutureWarning) # Map 'labelFreq' keyword to 'label_freq' keyword label_freq = kwargs.pop('labelFreq') + # Check to see if legacy 'arrow_width' or 'arrow_length' were used + if 'arrow_width' in kwargs or 'arrow_length' in kwargs: + warnings.warn( + "'arrow_width' and 'arrow_length' keywords are deprecated in " + "nyquist_plot; use `arrow_size` instead", FutureWarning) + kwargs['arrow_size'] = \ + (kwargs.get('arrow_width', 0) + kwargs.get('arrow_length', 0)) / 2 + kwargs.pop('arrow_width', False) + kwargs.pop('arrow_length', False) + + # Get values for params (and pop from list to allow keyword use in plot) + omega_num = config._get_param('freqplot', 'number_of_samples', omega_num) + mirror_style = config._get_param( + 'nyquist', 'mirror_style', kwargs, _nyquist_defaults, pop=True) + arrows = config._get_param( + 'nyquist', 'arrows', kwargs, _nyquist_defaults, pop=True) + arrow_size = config._get_param( + 'nyquist', 'arrow_size', kwargs, _nyquist_defaults, pop=True) + arrow_style = config._get_param('nyquist', 'arrow_style', kwargs, None) + indent_radius = config._get_param( + 'nyquist', 'indent_radius', kwargs, _nyquist_defaults, pop=True) + indent_direction = config._get_param( + 'nyquist', 'indent_direction', kwargs, _nyquist_defaults, pop=True) + # If argument was a singleton, turn it into a list - if not getattr(syslist, '__iter__', False): + if not hasattr(syslist, '__iter__'): syslist = (syslist,) - # Select a default range if none is provided - if omega is None: - omega = default_frequency_range(syslist) + # Decide whether to go above Nyquist frequency + omega_range_given = True if omega is not None else False - # Interpolate between wmin and wmax if a tuple or list are provided - elif isinstance(omega, list) or isinstance(omega, tuple): - # Only accept tuple or list of length 2 - if len(omega) != 2: - raise ValueError("Supported frequency arguments are (wmin,wmax)" - "tuple or list, or frequency vector. ") - omega = np.logspace(np.log10(omega[0]), np.log10(omega[1]), - num=50, endpoint=True, base=10.0) + # Figure out the frequency limits + if omega is None: + if omega_limits is None: + # Select a default range if none is provided + omega = _default_frequency_range( + syslist, number_of_samples=omega_num) + # Replace first point with the origin + omega[0] = 0 + else: + omega_range_given = True + omega_limits = np.asarray(omega_limits) + if len(omega_limits) != 2: + raise ValueError("len(omega_limits) must be 2") + omega = np.logspace(np.log10(omega_limits[0]), + np.log10(omega_limits[1]), num=omega_num, + endpoint=True) + + # Go through each system and keep track of the results + counts, contours = [], [] for sys in syslist: - if sys.inputs > 1 or sys.outputs > 1: + if not sys.issiso(): # TODO: Add MIMO nyquist plots. - raise NotImplementedError( - "Nyquist is currently only implemented for SISO systems.") + raise ControlMIMONotImplemented( + "Nyquist plot currently only supports SISO systems.") + + # Figure out the frequency range + omega_sys = np.asarray(omega) + + # Determine the contour used to evaluate the Nyquist curve + if sys.isdtime(strict=True): + # Transform frequencies in for discrete-time systems + nyquistfrq = math.pi / sys.dt + if not omega_range_given: + # limit up to and including nyquist frequency + omega_sys = np.hstack(( + omega_sys[omega_sys < nyquistfrq], nyquistfrq)) + + # Issue a warning if we are sampling above Nyquist + if np.any(omega_sys * sys.dt > np.pi) and warn_nyquist: + warnings.warn("evaluation above Nyquist frequency") + + # Transform frequencies to continuous domain + contour = np.exp(1j * omega * sys.dt) else: - # Get the magnitude and phase of the system - mag_tmp, phase_tmp, omega = sys.freqresp(omega) - mag = np.squeeze(mag_tmp) - phase = np.squeeze(phase_tmp) + contour = 1j * omega_sys + + # Bend the contour around any poles on/near the imaginary axis + if isinstance(sys, (StateSpace, TransferFunction)) and \ + sys.isctime() and indent_direction != 'none': + poles = sys.pole() + for i, s in enumerate(contour): + # Find the nearest pole + p = poles[(np.abs(poles - s)).argmin()] + + # See if we need to indent around it + if abs(s - p) < indent_radius: + if p.real < 0 or \ + (p.real == 0 and indent_direction == 'right'): + # Indent to the right + contour[i] += \ + np.sqrt(indent_radius ** 2 - (s-p).imag ** 2) + elif p.real > 0 or \ + (p.real == 0 and indent_direction == 'left'): + # Indent to the left + contour[i] -= \ + np.sqrt(indent_radius ** 2 - (s-p).imag ** 2) + else: + ValueError("unknown value for indent_direction") + + # TODO: add code to indent around discrete poles on unit circle + + # Compute the primary curve + resp = sys(contour) + + # Compute CW encirclements of -1 by integrating the (unwrapped) angle + phase = -unwrap(np.angle(resp + 1)) + count = int(np.round(np.sum(np.diff(phase)) / np.pi, 0)) + + counts.append(count) + contours.append(contour) + + if plot: + # Parse the arrows keyword + if isinstance(arrows, int): + N = arrows + # Space arrows out, starting midway along each "region" + arrow_pos = np.linspace(0.5/N, 1 + 0.5/N, N, endpoint=False) + elif isinstance(arrows, (list, np.ndarray)): + arrow_pos = np.sort(np.atleast_1d(arrows)) + elif not arrows: + arrow_pos = [] + else: + raise ValueError("unknown or unsupported arrow location") - # Compute the primary curve - x = np.multiply(mag, np.cos(phase)) - y = np.multiply(mag, np.sin(phase)) + # Set the arrow style + if arrow_style is None: + arrow_style = mpl.patches.ArrowStyle( + 'simple', head_width=arrow_size, head_length=arrow_size) - if plot: - # Plot the primary curve and mirror image - p = plt.plot(x, y, '-', color=color, *args, **kwargs) - c = p[0].get_color() - ax = plt.gca() - # Plot arrow to indicate Nyquist encirclement orientation - ax.arrow(x[0], y[0], (x[1]-x[0])/2, (y[1]-y[0])/2, fc=c, ec=c, - head_width=arrowhead_width, - head_length=arrowhead_length) - - plt.plot(x, -y, '-', color=c, *args, **kwargs) - ax.arrow( - x[-1], -y[-1], (x[-1]-x[-2])/2, (y[-1]-y[-2])/2, - fc=c, ec=c, head_width=arrowhead_width, - head_length=arrowhead_length) - - # Mark the -1 point - plt.plot([-1], [0], 'r+') + # Save the components of the response + x, y = resp.real, resp.imag + + # Plot the primary curve + p = plt.plot(x, y, '-', color=color, *args, **kwargs) + c = p[0].get_color() + ax = plt.gca() + _add_arrows_to_line2D( + ax, p[0], arrow_pos, arrowstyle=arrow_style, dir=1) + + # Plot the mirror image + if mirror_style is not False: + p = plt.plot(x, -y, mirror_style, color=c, *args, **kwargs) + _add_arrows_to_line2D( + ax, p[0], arrow_pos, arrowstyle=arrow_style, dir=-1) + + # Mark the -1 point + plt.plot([-1], [0], 'r+') # Label the frequencies of the points if label_freq: ind = slice(None, None, label_freq) - for xpt, ypt, omegapt in zip(x[ind], y[ind], omega[ind]): + for xpt, ypt, omegapt in zip(x[ind], y[ind], omega_sys[ind]): # Convert to Hz f = omegapt / (2 * np.pi) @@ -584,7 +844,85 @@ def nyquist_plot(syslist, omega=None, plot=True, label_freq=0, ax.set_ylabel("Imaginary axis") ax.grid(color="lightgray") - return x, y, omega + # "Squeeze" the results + if len(syslist) == 1: + counts, contours = counts[0], contours[0] + + # Return counts and (optionally) the contour we used + return (counts, contours) if return_contour else counts + + +# Internal function to add arrows to a curve +def _add_arrows_to_line2D( + axes, line, arrow_locs=[0.2, 0.4, 0.6, 0.8], + arrowstyle='-|>', arrowsize=1, dir=1, transform=None): + """ + Add arrows to a matplotlib.lines.Line2D at selected locations. + + Parameters: + ----------- + axes: Axes object as returned by axes command (or gca) + line: Line2D object as returned by plot command + arrow_locs: list of locations where to insert arrows, % of total length + arrowstyle: style of the arrow + arrowsize: size of the arrow + transform: a matplotlib transform instance, default to data coordinates + + Returns: + -------- + arrows: list of arrows + + Based on https://stackoverflow.com/questions/26911898/ + + """ + if not isinstance(line, mpl.lines.Line2D): + raise ValueError("expected a matplotlib.lines.Line2D object") + x, y = line.get_xdata(), line.get_ydata() + + arrow_kw = { + "arrowstyle": arrowstyle, + } + + color = line.get_color() + use_multicolor_lines = isinstance(color, np.ndarray) + if use_multicolor_lines: + raise NotImplementedError("multicolor lines not supported") + else: + arrow_kw['color'] = color + + linewidth = line.get_linewidth() + if isinstance(linewidth, np.ndarray): + raise NotImplementedError("multiwidth lines not supported") + else: + arrow_kw['linewidth'] = linewidth + + if transform is None: + transform = axes.transData + + # Compute the arc length along the curve + s = np.cumsum(np.sqrt(np.diff(x) ** 2 + np.diff(y) ** 2)) + + arrows = [] + for loc in arrow_locs: + n = np.searchsorted(s, s[-1] * loc) + + # Figure out what direction to paint the arrow + if dir == 1: + arrow_tail = (x[n], y[n]) + arrow_head = (np.mean(x[n:n + 2]), np.mean(y[n:n + 2])) + elif dir == -1: + # Orient the arrow in the other direction on the segment + arrow_tail = (x[n + 1], y[n + 1]) + arrow_head = (np.mean(x[n:n + 2]), np.mean(y[n:n + 2])) + else: + raise ValueError("unknown value for keyword 'dir'") + + p = mpl.patches.FancyArrowPatch( + arrow_tail, arrow_head, transform=transform, lw=0, + **arrow_kw) + axes.add_patch(p) + arrows.append(p) + return arrows # @@ -611,9 +949,9 @@ def gangof4_plot(P, C, omega=None, **kwargs): ------- None """ - if P.inputs > 1 or P.outputs > 1 or C.inputs > 1 or C.outputs > 1: + if not P.issiso() or not C.issiso(): # TODO: Add MIMO go4 plots. - raise NotImplementedError( + raise ControlMIMONotImplemented( "Gang of four is currently only implemented for SISO systems.") # Get the default parameter values @@ -629,7 +967,7 @@ def gangof4_plot(P, C, omega=None, **kwargs): # Select a default range if none is provided # TODO: This needs to be made more intelligent if omega is None: - omega = default_frequency_range((P, C, S)) + omega = _default_frequency_range((P, C, S)) # Set up the axes with labels so that multiple calls to # gangof4_plot will superimpose the data. See details in bode_plot. @@ -657,7 +995,7 @@ def gangof4_plot(P, C, omega=None, **kwargs): omega_plot = omega / (2. * math.pi) if Hz else omega # TODO: Need to add in the mag = 1 lines - mag_tmp, phase_tmp, omega = S.freqresp(omega) + mag_tmp, phase_tmp, omega = S.frequency_response(omega) mag = np.squeeze(mag_tmp) if dB: plot_axes['s'].semilogx(omega_plot, 20 * np.log10(mag), **kwargs) @@ -667,7 +1005,7 @@ def gangof4_plot(P, C, omega=None, **kwargs): plot_axes['s'].tick_params(labelbottom=False) plot_axes['s'].grid(grid, which='both') - mag_tmp, phase_tmp, omega = (P * S).freqresp(omega) + mag_tmp, phase_tmp, omega = (P * S).frequency_response(omega) mag = np.squeeze(mag_tmp) if dB: plot_axes['ps'].semilogx(omega_plot, 20 * np.log10(mag), **kwargs) @@ -677,7 +1015,7 @@ def gangof4_plot(P, C, omega=None, **kwargs): plot_axes['ps'].set_ylabel("$|PS|$" + " (dB)" if dB else "") plot_axes['ps'].grid(grid, which='both') - mag_tmp, phase_tmp, omega = (C * S).freqresp(omega) + mag_tmp, phase_tmp, omega = (C * S).frequency_response(omega) mag = np.squeeze(mag_tmp) if dB: plot_axes['cs'].semilogx(omega_plot, 20 * np.log10(mag), **kwargs) @@ -688,7 +1026,7 @@ def gangof4_plot(P, C, omega=None, **kwargs): plot_axes['cs'].set_ylabel("$|CS|$" + " (dB)" if dB else "") plot_axes['cs'].grid(grid, which='both') - mag_tmp, phase_tmp, omega = T.freqresp(omega) + mag_tmp, phase_tmp, omega = T.frequency_response(omega) mag = np.squeeze(mag_tmp) if dB: plot_axes['t'].semilogx(omega_plot, 20 * np.log10(mag), **kwargs) @@ -710,8 +1048,8 @@ def gangof4_plot(P, C, omega=None, **kwargs): # # Compute reasonable defaults for axes -def default_frequency_range(syslist, Hz=None, number_of_samples=None, - feature_periphery_decades=None): +def _default_frequency_range(syslist, Hz=None, number_of_samples=None, + feature_periphery_decades=None): """Compute a reasonable default frequency range for frequency domain plots. @@ -744,7 +1082,7 @@ def default_frequency_range(syslist, Hz=None, number_of_samples=None, -------- >>> from matlab import ss >>> sys = ss("1. -2; 3. -4", "5.; 7", "6. 8", "9.") - >>> omega = default_frequency_range(sys) + >>> omega = _default_frequency_range(sys) """ # This code looks at the poles and zeros of all of the systems that @@ -764,7 +1102,7 @@ def default_frequency_range(syslist, Hz=None, number_of_samples=None, freq_interesting = [] # detect if single sys passed by checking if it is sequence-like - if not getattr(syslist, '__iter__', False): + if not hasattr(syslist, '__iter__'): syslist = (syslist,) for sys in syslist: diff --git a/control/iosys.py b/control/iosys.py index a90b5193c..526da4cdb 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -3,16 +3,11 @@ # RMM, 28 April 2019 # # Additional features to add -# * Improve support for signal names, specially in operator overloads -# - Figure out how to handle "nested" names (icsys.sys[1].x[1]) -# - Use this to implement signal names for operators? # * Allow constant inputs for MIMO input_output_response (w/out ones) # * Add support for constants/matrices as part of operators (1 + P) # * Add unit tests (and example?) for time-varying systems # * Allow time vector for discrete time simulations to be multiples of dt # * Check the way initial outputs for discrete time systems are handled -# * Rename 'connections' as 'conlist' to match 'inplist' and 'outlist'? -# * Allow signal summation in InterconnectedSystem diagrams (via new output?) # """The :mod:`~control.iosys` module contains the @@ -36,13 +31,24 @@ import copy from warnings import warn -from .statesp import StateSpace, tf2ss -from .timeresp import _check_convert_array -from .lti import isctime, isdtime, _find_timebase +from .statesp import StateSpace, tf2ss, _convert_to_statespace +from .timeresp import _check_convert_array, _process_time_response +from .lti import isctime, isdtime, common_timebase +from . import config __all__ = ['InputOutputSystem', 'LinearIOSystem', 'NonlinearIOSystem', - 'InterconnectedSystem', 'input_output_response', 'find_eqpt', - 'linearize', 'ss2io', 'tf2io'] + 'InterconnectedSystem', 'LinearICSystem', 'input_output_response', + 'find_eqpt', 'linearize', 'ss2io', 'tf2io', 'interconnect', + 'summing_junction'] + +# Define module default parameter values +_iosys_defaults = { + 'iosys.state_name_delim': '_', + 'iosys.duplicate_system_name_prefix': '', + 'iosys.duplicate_system_name_suffix': '$copy', + 'iosys.linearized_system_name_prefix': '', + 'iosys.linearized_system_name_suffix': '$linearized' +} class InputOutputSystem(object): @@ -69,9 +75,11 @@ class for a set of subclasses that are used to implement specific states : int, list of str, or None Description of the system states. Same format as `inputs`. dt : None, True or float, optional - System timebase. None (default) indicates continuous time, True - indicates discrete time with undefined sampling time, positive number - is discrete time with specified sampling time. + System timebase. 0 (default) indicates continuous + time, True indicates discrete time with unspecified sampling + time, positive number is discrete time with specified + sampling time, None indicates unspecified timebase (either + continuous or discrete time). params : dict, optional Parameter values for the systems. Passed to the evaluation functions for the system as default values, overriding internal defaults. @@ -87,9 +95,11 @@ class for a set of subclasses that are used to implement specific Dictionary of signal names for the inputs, outputs and states and the index of the corresponding array dt : None, True or float - System timebase. None (default) indicates continuous time, True - indicates discrete time with undefined sampling time, positive number - is discrete time with specified sampling time. + System timebase. 0 (default) indicates continuous + time, True indicates discrete time with unspecified sampling + time, positive number is discrete time with specified + sampling time, None indicates unspecified timebase (either + continuous or discrete time). params : dict, optional Parameter values for the systems. Passed to the evaluation functions for the system as default values, overriding internal defaults. @@ -98,8 +108,8 @@ class for a set of subclasses that are used to implement specific Notes ----- - The `InputOuputSystem` class (and its subclasses) makes use of two special - methods for implementing much of the work of the class: + The :class:`~control.InputOuputSystem` class (and its subclasses) makes + use of two special methods for implementing much of the work of the class: * _rhs(t, x, u): compute the right hand side of the differential or difference equation for the system. This must be specified by the @@ -111,6 +121,7 @@ class for a set of subclasses that are used to implement specific """ idCounter = 0 + def name_or_default(self, name=None): if name is None: name = "sys[{}]".format(InputOutputSystem.idCounter) @@ -118,14 +129,14 @@ def name_or_default(self, name=None): return name def __init__(self, inputs=None, outputs=None, states=None, params={}, - dt=None, name=None): + name=None, **kwargs): """Create an input/output system. The InputOutputSystem contructor is used to create an input/output object with the core information required for all input/output systems. Instances of this class are normally created by one of the - input/output subclasses: :class:`~control.LinearIOSystem`, - :class:`~control.NonlinearIOSystem`, + input/output subclasses: :class:`~control.LinearICSystem`, + :class:`~control.LinearIOSystem`, :class:`~control.NonlinearIOSystem`, :class:`~control.InterconnectedSystem`. Parameters @@ -143,17 +154,18 @@ def __init__(self, inputs=None, outputs=None, states=None, params={}, states : int, list of str, or None Description of the system states. Same format as `inputs`. dt : None, True or float, optional - System timebase. None (default) indicates continuous - time, True indicates discrete time with undefined sampling + System timebase. 0 (default) indicates continuous + time, True indicates discrete time with unspecified sampling time, positive number is discrete time with specified - sampling time. + sampling time, None indicates unspecified timebase (either + continuous or discrete time). params : dict, optional Parameter values for the systems. Passed to the evaluation functions for the system as default values, overriding internal defaults. name : string, optional - System name (used for specifying signals). If unspecified, a generic - name is generated with a unique integer id. + System name (used for specifying signals). If unspecified, a + generic name is generated with a unique integer id. Returns ------- @@ -162,9 +174,13 @@ def __init__(self, inputs=None, outputs=None, states=None, params={}, """ # Store the input arguments - self.params = params.copy() # default parameters - self.dt = dt # timebase - self.name = self.name_or_default(name) # system name + + # default parameters + self.params = params.copy() + # timebase + self.dt = kwargs.get('dt', config.defaults['control.default_dt']) + # system name + self.name = self.name_or_default(name) # Parse and store the number of inputs, outputs, and states self.set_inputs(inputs) @@ -178,11 +194,14 @@ def __str__(self): """String representation of an input/output system""" str = "System: " + (self.name if self.name else "(None)") + "\n" str += "Inputs (%s): " % self.ninputs - for key in self.input_index: str += key + ", " + for key in self.input_index: + str += key + ", " str += "\nOutputs (%s): " % self.noutputs - for key in self.output_index: str += key + ", " + for key in self.output_index: + str += key + ", " str += "\nStates (%s): " % self.nstates - for key in self.state_index: str += key + ", " + for key in self.state_index: + str += key + ", " return str def __mul__(sys2, sys1): @@ -191,18 +210,13 @@ def __mul__(sys2, sys1): if isinstance(sys1, (int, float, np.number)): # TODO: Scale the output raise NotImplemented("Scalar multiplication not yet implemented") + elif isinstance(sys1, np.ndarray): # TODO: Post-multiply by a matrix raise NotImplemented("Matrix multiplication not yet implemented") - elif isinstance(sys1, StateSpace) and isinstance(sys2, StateSpace): - # Special case: maintain linear systems structure - new_ss_sys = StateSpace.__mul__(sys2, sys1) - # TODO: set input and output names - new_io_sys = LinearIOSystem(new_ss_sys) - return new_io_sys elif not isinstance(sys1, InputOutputSystem): - raise ValueError("Unknown I/O system object ", sys1) + raise TypeError("Unknown I/O system object ", sys1) # Make sure systems can be interconnected if sys1.noutputs != sys2.ninputs: @@ -210,16 +224,15 @@ def __mul__(sys2, sys1): "inputs and outputs") # Make sure timebase are compatible - dt = _find_timebase(sys1, sys2) - if dt is False: - raise ValueError("System timebases are not compabile") + dt = common_timebase(sys1.dt, sys2.dt) - inplist = [(0,i) for i in range(sys1.ninputs)] - outlist = [(1,i) for i in range(sys2.noutputs)] - # Return the series interconnection between the systems - newsys = InterconnectedSystem((sys1, sys2), inplist=inplist, outlist=outlist) + # Create a new system to handle the composition + inplist = [(0, i) for i in range(sys1.ninputs)] + outlist = [(1, i) for i in range(sys2.noutputs)] + newsys = InterconnectedSystem( + (sys1, sys2), inplist=inplist, outlist=outlist) - # Set up the connection map manually + # Set up the connection map manually newsys.set_connect_map(np.block( [[np.zeros((sys1.ninputs, sys1.noutputs)), np.zeros((sys1.ninputs, sys2.noutputs))], @@ -227,42 +240,48 @@ def __mul__(sys2, sys1): np.zeros((sys2.ninputs, sys2.noutputs))]] )) - # Return the newly created system + # If both systems are linear, create LinearICSystem + if isinstance(sys1, StateSpace) and isinstance(sys2, StateSpace): + ss_sys = StateSpace.__mul__(sys2, sys1) + return LinearICSystem(newsys, ss_sys) + + # Return the newly created InterconnectedSystem return newsys def __rmul__(sys1, sys2): """Pre-multiply an input/output systems by a scalar/matrix""" - if isinstance(sys2, (int, float, np.number)): + if isinstance(sys2, InputOutputSystem): + # Both systems are InputOutputSystems => use __mul__ + return InputOutputSystem.__mul__(sys2, sys1) + + elif isinstance(sys2, (int, float, np.number)): # TODO: Scale the output raise NotImplemented("Scalar multiplication not yet implemented") + elif isinstance(sys2, np.ndarray): # TODO: Post-multiply by a matrix raise NotImplemented("Matrix multiplication not yet implemented") - elif isinstance(sys1, StateSpace) and isinstance(sys2, StateSpace): - # Special case: maintain linear systems structure - new_ss_sys = StateSpace.__rmul__(sys1, sys2) - # TODO: set input and output names - new_io_sys = LinearIOSystem(new_ss_sys) - return new_io_sys - elif not isinstance(sys2, InputOutputSystem): - raise ValueError("Unknown I/O system object ", sys1) + elif isinstance(sys2, StateSpace): + # TODO: Should eventuall preserve LinearIOSystem structure + return StateSpace.__mul__(sys2, sys1) + else: - # Both systetms are InputOutputSystems => use __mul__ - return InputOutputSystem.__mul__(sys2, sys1) + raise TypeError("Unknown I/O system object ", sys1) def __add__(sys1, sys2): """Add two input/output systems (parallel interconnection)""" # TODO: Allow addition of scalars and matrices - if not isinstance(sys2, InputOutputSystem): - raise ValueError("Unknown I/O system object ", sys2) - elif isinstance(sys1, StateSpace) and isinstance(sys2, StateSpace): - # Special case: maintain linear systems structure - new_ss_sys = StateSpace.__add__(sys1, sys2) - # TODO: set input and output names - new_io_sys = LinearIOSystem(new_ss_sys) + if isinstance(sys2, (int, float, np.number)): + # TODO: Scale the output + raise NotImplemented("Scalar addition not yet implemented") - return new_io_sys + elif isinstance(sys2, np.ndarray): + # TODO: Post-multiply by a matrix + raise NotImplemented("Matrix addition not yet implemented") + + elif not isinstance(sys2, InputOutputSystem): + raise TypeError("Unknown I/O system object ", sys2) # Make sure number of input and outputs match if sys1.ninputs != sys2.ninputs or sys1.noutputs != sys2.noutputs: @@ -271,36 +290,45 @@ def __add__(sys1, sys2): ninputs = sys1.ninputs noutputs = sys1.noutputs - inplist = [[(0,i),(1,i)] for i in range(ninputs)] - outlist = [[(0,i),(1,i)] for i in range(noutputs)] # Create a new system to handle the composition - newsys = InterconnectedSystem((sys1, sys2), inplist=inplist, outlist=outlist) + inplist = [[(0, i), (1, i)] for i in range(ninputs)] + outlist = [[(0, i), (1, i)] for i in range(noutputs)] + newsys = InterconnectedSystem( + (sys1, sys2), inplist=inplist, outlist=outlist) - # Return the newly created system + # If both systems are linear, create LinearICSystem + if isinstance(sys1, StateSpace) and isinstance(sys2, StateSpace): + ss_sys = StateSpace.__add__(sys2, sys1) + return LinearICSystem(newsys, ss_sys) + + # Return the newly created InterconnectedSystem return newsys # TODO: add __radd__ to allow postaddition by scalars and matrices def __neg__(sys): """Negate an input/output systems (rescale)""" - if isinstance(sys, StateSpace): - # Special case: maintain linear systems structure - new_ss_sys = StateSpace.__neg__(sys) - # TODO: set input and output names - new_io_sys = LinearIOSystem(new_ss_sys) - - return new_io_sys if sys.ninputs is None or sys.noutputs is None: raise ValueError("Can't determine number of inputs or outputs") - inplist = [(0,i) for i in range(sys.ninputs)] - outlist = [(0,i,-1) for i in range(sys.noutputs)] + inplist = [(0, i) for i in range(sys.ninputs)] + outlist = [(0, i, -1) for i in range(sys.noutputs)] # Create a new system to hold the negation - newsys = InterconnectedSystem((sys,), dt=sys.dt, inplist=inplist, outlist=outlist) + newsys = InterconnectedSystem( + (sys,), dt=sys.dt, inplist=inplist, outlist=outlist) + + # If the system is linear, create LinearICSystem + if isinstance(sys, StateSpace): + ss_sys = StateSpace.__neg__(sys) + return LinearICSystem(newsys, ss_sys) # Return the newly created system return newsys + def _isstatic(self): + """Check to see if a system is a static system (no states)""" + return self.nstates == 0 + # Utility function to parse a list of signals def _process_signal_list(self, signals, prefix='s'): if signals is None: @@ -330,26 +358,88 @@ def _update_params(self, params, warning=False): if (warning): warn("Parameters passed to InputOutputSystem ignored.") - def _rhs(self, t, x, u): + def _rhs(self, t, x, u, params={}): """Evaluate right hand side of a differential or difference equation. Private function used to compute the right hand side of an - input/output system model. + input/output system model. Intended for fast + evaluation; for a more user-friendly interface + you may want to use :meth:`dynamics`. """ NotImplemented("Evaluation not implemented for system of type ", type(self)) + def dynamics(self, t, x, u): + """Compute the dynamics of a differential or difference equation. + + Given time `t`, input `u` and state `x`, returns the value of the + right hand side of the dynamical system. If the system is continuous, + returns the time derivative + + dx/dt = f(t, x, u) + + where `f` is the system's (possibly nonlinear) dynamics function. + If the system is discrete-time, returns the next value of `x`: + + x[t+dt] = f(t, x[t], u[t]) + + Where `t` is a scalar. + + The inputs `x` and `u` must be of the correct length. + + Parameters + ---------- + t : float + the time at which to evaluate + x : array_like + current state + u : array_like + input + + Returns + ------- + dx/dt or x[t+dt] : ndarray + """ + return self._rhs(t, x, u) + def _out(self, t, x, u, params={}): """Evaluate the output of a system at a given state, input, and time Private function used to compute the output of of an input/output - system model given the state, input, parameters, and time. + system model given the state, input, parameters. Intended for fast + evaluation; for a more user-friendly interface you may want to use + :meth:`output`. """ # If no output function was defined in subclass, return state return x + def output(self, t, x, u): + """Compute the output of the system + + Given time `t`, input `u` and state `x`, returns the output of the + system: + + y = g(t, x, u) + + The inputs `x` and `u` must be of the correct length. + + Parameters + ---------- + t : float + the time at which to evaluate + x : array_like + current state + u : array_like + input + + Returns + ------- + y : ndarray + """ + return self._out(t, x, u) + def set_inputs(self, inputs, prefix='u'): """Set the number/names of the system inputs. @@ -422,6 +512,10 @@ def find_state(self, name): """Find the index for a state given its name (`None` if not found)""" return self.state_index.get(name, None) + def issiso(self): + """Check to see if a system is single input, single output""" + return self.ninputs == 1 and self.noutputs == 1 + def feedback(self, other=1, sign=-1, params={}): """Feedback interconnection between two input/output systems @@ -449,14 +543,14 @@ def feedback(self, other=1, sign=-1, params={}): """ # TODO: add conversion to I/O system when needed if not isinstance(other, InputOutputSystem): - raise TypeError("Feedback around I/O system must be I/O system.") - elif isinstance(self, StateSpace) and isinstance(other, StateSpace): - # Special case: maintain linear systems structure - new_ss_sys = StateSpace.feedback(self, other, sign=sign) - # TODO: set input and output names - new_io_sys = LinearIOSystem(new_ss_sys) - - return new_io_sys + # Try converting to a state space system + try: + other = _convert_to_statespace(other) + except TypeError: + raise TypeError( + "Feedback around I/O system must be an I/O system " + "or convertable to an I/O system.") + other = LinearIOSystem(other) # Make sure systems can be interconnected if self.noutputs != other.ninputs or other.noutputs != self.ninputs: @@ -464,15 +558,15 @@ def feedback(self, other=1, sign=-1, params={}): "inputs and outputs") # Make sure timebases are compatible - dt = _find_timebase(self, other) - if dt is False: - raise ValueError("System timebases are not compabile") + dt = common_timebase(self.dt, other.dt) + + inplist = [(0, i) for i in range(self.ninputs)] + outlist = [(0, i) for i in range(self.noutputs)] - inplist = [(0,i) for i in range(self.ninputs)] - outlist = [(0,i) for i in range(self.noutputs)] # Return the series interconnection between the systems - newsys = InterconnectedSystem((self, other), inplist=inplist, outlist=outlist, - params=params, dt=dt) + newsys = InterconnectedSystem( + (self, other), inplist=inplist, outlist=outlist, + params=params, dt=dt) # Set up the connecton map manually newsys.set_connect_map(np.block( @@ -482,10 +576,16 @@ def feedback(self, other=1, sign=-1, params={}): np.zeros((other.ninputs, other.noutputs))]] )) + if isinstance(self, StateSpace) and isinstance(other, StateSpace): + # Special case: maintain linear systems structure + ss_sys = StateSpace.feedback(self, other, sign=sign) + return LinearICSystem(newsys, ss_sys) + # Return the newly created system return newsys - def linearize(self, x0, u0, t=0, params={}, eps=1e-6): + def linearize(self, x0, u0, t=0, params={}, eps=1e-6, + name=None, copy=False, **kwargs): """Linearize an input/output system at a given state and input. Return the linearization of an input/output system at a given state @@ -504,8 +604,10 @@ def linearize(self, x0, u0, t=0, params={}, eps=1e-6): ninputs = _find_size(self.ninputs, u0) # Convert x0, u0 to arrays, if needed - if np.isscalar(x0): x0 = np.ones((nstates,)) * x0 - if np.isscalar(u0): u0 = np.ones((ninputs,)) * u0 + if np.isscalar(x0): + x0 = np.ones((nstates,)) * x0 + if np.isscalar(u0): + u0 = np.ones((ninputs,)) * u0 # Compute number of outputs by evaluating the output function noutputs = _find_size(self.noutputs, self._out(t, x0, u0)) @@ -530,7 +632,7 @@ def linearize(self, x0, u0, t=0, params={}, eps=1e-6): A[:, i] = (self._rhs(t, x0 + dx, u0) - F0) / eps C[:, i] = (self._out(t, x0 + dx, u0) - H0) / eps - # Perturb each of the input variables and compute linearization + # Perturb each of the input variables and compute linearization for i in range(ninputs): du = np.zeros((ninputs,)) du[i] = eps @@ -538,13 +640,33 @@ def linearize(self, x0, u0, t=0, params={}, eps=1e-6): D[:, i] = (self._out(t, x0, u0 + du) - H0) / eps # Create the state space system - linsys = StateSpace(A, B, C, D, self.dt, remove_useless=False) - return LinearIOSystem(linsys) + linsys = LinearIOSystem( + StateSpace(A, B, C, D, self.dt, remove_useless=False), + name=name, **kwargs) + + # Set the names the system, inputs, outputs, and states + if copy: + if name is None: + linsys.name = \ + config.defaults['iosys.linearized_system_name_prefix'] + \ + self.name + \ + config.defaults['iosys.linearized_system_name_suffix'] + linsys.ninputs, linsys.input_index = self.ninputs, \ + self.input_index.copy() + linsys.noutputs, linsys.output_index = \ + self.noutputs, self.output_index.copy() + linsys.nstates, linsys.state_index = \ + self.nstates, self.state_index.copy() + + return linsys def copy(self, newname=None): """Make a copy of an input/output system.""" + dup_prefix = config.defaults['iosys.duplicate_system_name_prefix'] + dup_suffix = config.defaults['iosys.duplicate_system_name_suffix'] newsys = copy.copy(self) - newsys.name = self.name_or_default("copy of " + self.name if not newname else newname) + newsys.name = self.name_or_default( + dup_prefix + self.name + dup_suffix if not newname else newname) return newsys @@ -556,7 +678,7 @@ class LinearIOSystem(InputOutputSystem, StateSpace): """ def __init__(self, linsys, inputs=None, outputs=None, states=None, - name=None): + name=None, **kwargs): """Create an I/O system from a state space linear system. Converts a :class:`~control.StateSpace` system into an @@ -580,17 +702,18 @@ def __init__(self, linsys, inputs=None, outputs=None, states=None, states : int, list of str, or None, optional Description of the system states. Same format as `inputs`. dt : None, True or float, optional - System timebase. None (default) indicates continuous - time, True indicates discrete time with undefined sampling + System timebase. 0 (default) indicates continuous + time, True indicates discrete time with unspecified sampling time, positive number is discrete time with specified - sampling time. + sampling time, None indicates unspecified timebase (either + continuous or discrete time). params : dict, optional Parameter values for the systems. Passed to the evaluation functions for the system as default values, overriding internal defaults. name : string, optional - System name (used for specifying signals). If unspecified, a generic - name is generated with a unique integer id. + System name (used for specifying signals). If unspecified, a + generic name is generated with a unique integer id. Returns ------- @@ -601,10 +724,14 @@ def __init__(self, linsys, inputs=None, outputs=None, states=None, if not isinstance(linsys, StateSpace): raise TypeError("Linear I/O system must be a state space object") + # Look for 'input' and 'output' parameter name variants + inputs = _parse_signal_parameter(inputs, 'input', kwargs) + outputs = _parse_signal_parameter(outputs, 'output', kwargs, end=True) + # Create the I/O system object super(LinearIOSystem, self).__init__( - inputs=linsys.inputs, outputs=linsys.outputs, - states=linsys.states, params={}, dt=linsys.dt, name=name) + inputs=linsys.ninputs, outputs=linsys.noutputs, + states=linsys.nstates, params={}, dt=linsys.dt, name=name) # Initalize additional state space variables StateSpace.__init__(self, linsys, remove_useless=False) @@ -612,16 +739,16 @@ def __init__(self, linsys, inputs=None, outputs=None, states=None, # Process input, output, state lists, if given # Make sure they match the size of the linear system ninputs, self.input_index = self._process_signal_list( - inputs if inputs is not None else linsys.inputs, prefix='u') - if ninputs is not None and linsys.inputs != ninputs: + inputs if inputs is not None else linsys.ninputs, prefix='u') + if ninputs is not None and linsys.ninputs != ninputs: raise ValueError("Wrong number/type of inputs given.") noutputs, self.output_index = self._process_signal_list( - outputs if outputs is not None else linsys.outputs, prefix='y') - if noutputs is not None and linsys.outputs != noutputs: + outputs if outputs is not None else linsys.noutputs, prefix='y') + if noutputs is not None and linsys.noutputs != noutputs: raise ValueError("Wrong number/type of outputs given.") nstates, self.state_index = self._process_signal_list( - states if states is not None else linsys.states, prefix='x') - if nstates is not None and linsys.states != nstates: + states if states is not None else linsys.nstates, prefix='x') + if nstates is not None and linsys.nstates != nstates: raise ValueError("Wrong number/type of states given.") def _update_params(self, params={}, warning=True): @@ -650,13 +777,13 @@ class NonlinearIOSystem(InputOutputSystem): """ def __init__(self, updfcn, outfcn=None, inputs=None, outputs=None, - states=None, params={}, dt=None, name=None): + states=None, params={}, name=None, **kwargs): """Create a nonlinear I/O system given update and output functions. - Creates an `InputOutputSystem` for a nonlinear system by specifying a - state update function and an output function. The new system can be a - continuous or discrete time system (Note: discrete-time systems not - yet supported by most function.) + Creates an :class:`~control.InputOutputSystem` for a nonlinear system + by specifying a state update function and an output function. The new + system can be a continuous or discrete time system (Note: + discrete-time systems not yet supported by most function.) Parameters ---------- @@ -702,14 +829,14 @@ def __init__(self, updfcn, outfcn=None, inputs=None, outputs=None, operating in continuous or discrete time. It can have the following values: - * dt = None No timebase specified - * dt = 0 Continuous time system - * dt > 0 Discrete time system with sampling time dt - * dt = True Discrete time with unspecified sampling time + * dt = 0: continuous time system (default) + * dt > 0: discrete time system with sampling period 'dt' + * dt = True: discrete time with unspecified sampling period + * dt = None: no timebase specified name : string, optional - System name (used for specifying signals). If unspecified, a generic - name is generated with a unique integer id. + System name (used for specifying signals). If unspecified, a + generic name is generated with a unique integer id. Returns ------- @@ -717,16 +844,25 @@ def __init__(self, updfcn, outfcn=None, inputs=None, outputs=None, Nonlinear system represented as an input/output system. """ + # Look for 'input' and 'output' parameter name variants + inputs = _parse_signal_parameter(inputs, 'input', kwargs) + outputs = _parse_signal_parameter(outputs, 'output', kwargs) + # Store the update and output functions self.updfcn = updfcn self.outfcn = outfcn # Initialize the rest of the structure + dt = kwargs.pop('dt', config.defaults['control.default_dt']) super(NonlinearIOSystem, self).__init__( inputs=inputs, outputs=outputs, states=states, params=params, dt=dt, name=name ) + # Make sure all input arguments got parsed + if kwargs: + raise TypeError("unknown parameters %s" % kwargs) + # Check to make sure arguments are consistent if updfcn is None: if self.nstates is None: @@ -748,6 +884,42 @@ def __init__(self, updfcn, outfcn=None, inputs=None, outputs=None, # Initialize current parameters to default parameters self._current_params = params.copy() + # Return the value of a static nonlinear system + def __call__(sys, u, params=None, squeeze=None): + """Evaluate a (static) nonlinearity at a given input value + + If a nonlinear I/O system has not internal state, then evaluating the + system at an input `u` gives the output `y = F(u)`, determined by the + output function. + + Parameters + ---------- + params : dict, optional + Parameter values for the system. Passed to the evaluation function + 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 + system output as a 2D array even if the system is SISO. Default + value set by config.defaults['control.squeeze_time_response']. + + """ + + # Make sure the call makes sense + if not sys._isstatic(): + raise TypeError( + "function evaluation is only supported for static " + "input/output systems") + + # If we received any parameters, update them before calling _out() + if params is not None: + sys._update_params(params) + + # Evaluate the function on the argument + out = sys._out(0, np.array((0,)), np.asarray(u)) + _, out = _process_time_response(sys, None, out, None, squeeze=squeeze) + return out + def _update_params(self, params, warning=False): # Update the current parameter values self._current_params = self.params.copy() @@ -775,7 +947,7 @@ class InterconnectedSystem(InputOutputSystem): """ def __init__(self, syslist, connections=[], inplist=[], outlist=[], inputs=None, outputs=None, states=None, - params={}, dt=None, name=None): + params={}, dt=None, name=None, **kwargs): """Create an I/O system from a list of systems + connection info. The InterconnectedSystem class is used to represent an input/output @@ -784,124 +956,33 @@ def __init__(self, syslist, connections=[], inplist=[], outlist=[], inputs to other subsystems. The overall system inputs and outputs can be any subset of subsystem inputs and outputs. - Parameters - ---------- - syslist : array_like of InputOutputSystems - The list of input/output systems to be connected - - connections : list of tuple of connection specifications, optional - Description of the internal connections between the subsystems. - - [connection1, connection2, ...] - - Each connection is a tuple that describes an input to one of the - subsystems. The entries are of the form: - - (input-spec, output-spec1, output-spec2, ...) - - The input-spec should be a tuple of the form `(subsys_i, inp_j)` - where `subsys_i` is the index into `syslist` and `inp_j` is the - index into the input vector for the subsystem. If `subsys_i` has - a single input, then the subsystem index `subsys_i` can be listed - as the input-spec. If systems and signals are given names, then - the form 'sys.sig' or ('sys', 'sig') are also recognized. - - Each output-spec should be a tuple of the form `(subsys_i, out_j, - gain)`. The input will be constructed by summing the listed - outputs after multiplying by the gain term. If the gain term is - omitted, it is assumed to be 1. If the system has a single - output, then the subsystem index `subsys_i` can be listed as the - input-spec. If systems and signals are given names, then the form - 'sys.sig', ('sys', 'sig') or ('sys', 'sig', gain) are also - recognized, and the special form '-sys.sig' can be used to specify - a signal with gain -1. - - If omitted, the connection map (matrix) can be specified using the - :func:`~control.InterconnectedSystem.set_connect_map` method. - - inplist : List of tuple of input specifications, optional - List of specifications for how the inputs for the overall system - are mapped to the subsystem inputs. The input specification is - similar to the form defined in the connection specification, except - that connections do not specify an input-spec, since these are - the system inputs. The entries are thus of the form: - - (output-spec1, output-spec2, ...) - - Each system input is added to the input for the listed subsystem. - - If omitted, the input map can be specified using the - `set_input_map` method. - - outlist : tuple of output specifications, optional - List of specifications for how the outputs for the subsystems are - mapped to overall system outputs. The output specification is the - same as the form defined in the inplist specification - (including the optional gain term). Numbered outputs must be - chosen from the list of subsystem outputs, but named outputs can - also be contained in the list of subsystem inputs. - - If omitted, the output map can be specified using the - `set_output_map` method. - - inputs : int, list of str or None, optional - 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 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. - - outputs : int, list of str or None, optional - Description of the system outputs. Same format as `inputs`. - - states : int, list of str, or None, optional - Description of the system states. Same format as `inputs`, except - the state names will be of the form '.', - for each subsys in syslist and each state_name of each subsys. - - params : dict, optional - Parameter values for the systems. Passed to the evaluation - functions for the system as default values, overriding internal - defaults. - - dt : timebase, optional - The timebase for the system, used to specify whether the system is - operating in continuous or discrete time. It can have the - following values: - - * dt = None No timebase specified - * dt = 0 Continuous time system - * dt > 0 Discrete time system with sampling time dt - * dt = True Discrete time with unspecified sampling time - - name : string, optional - System name (used for specifying signals). If unspecified, a generic - name is generated with a unique integer id. + See :func:`~control.interconnect` for a list of parameters. """ + # Look for 'input' and 'output' parameter name variants + inputs = _parse_signal_parameter(inputs, 'input', kwargs) + outputs = _parse_signal_parameter(outputs, 'output', kwargs, end=True) + # Convert input and output names to lists if they aren't already - if not isinstance(inplist, (list, tuple)): inplist = [inplist] - if not isinstance(outlist, (list, tuple)): outlist = [outlist] + if not isinstance(inplist, (list, tuple)): + inplist = [inplist] + if not isinstance(outlist, (list, tuple)): + outlist = [outlist] # Check to make sure all systems are consistent self.syslist = syslist self.syslist_index = {} - dt = None - nstates = 0; self.state_offset = [] - ninputs = 0; self.input_offset = [] - noutputs = 0; self.output_offset = [] + nstates = 0 + self.state_offset = [] + ninputs = 0 + self.input_offset = [] + noutputs = 0 + self.output_offset = [] sysobj_name_dct = {} sysname_count_dct = {} for sysidx, sys in enumerate(syslist): # Make sure time bases are consistent - # TODO: Use lti._find_timebase() instead? - if dt is None and sys.dt is not None: - # Timebase was not specified; set to match this system - dt = sys.dt - elif dt != sys.dt: - raise TypeError("System timebases are not compatible") + dt = common_timebase(dt, sys.dt) # Make sure number of inputs, outputs, states is given if sys.ninputs is None or sys.noutputs is None or \ @@ -924,14 +1005,16 @@ def __init__(self, syslist, connections=[], inplist=[], outlist=[], # Duplicates are renamed sysname_1, sysname_2, etc. if sys in sysobj_name_dct: sys = sys.copy() - warn("Duplicate object found in system list: %s. Making a copy" % str(sys)) + warn("Duplicate object found in system list: %s. " + "Making a copy" % str(sys.name)) if sys.name is not None and sys.name in sysname_count_dct: count = sysname_count_dct[sys.name] sysname_count_dct[sys.name] += 1 sysname = sys.name + "_" + str(count) sysobj_name_dct[sys] = sysname self.syslist_index[sysname] = sysidx - warn("Duplicate name found in system list. Renamed to {}".format(sysname)) + warn("Duplicate name found in system list. " + "Renamed to {}".format(sysname)) else: sysname_count_dct[sys.name] = 1 sysobj_name_dct[sys] = sys.name @@ -939,8 +1022,10 @@ def __init__(self, syslist, connections=[], inplist=[], outlist=[], if states is None: states = [] + state_name_delim = config.defaults['iosys.state_name_delim'] for sys, sysname in sysobj_name_dct.items(): - states += [sysname + '.' + statename for statename in sys.state_index.keys()] + states += [sysname + state_name_delim + + statename for statename in sys.state_index.keys()] # Create the I/O system super(InterconnectedSystem, self).__init__( @@ -965,46 +1050,44 @@ def __init__(self, syslist, connections=[], inplist=[], outlist=[], input_index = self._parse_input_spec(connection[0]) for output_spec in connection[1:]: output_index, gain = self._parse_output_spec(output_spec) - self.connect_map[input_index, output_index] = gain + if self.connect_map[input_index, output_index] != 0: + warn("multiple connections given for input %d" % + input_index + ". Combining with previous entries.") + self.connect_map[input_index, output_index] += gain # Convert the input list to a matrix: maps system to subsystems self.input_map = np.zeros((ninputs, self.ninputs)) for index, inpspec in enumerate(inplist): - if isinstance(inpspec, (int, str, tuple)): inpspec = [inpspec] + if isinstance(inpspec, (int, str, tuple)): + inpspec = [inpspec] + if not isinstance(inpspec, list): + raise ValueError("specifications in inplist must be of type " + "int, str, tuple or list.") for spec in inpspec: - self.input_map[self._parse_input_spec(spec), index] = 1 + ulist_index = self._parse_input_spec(spec) + if self.input_map[ulist_index, index] != 0: + warn("multiple connections given for input %d" % + index + ". Combining with previous entries.") + self.input_map[ulist_index, index] += 1 # Convert the output list to a matrix: maps subsystems to system self.output_map = np.zeros((self.noutputs, noutputs + ninputs)) for index, outspec in enumerate(outlist): - if isinstance(outspec, (int, str, tuple)): outspec = [outspec] + if isinstance(outspec, (int, str, tuple)): + outspec = [outspec] + if not isinstance(outspec, list): + raise ValueError("specifications in outlist must be of type " + "int, str, tuple or list.") for spec in outspec: ylist_index, gain = self._parse_output_spec(spec) - self.output_map[index, ylist_index] = gain + if self.output_map[index, ylist_index] != 0: + warn("multiple connections given for output %d" % + index + ". Combining with previous entries.") + self.output_map[index, ylist_index] += gain # Save the parameters for the system self.params = params.copy() - def __add__(self, sys): - # TODO: implement special processing to maintain flat structure - return super(InterconnectedSystem, self).__add__(sys) - - def __radd__(self, sys): - # TODO: implement special processing to maintain flat structure - return super(InterconnectedSystem, self).__radd__(sys) - - def __mul__(self, sys): - # TODO: implement special processing to maintain flat structure - return super(InterconnectedSystem, self).__mul__(sys) - - def __rmul__(self, sys): - # TODO: implement special processing to maintain flat structure - return super(InterconnectedSystem, self).__rmul__(sys) - - def __neg__(self): - # TODO: implement special processing to maintain flat structure - return super(InterconnectedSystem, self).__neg__() - def _update_params(self, params, warning=False): for sys in self.syslist: local = sys.params.copy() # start with system parameters @@ -1022,7 +1105,7 @@ def _rhs(self, t, x, u): # Go through each system and update the right hand side for that system xdot = np.zeros((self.nstates,)) # Array to hold results - state_index = 0; input_index = 0 # Start at the beginning + state_index, input_index = 0, 0 # Start at the beginning for sys in self.syslist: # Update the right hand side for this subsystem if sys.nstates != 0: @@ -1065,7 +1148,7 @@ def _compute_static_io(self, t, x, u): # TODO (later): see if there is a more efficient way to compute cycle_count = len(self.syslist) + 1 while cycle_count > 0: - state_index = 0; input_index = 0; output_index = 0 + state_index, input_index, output_index = 0, 0, 0 for sys in self.syslist: # Compute outputs for each system from current state ysys = sys._out( @@ -1078,8 +1161,8 @@ def _compute_static_io(self, t, x, u): # Store the input in the second part of ylist ylist[noutputs + input_index: - noutputs + input_index + sys.ninputs] = \ - ulist[input_index:input_index + sys.ninputs] + noutputs + input_index + sys.ninputs] = \ + ulist[input_index:input_index + sys.ninputs] # Increment the index pointers state_index += sys.nstates @@ -1123,7 +1206,9 @@ def _parse_input_spec(self, spec): """ # Parse the signal that we received - subsys_index, input_index = self._parse_signal(spec, 'input') + subsys_index, input_index, gain = self._parse_signal(spec, 'input') + if gain != 1: + raise ValueError("gain not allowed in spec '%s'." % str(spec)) # Return the index into the input vector list (ylist) return self.input_offset[subsys_index] + input_index @@ -1152,27 +1237,18 @@ def _parse_output_spec(self, spec): the gain to use for that output. """ - gain = 1 # Default gain - - # Check for special forms of the input - if isinstance(spec, tuple) and len(spec) == 3: - gain = spec[2] - spec = spec[:2] - elif isinstance(spec, str) and spec[0] == '-': - gain = -1 - spec = spec[1:] - # Parse the rest of the spec with standard signal parsing routine try: # Start by looking in the set of subsystem outputs - subsys_index, output_index = self._parse_signal(spec, 'output') + subsys_index, output_index, gain = \ + self._parse_signal(spec, 'output') # Return the index into the input vector list (ylist) return self.output_offset[subsys_index] + output_index, gain except ValueError: # Try looking in the set of subsystem *inputs* - subsys_index, input_index = self._parse_signal( + subsys_index, input_index, gain = self._parse_signal( spec, 'input or output', dictname='input_index') # Return the index into the input vector list (ylist) @@ -1197,20 +1273,31 @@ def _parse_signal(self, spec, signame='input', dictname=None): """ import re + gain = 1 # Default gain + + # Check for special forms of the input + if isinstance(spec, tuple) and len(spec) == 3: + gain = spec[2] + spec = spec[:2] + elif isinstance(spec, str) and spec[0] == '-': + gain = -1 + spec = spec[1:] + # Process cases where we are given indices as integers if isinstance(spec, int): - return spec, 0 + return spec, 0, gain elif isinstance(spec, tuple) and len(spec) == 1 \ and isinstance(spec[0], int): - return spec[0], 0 + return spec[0], 0, gain elif isinstance(spec, tuple) and len(spec) == 2 \ and all([isinstance(index, int) for index in spec]): - return spec + return spec + (gain,) # Figure out the name of the dictionary to use - if dictname is None: dictname = signame + '_index' + if dictname is None: + dictname = signame + '_index' if isinstance(spec, str): # If we got a dotted string, break up into pieces @@ -1232,7 +1319,7 @@ def _parse_signal(self, spec, signame='input', dictname=None): raise ValueError("Couldn't find %s signal '%s.%s'." % (signame, namelist[0], namelist[1])) - return system_index, signal_index + return system_index, signal_index, gain # Handle the ('sys', 'sig'), (i, j), and mixed cases elif isinstance(spec, tuple) and len(spec) == 2 and \ @@ -1245,7 +1332,7 @@ def _parse_signal(self, spec, signame='input', dictname=None): else: system_index = self._find_system(spec[0]) if system_index is None: - raise ValueError("Couldn't find system %s." % spec[0]) + raise ValueError("Couldn't find system '%s'." % spec[0]) if isinstance(spec[1], int): signal_index = spec[1] @@ -1258,7 +1345,7 @@ def _parse_signal(self, spec, signame='input', dictname=None): if signal_index is None: raise ValueError("Couldn't find signal %s.%s." % tuple(spec)) - return system_index, signal_index + return system_index, signal_index, gain else: raise ValueError("Couldn't parse signal reference %s." % str(spec)) @@ -1328,9 +1415,69 @@ def set_output_map(self, output_map): self.noutputs = output_map.shape[0] -def input_output_response(sys, T, U=0., X0=0, params={}, method='RK45', - return_x=False, squeeze=True): +class LinearICSystem(InterconnectedSystem, LinearIOSystem): + """Interconnection of a set of linear input/output systems. + This class is used to implement a system that is an interconnection of + linear input/output systems. It has all of the structure of an + :class:`~control.InterconnectedSystem`, but also maintains the requirement + elements of :class:`~control.LinearIOSystem`, including the + :class:`StateSpace` class structure, allowing it to be passed to functions + that expect a :class:`StateSpace` system. + + """ + + def __init__(self, io_sys, ss_sys=None): + if not isinstance(io_sys, InterconnectedSystem): + raise TypeError("First argument must be an interconnected system.") + + # Create the I/O system object + InputOutputSystem.__init__( + self, name=io_sys.name, params=io_sys.params) + + # Copy over the I/O systems attributes + self.syslist = io_sys.syslist + self.ninputs = io_sys.ninputs + self.noutputs = io_sys.noutputs + self.nstates = io_sys.nstates + self.input_index = io_sys.input_index + self.output_index = io_sys.output_index + self.state_index = io_sys.state_index + self.dt = io_sys.dt + + # Copy over the attributes from the interconnected system + self.syslist_index = io_sys.syslist_index + self.state_offset = io_sys.state_offset + self.input_offset = io_sys.input_offset + self.output_offset = io_sys.output_offset + self.connect_map = io_sys.connect_map + self.input_map = io_sys.input_map + self.output_map = io_sys.output_map + self.params = io_sys.params + + # If we didnt' get a state space system, linearize the full system + # TODO: this could be replaced with a direct computation (someday) + if ss_sys is None: + ss_sys = self.linearize(0, 0) + + # Initialize the state space attributes + if isinstance(ss_sys, StateSpace): + # Make sure the dimension match + if io_sys.ninputs != ss_sys.ninputs or \ + io_sys.noutputs != ss_sys.noutputs or \ + io_sys.nstates != ss_sys.nstates: + raise ValueError("System dimensions for first and second " + "arguments must match.") + StateSpace.__init__(self, ss_sys, remove_useless=False) + + else: + raise TypeError("Second argument must be a state space system.") + + +def input_output_response( + sys, T, U=0., X0=0, params={}, + transpose=False, return_x=False, squeeze=None, + solve_ivp_kwargs={}, **kwargs): """Compute the output response of a system to a given input. Simulate a dynamical system with a given input and return its output @@ -1338,38 +1485,68 @@ def input_output_response(sys, T, U=0., X0=0, params={}, method='RK45', Parameters ---------- - sys: InputOutputSystem + sys : InputOutputSystem Input/output system to simulate. - T: array-like + T : array-like Time steps at which the input is defined; values must be evenly spaced. - U: array-like or number, optional + U : array-like or number, optional Input array giving input at each time `T` (default = 0). - X0: array-like or number, optional + X0 : array-like or number, optional Initial condition (default = 0). return_x : bool, optional If True, return the values of the state at each time (default = False). squeeze : bool, optional - If True (default), squeeze unused dimensions out of the output - response. In particular, for a single output system, return a - vector of shape (nsteps) instead of (nsteps, 1). + 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 + system output as a 2D array even if the system is SISO. Default value + set by config.defaults['control.squeeze_time_response']. Returns ------- T : array Time values of the output. yout : array - Response of the system. + Response of the system. If the system is SISO and squeeze is not + True, the array is 1D (indexed by time). If the system is not SISO or + squeeze is False, the array is 2D (indexed by the output number and + time). xout : array - Time evolution of the state vector (if return_x=True) + Time evolution of the state vector (if return_x=True). + + Other parameters + ---------------- + solve_ivp_method : str, optional + Set the method used by :func:`scipy.integrate.solve_ivp`. Defaults + to 'RK45'. + solve_ivp_kwargs : str, optional + Pass additional keywords to :func:`scipy.integrate.solve_ivp`. Raises ------ TypeError If the system is not an input/output system. ValueError - If time step does not match sampling time (for discrete time systems) + If time step does not match sampling time (for discrete time systems). """ + # + # Process keyword arguments + # + + # Allow method as an alternative to solve_ivp_method + if kwargs.get('method', None): + solve_ivp_kwargs['method'] = kwargs.pop('method') + + # Figure out the method to be used + if kwargs.get('solve_ivp_method', None): + if kwargs.get('method', None): + raise ValueError("ivp_method specified more than once") + solve_ivp_kwargs['method'] = kwargs['solve_ivp_method'] + + # Set the default method to 'RK45' + if solve_ivp_kwargs.get('method', None) is None: + solve_ivp_kwargs['method'] = 'RK45' + # Sanity checking on the input if not isinstance(sys, InputOutputSystem): raise TypeError("System of type ", type(sys), " not valid") @@ -1396,11 +1573,9 @@ def input_output_response(sys, T, U=0., X0=0, params={}, method='RK45', for i in range(len(T)): u = U[i] if len(U.shape) == 1 else U[:, i] y[:, i] = sys._out(T[i], [], u) - if (squeeze): y = np.squeeze(y) - if return_x: - return T, y, [] - else: - return T, y + return _process_time_response( + sys, T, y, np.array((0, 0, np.asarray(T).size)), + transpose=transpose, return_x=return_x, squeeze=squeeze) # create X0 if not given, test if X0 has correct shape X0 = _check_convert_array(X0, [(nstates,), (nstates, 1)], @@ -1409,17 +1584,36 @@ def input_output_response(sys, T, U=0., X0=0, params={}, method='RK45', # Update the parameter values sys._update_params(params) + # + # Define a function to evaluate the input at an arbitrary time + # + # This is equivalent to the function + # + # ufun = sp.interpolate.interp1d(T, U, fill_value='extrapolate') + # + # but has a lot less overhead => simulation runs much faster + def ufun(t): + # Find the value of the index using linear interpolation + idx = np.searchsorted(T, t, side='left') + if idx == 0: + # For consistency in return type, multiple by a float + return U[..., 0] * 1. + else: + dt = (t - T[idx-1]) / (T[idx] - T[idx-1]) + return U[..., idx-1] * (1. - dt) + U[..., idx] * dt + # Create a lambda function for the right hand side - u = sp.interpolate.interp1d(T, U, fill_value="extrapolate") - def ivp_rhs(t, x): return sys._rhs(t, x, u(t)) + def ivp_rhs(t, x): + return sys._rhs(t, x, ufun(t)) # Perform the simulation if isctime(sys): if not hasattr(sp.integrate, 'solve_ivp'): raise NameError("scipy.integrate.solve_ivp not found; " "use SciPy 1.0 or greater") - soln = sp.integrate.solve_ivp(ivp_rhs, (T0, Tf), X0, t_eval=T, - method=method, vectorized=False) + soln = sp.integrate.solve_ivp( + ivp_rhs, (T0, Tf), X0, t_eval=T, + vectorized=False, **solve_ivp_kwargs) # Compute the output associated with the state (and use sys.out to # figure out the number of outputs just in case it wasn't specified) @@ -1460,10 +1654,10 @@ def ivp_rhs(t, x): return sys._rhs(t, x, u(t)) for i in range(len(T)): # Store the current state and output soln.y.append(x) - y.append(sys._out(T[i], x, u(T[i]))) + y.append(sys._out(T[i], x, ufun(T[i]))) # Update the state for the next iteration - x = sys._rhs(T[i], x, u(T[i])) + x = sys._rhs(T[i], x, ufun(T[i])) # Convert output to numpy arrays soln.y = np.transpose(np.array(soln.y)) @@ -1475,13 +1669,8 @@ def ivp_rhs(t, x): return sys._rhs(t, x, u(t)) else: # Neither ctime or dtime?? raise TypeError("Can't determine system type") - # Get rid of extra dimensions in the output, of desired - if (squeeze): y = np.squeeze(y) - - if return_x: - return soln.t, y, soln.y - else: - return soln.t, y + return _process_time_response(sys, soln.t, y, soln.y, transpose=transpose, + return_x=return_x, squeeze=squeeze) def find_eqpt(sys, x0, u0=[], y0=None, t=0, params={}, @@ -1561,9 +1750,12 @@ def find_eqpt(sys, x0, u0=[], y0=None, t=0, params={}, noutputs = _find_size(sys.noutputs, y0) # Convert x0, u0, y0 to arrays, if needed - if np.isscalar(x0): x0 = np.ones((nstates,)) * x0 - if np.isscalar(u0): u0 = np.ones((ninputs,)) * u0 - if np.isscalar(y0): y0 = np.ones((ninputs,)) * y0 + if np.isscalar(x0): + x0 = np.ones((nstates,)) * x0 + if np.isscalar(u0): + u0 = np.ones((ninputs,)) * u0 + if np.isscalar(y0): + y0 = np.ones((ninputs,)) * y0 # Discrete-time not yet supported if isdtime(sys, strict=True): @@ -1699,7 +1891,8 @@ def rootfun(z): # Compute the update and output maps dx = sys._rhs(t, x, u) - dx0 - if dtime: dx -= x # TODO: check + if dtime: + dx -= x # TODO: check dy = sys._out(t, x, u) - y0 # Map the results into the constrained variables @@ -1717,7 +1910,8 @@ def rootfun(z): z = (x, u, sys._out(t, x, u)) # Return the result based on what the user wants and what we found - if not return_y: z = z[0:2] # Strip y from result if not desired + if not return_y: + z = z[0:2] # Strip y from result if not desired if return_result: # Return whatever we got, along with the result dictionary return z + (result,) @@ -1734,7 +1928,7 @@ def linearize(sys, xeq, ueq=[], t=0, params={}, **kw): """Linearize an input/output system at a given state and input. This function computes the linearization of an input/output system at a - given state and input value and returns a :class:`control.StateSpace` + given state and input value and returns a :class:`~control.StateSpace` object. The eavaluation point need not be an equilibrium point. Parameters @@ -1753,6 +1947,19 @@ def linearize(sys, xeq, ueq=[], t=0, params={}, **kw): params : dict, optional Parameter values for the systems. Passed to the evaluation functions for the system as default values, overriding internal defaults. + copy : bool, Optional + If `copy` is True, copy the names of the input signals, output signals, + and states to the linearized system. If `name` is not specified, + the system name is set to the input system name with the string + '_linearized' appended. + name : string, optional + Set the name of the linearized system. If not specified and + if `copy` is `False`, a generic name is generated + with a unique integer id. If `copy` is `True`, the new system + name is determined by adding the prefix and suffix strings in + config.defaults['iosys.linearized_system_name_prefix'] and + config.defaults['iosys.linearized_system_name_suffix'], with the + default being to add the suffix '$linearized'. Returns ------- @@ -1766,6 +1973,17 @@ def linearize(sys, xeq, ueq=[], t=0, params={}, **kw): return sys.linearize(xeq, ueq, t=t, params=params, **kw) +# Utility function to parse a signal parameter +def _parse_signal_parameter(value, name, kwargs, end=False): + # Check kwargs for a variant of the parameter name + if value is None and name in kwargs: + value = kwargs.pop(name) + + if end and kwargs: + raise TypeError("unknown parameters %s" % kwargs) + return value + + def _find_size(sysval, vecval): """Utility function to find the size of a system parameter @@ -1786,16 +2004,415 @@ def _find_size(sysval, vecval): # Convert a state space system into an input/output system (wrapper) -def ss2io(*args, **kw): return LinearIOSystem(*args, **kw) +def ss2io(*args, **kwargs): + return LinearIOSystem(*args, **kwargs) ss2io.__doc__ = LinearIOSystem.__init__.__doc__ # Convert a transfer function into an input/output system (wrapper) -def tf2io(*args, **kw): +def tf2io(*args, **kwargs): """Convert a transfer function into an I/O system""" # TODO: add remaining documentation # Convert the system to a state space system linsys = tf2ss(*args) # Now convert the state space system to an I/O system - return LinearIOSystem(linsys, **kw) + return LinearIOSystem(linsys, **kwargs) + + +# Function to create an interconnected system +def interconnect(syslist, connections=None, inplist=[], outlist=[], + inputs=None, outputs=None, states=None, + params={}, dt=None, name=None, **kwargs): + """Interconnect a set of input/output systems. + + This function creates a new system that is an interconnection of a set of + input/output systems. If all of the input systems are linear I/O systems + (type :class:`~control.LinearIOSystem`) then the resulting system will be + a linear interconnected I/O system (type :class:`~control.LinearICSystem`) + with the appropriate inputs, outputs, and states. Otherwise, an + interconnected I/O system (type :class:`~control.InterconnectedSystem`) + will be created. + + Parameters + ---------- + syslist : list of InputOutputSystems + The list of input/output systems to be connected + + connections : list of connections, optional + Description of the internal connections between the subsystems: + + [connection1, connection2, ...] + + Each connection is itself a list that describes an input to one of the + subsystems. The entries are of the form: + + [input-spec, output-spec1, output-spec2, ...] + + The input-spec can be in a number of different forms. The lowest + level representation is a tuple of the form `(subsys_i, inp_j)` where + `subsys_i` is the index into `syslist` and `inp_j` is the index into + the input vector for the subsystem. If `subsys_i` has a single input, + then the subsystem index `subsys_i` can be listed as the input-spec. + If systems and signals are given names, then the form 'sys.sig' or + ('sys', 'sig') are also recognized. + + Similarly, each output-spec should describe an output signal from one + of the susystems. The lowest level representation is a tuple of the + form `(subsys_i, out_j, gain)`. The input will be constructed by + summing the listed outputs after multiplying by the gain term. If the + gain term is omitted, it is assumed to be 1. If the system has a + single output, then the subsystem index `subsys_i` can be listed as + the input-spec. If systems and signals are given names, then the form + 'sys.sig', ('sys', 'sig') or ('sys', 'sig', gain) are also recognized, + and the special form '-sys.sig' can be used to specify a signal with + gain -1. + + If omitted, the `interconnect` function will attempt to create the + interconnection map by connecting all signals with the same base names + (ignoring the system name). Specifically, for each input signal name + in the list of systems, if that signal name corresponds to the output + signal in any of the systems, it will be connected to that input (with + a summation across all signals if the output name occurs in more than + one system). + + The `connections` keyword can also be set to `False`, which will leave + the connection map empty and it can be specified instead using the + low-level :func:`~control.InterconnectedSystem.set_connect_map` + method. + + inplist : list of input connections, optional + List of connections for how the inputs for the overall system are + mapped to the subsystem inputs. The input specification is similar to + the form defined in the connection specification, except that + connections do not specify an input-spec, since these are the system + inputs. The entries for a connection are thus of the form: + + [input-spec1, input-spec2, ...] + + Each system input is added to the input for the listed subsystem. If + the system input connects to only one subsystem input, a single input + specification can be given (without the inner list). + + If omitted, the input map can be specified using the + :func:`~control.InterconnectedSystem.set_input_map` method. + + outlist : list of output connections, optional + List of connections for how the outputs from the subsystems are mapped + to overall system outputs. The output connection description is the + same as the form defined in the inplist specification (including the + optional gain term). Numbered outputs must be chosen from the list of + subsystem outputs, but named outputs can also be contained in the list + of subsystem inputs. + + If an output connection contains more than one signal specification, + then those signals are added together (multiplying by the any gain + term) to form the system output. + + If omitted, the output map can be specified using the + :func:`~control.InterconnectedSystem.set_output_map` method. + + inputs : int, list of str or None, optional + 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 + 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. + + outputs : int, list of str or None, optional + Description of the system outputs. Same format as `inputs`. + + states : int, list of str, or None, optional + Description of the system states. Same format as `inputs`. The + default is `None`, in which case the states will be given names of the + form '.', for each subsys in syslist and each + state_name of each subsys. + + params : dict, optional + Parameter values for the systems. Passed to the evaluation functions + for the system as default values, overriding internal defaults. + + dt : timebase, optional + The timebase for the system, used to specify whether the system is + operating in continuous or discrete time. It can have the following + values: + + * dt = 0: continuous time system (default) + * dt > 0: discrete time system with sampling period 'dt' + * dt = True: discrete time with unspecified sampling period + * dt = None: no timebase specified + + name : string, optional + System name (used for specifying signals). If unspecified, a generic + name is generated with a unique integer id. + + Example + ------- + >>> P = control.LinearIOSystem( + >>> control.rss(2, 2, 2, strictly_proper=True), name='P') + >>> C = control.LinearIOSystem(control.rss(2, 2, 2), name='C') + >>> T = control.interconnect( + >>> [P, C], + >>> connections = [ + >>> ['P.u[0]', 'C.y[0]'], ['P.u[1]', 'C.y[1]'], + >>> ['C.u[0]', '-P.y[0]'], ['C.u[1]', '-P.y[1]']], + >>> inplist = ['C.u[0]', 'C.u[1]'], + >>> outlist = ['P.y[0]', 'P.y[1]'], + >>> ) + + For a SISO system, this example can be simplified by using the + :func:`~control.summing_block` function and the ability to automatically + interconnect signals with the same names: + + >>> P = control.tf2io(control.tf(1, [1, 0]), inputs='u', outputs='y') + >>> C = control.tf2io(control.tf(10, [1, 1]), inputs='e', outputs='u') + >>> sumblk = control.summing_junction(inputs=['r', '-y'], output='e') + >>> T = control.interconnect([P, C, sumblk], input='r', output='y') + + Notes + ----- + If a system is duplicated in the list of systems to be connected, + a warning is generated a copy of the system is created with the + name of the new system determined by adding the prefix and suffix + strings in config.defaults['iosys.linearized_system_name_prefix'] + and config.defaults['iosys.linearized_system_name_suffix'], with the + default being to add the suffix '$copy'$ to the system name. + + It is possible to replace lists in most of arguments with tuples instead, + but strictly speaking the only use of tuples should be in the + specification of an input- or output-signal via the tuple notation + `(subsys_i, signal_j, gain)` (where `gain` is optional). If you get an + unexpected error message about a specification being of the wrong type, + check your use of tuples. + + In addition to its use for general nonlinear I/O systems, the + :func:`~control.interconnect` function allows linear systems to be + interconnected using named signals (compared with the + :func:`~control.connect` function, which uses signal indices) and to be + treated as both a :class:`~control.StateSpace` system as well as an + :class:`~control.InputOutputSystem`. + + The `input` and `output` keywords can be used instead of `inputs` and + `outputs`, for more natural naming of SISO systems. + + """ + # Look for 'input' and 'output' parameter name variants + inputs = _parse_signal_parameter(inputs, 'input', kwargs) + outputs = _parse_signal_parameter(outputs, 'output', kwargs, end=True) + + # If connections was not specified, set up default connection list + if connections is None: + # For each system input, look for outputs with the same name + connections = [] + for input_sys in syslist: + for input_name in input_sys.input_index.keys(): + connect = [input_sys.name + "." + input_name] + for output_sys in syslist: + if input_name in output_sys.output_index.keys(): + connect.append(output_sys.name + "." + input_name) + if len(connect) > 1: + connections.append(connect) + elif connections is False: + # Use an empty connections list + connections = [] + + # If inplist/outlist is not present, try using inputs/outputs instead + if not inplist and inputs is not None: + inplist = list(inputs) + if not outlist and outputs is not None: + outlist = list(outputs) + + # Process input list + if not isinstance(inplist, (list, tuple)): + inplist = [inplist] + new_inplist = [] + for signal in inplist: + # Create an empty connection and append to inplist + connection = [] + + # Check for signal names without a system name + if isinstance(signal, str) and len(signal.split('.')) == 1: + # Get the signal name + name = signal[1:] if signal[0] == '-' else signal + sign = '-' if signal[0] == '-' else "" + + # Look for the signal name as a system input + for sys in syslist: + if name in sys.input_index.keys(): + connection.append(sign + sys.name + "." + name) + + # Make sure we found the name + if len(connection) == 0: + raise ValueError("could not find signal %s" % name) + else: + new_inplist.append(connection) + else: + new_inplist.append(signal) + inplist = new_inplist + + # Process output list + if not isinstance(outlist, (list, tuple)): + outlist = [outlist] + new_outlist = [] + for signal in outlist: + # Create an empty connection and append to inplist + connection = [] + + # Check for signal names without a system name + if isinstance(signal, str) and len(signal.split('.')) == 1: + # Get the signal name + name = signal[1:] if signal[0] == '-' else signal + sign = '-' if signal[0] == '-' else "" + + # Look for the signal name as a system output + for sys in syslist: + if name in sys.output_index.keys(): + connection.append(sign + sys.name + "." + name) + + # Make sure we found the name + if len(connection) == 0: + raise ValueError("could not find signal %s" % name) + else: + new_outlist.append(connection) + else: + new_outlist.append(signal) + outlist = new_outlist + + newsys = InterconnectedSystem( + syslist, connections=connections, inplist=inplist, outlist=outlist, + inputs=inputs, outputs=outputs, states=states, + params=params, dt=dt, name=name) + + # If all subsystems are linear systems, maintain linear structure + if all([isinstance(sys, LinearIOSystem) for sys in syslist]): + return LinearICSystem(newsys, None) + + return newsys + + +# Summing junction +def summing_junction( + inputs=None, output=None, dimension=None, name=None, + prefix='u', **kwargs): + """Create a summing junction as an input/output system. + + This function creates a static input/output system that outputs the sum of + the inputs, potentially with a change in sign for each individual input. + The input/output system that is created by this function can be used as a + component in the :func:`~control.interconnect` function. + + Parameters + ---------- + inputs : int, string or list of strings + Description of the inputs to the summing junction. This can be given + as an integer count, a string, or a list of strings. If an integer + count is specified, the names of the input signals will be of the form + `u[i]`. + output : string, optional + Name of the system output. If not specified, the output will be 'y'. + dimension : int, optional + The dimension of the summing junction. If the dimension is set to a + positive integer, a multi-input, multi-output summing junction will be + created. The input and output signal names will be of the form + `[i]` where `signal` is the input/output signal name specified + by the `inputs` and `output` keywords. Default value is `None`. + name : string, optional + System name (used for specifying signals). If unspecified, a generic + name is generated with a unique integer id. + prefix : string, optional + If `inputs` is an integer, create the names of the states using the + given prefix (default = 'u'). The names of the input will be of the + form `prefix[i]`. + + Returns + ------- + sys : static LinearIOSystem + Linear input/output system object with no states and only a direct + term that implements the summing junction. + + Example + ------- + >>> P = control.tf2io(ct.tf(1, [1, 0]), input='u', output='y') + >>> C = control.tf2io(ct.tf(10, [1, 1]), input='e', output='u') + >>> sumblk = control.summing_junction(inputs=['r', '-y'], output='e') + >>> T = control.interconnect((P, C, sumblk), input='r', output='y') + + """ + # Utility function to parse input and output signal lists + def _parse_list(signals, signame='input', prefix='u'): + # Parse signals, including gains + if isinstance(signals, int): + nsignals = signals + names = ["%s[%d]" % (prefix, i) for i in range(nsignals)] + gains = np.ones((nsignals,)) + elif isinstance(signals, str): + nsignals = 1 + gains = [-1 if signals[0] == '-' else 1] + names = [signals[1:] if signals[0] == '-' else signals] + elif isinstance(signals, list) and \ + all([isinstance(x, str) for x in signals]): + nsignals = len(signals) + gains = np.ones((nsignals,)) + names = [] + for i in range(nsignals): + if signals[i][0] == '-': + gains[i] = -1 + names.append(signals[i][1:]) + else: + names.append(signals[i]) + else: + raise ValueError( + "could not parse %s description '%s'" + % (signame, str(signals))) + + # Return the parsed list + return nsignals, names, gains + + # Look for 'input' and 'output' parameter name variants + inputs = _parse_signal_parameter(inputs, 'input', kwargs) + output = _parse_signal_parameter(output, 'outputs', kwargs, end=True) + + # Default values for inputs and output + if inputs is None: + raise TypeError("input specification is required") + if output is None: + output = 'y' + + # Read the input list + ninputs, input_names, input_gains = _parse_list( + inputs, signame="input", prefix=prefix) + noutputs, output_names, output_gains = _parse_list( + output, signame="output", prefix='y') + if noutputs > 1: + raise NotImplementedError("vector outputs not yet supported") + + # If the dimension keyword is present, vectorize inputs and outputs + if isinstance(dimension, int) and dimension >= 1: + # Create a new list of input/output names and update parameters + input_names = ["%s[%d]" % (name, dim) + for name in input_names + for dim in range(dimension)] + ninputs = ninputs * dimension + + output_names = ["%s[%d]" % (name, dim) + for name in output_names + for dim in range(dimension)] + noutputs = noutputs * dimension + elif dimension is not None: + raise ValueError( + "unrecognized dimension value '%s'" % str(dimension)) + else: + dimension = 1 + + # Create the direct term + D = np.kron(input_gains * output_gains[0], np.eye(dimension)) + + # Create a linear system of the appropriate size + ss_sys = StateSpace( + np.zeros((0, 0)), np.ones((0, ninputs)), np.ones((noutputs, 0)), D) + + # Create a LinearIOSystem + return LinearIOSystem( + ss_sys, inputs=input_names, outputs=output_names, name=name) diff --git a/control/lti.py b/control/lti.py index 8db14794b..01d04e020 100644 --- a/control/lti.py +++ b/control/lti.py @@ -9,14 +9,17 @@ isdtime() isctime() timebase() -timebaseEqual() +common_timebase() """ import numpy as np -from numpy import absolute, real +from numpy import absolute, real, angle, abs +from warnings import warn +from . import config -__all__ = ['issiso', 'timebase', 'timebaseEqual', 'isdtime', 'isctime', - 'pole', 'zero', 'damp', 'evalfr', 'freqresp', 'dcgain'] +__all__ = ['issiso', 'timebase', 'common_timebase', 'timebaseEqual', + 'isdtime', 'isctime', 'pole', 'zero', 'damp', 'evalfr', + 'freqresp', 'dcgain'] class LTI: """LTI is a parent class to linear time-invariant (LTI) system objects. @@ -44,10 +47,46 @@ def __init__(self, inputs=1, outputs=1, dt=None): """Assign the LTI object's numbers of inputs and ouputs.""" # Data members common to StateSpace and TransferFunction. - self.inputs = inputs - self.outputs = outputs + self.ninputs = inputs + self.noutputs = outputs self.dt = dt + # + # Getter and setter functions for legacy state attributes + # + # For this iteration, generate a deprecation warning whenever the + # getter/setter is called. For a future iteration, turn it into a + # future warning, so that users will see it. + # + + @property + def inputs(self): + warn("The LTI `inputs` attribute will be deprecated in a future " + "release. Use `ninputs` instead.", + DeprecationWarning, stacklevel=2) + return self.ninputs + + @inputs.setter + def inputs(self, value): + warn("The LTI `inputs` attribute will be deprecated in a future " + "release. Use `ninputs` instead.", + DeprecationWarning, stacklevel=2) + self.ninputs = value + + @property + def outputs(self): + warn("The LTI `outputs` attribute will be deprecated in a future " + "release. Use `noutputs` instead.", + DeprecationWarning, stacklevel=2) + return self.noutputs + + @outputs.setter + def outputs(self, value): + warn("The LTI `outputs` attribute will be deprecated in a future " + "release. Use `noutputs` instead.", + DeprecationWarning, stacklevel=2) + self.noutputs = value + def isdtime(self, strict=False): """ Check to see if a system is a discrete-time system @@ -85,7 +124,7 @@ def isctime(self, strict=False): def issiso(self): '''Check to see if a system is single input, single output''' - return self.inputs == 1 and self.outputs == 1 + return self.ninputs == 1 and self.noutputs == 1 def damp(self): '''Natural frequency, damping ratio of system poles @@ -109,11 +148,74 @@ def damp(self): Z = -real(splane_poles)/wn return wn, Z, poles + def frequency_response(self, omega, squeeze=None): + """Evaluate the linear time-invariant system at an array of angular + frequencies. + + Reports the frequency response of the system, + + G(j*omega) = mag*exp(j*phase) + + for continuous time systems. For discrete time systems, the response is + evaluated around the unit circle such that + + G(exp(j*omega*dt)) = mag*exp(j*phase). + + In general the system may be multiple input, multiple output (MIMO), + where `m = self.ninputs` number of inputs and `p = self.noutputs` number + of outputs. + + Parameters + ---------- + omega : float or 1D array_like + A list, tuple, array, or scalar value of frequencies in + radians/sec at which the system will be evaluated. + squeeze : bool, optional + If squeeze=True, remove single-dimensional entries from the shape + of the output even if the system is not SISO. If squeeze=False, + keep all indices (output, input and, if omega is array_like, + frequency) even if the system is SISO. The default value can be + set using config.defaults['control.squeeze_frequency_response']. + + Returns + ------- + mag : ndarray + The magnitude (absolute value, not dB or log10) of the system + frequency response. If the system is SISO and squeeze is not + True, the array is 1D, indexed by frequency. If the system is not + SISO or squeeze is False, the array is 3D, indexed by the output, + input, and frequency. If ``squeeze`` is True then + single-dimensional axes are removed. + phase : ndarray + The wrapped phase in radians of the system frequency response. + omega : ndarray + The (sorted) frequencies at which the response was evaluated. + + """ + omega = np.sort(np.array(omega, ndmin=1)) + if isdtime(self, strict=True): + # Convert the frequency to discrete time + if np.any(omega * self.dt > np.pi): + warn("__call__: evaluation above Nyquist frequency") + s = np.exp(1j * omega * self.dt) + else: + s = 1j * omega + response = self.__call__(s, squeeze=squeeze) + return abs(response), angle(response), omega + def dcgain(self): """Return the zero-frequency gain""" raise NotImplementedError("dcgain not implemented for %s objects" % str(self.__class__)) + def _dcgain(self, warn_infinite): + zeroresp = self(0 if self.isctime() else 1, + warn_infinite=warn_infinite) + if np.all(np.logical_or(np.isreal(zeroresp), np.isnan(zeroresp.imag))): + return zeroresp.real + else: + return zeroresp + # Test to see if a system is SISO def issiso(sys, strict=False): """ @@ -157,9 +259,59 @@ def timebase(sys, strict=True): return sys.dt +def common_timebase(dt1, dt2): + """ + Find the common timebase when interconnecting systems + + Parameters + ---------- + dt1, dt2: number or system with a 'dt' attribute (e.g. TransferFunction + or StateSpace system) + + Returns + ------- + dt: number + The common timebase of dt1 and dt2, as specified in + :ref:`conventions-ref`. + + Raises + ------ + ValueError + when no compatible time base can be found + """ + # explanation: + # if either dt is None, they are compatible with anything + # if either dt is True (discrete with unspecified time base), + # use the timebase of the other, if it is also discrete + # otherwise both dts must be equal + if hasattr(dt1, 'dt'): + dt1 = dt1.dt + if hasattr(dt2, 'dt'): + dt2 = dt2.dt + + if dt1 is None: + return dt2 + elif dt2 is None: + return dt1 + elif dt1 is True: + if dt2 > 0: + return dt2 + else: + raise ValueError("Systems have incompatible timebases") + elif dt2 is True: + if dt1 > 0: + return dt1 + else: + raise ValueError("Systems have incompatible timebases") + elif np.isclose(dt1, dt2): + return dt1 + else: + raise ValueError("Systems have incompatible timebases") + # Check to see if two timebases are equal def timebaseEqual(sys1, sys2): - """Check to see if two systems have the same timebase + """ + Check to see if two systems have the same timebase timebaseEqual(sys1, sys2) @@ -168,6 +320,9 @@ def timebaseEqual(sys1, sys2): discrete or continuous timebase systems. If two systems have a discrete timebase (dt > 0) then their timebases must be equal. """ + warn("timebaseEqual will be deprecated in a future release of " + "python-control; use :func:`common_timebase` instead", + PendingDeprecationWarning) if (type(sys1.dt) == bool or type(sys2.dt) == bool): # Make sure both are unspecified discrete timebases @@ -178,27 +333,6 @@ def timebaseEqual(sys1, sys2): else: return sys1.dt == sys2.dt -# Find a common timebase between two or more systems -def _find_timebase(sys1, *sysn): - """Find the common timebase between systems, otherwise return False""" - - # Create a list of systems to check - syslist = [sys1] - syslist.append(*sysn) - - # Look for a common timebase - dt = None - - for sys in syslist: - # Make sure time bases are consistent - if (dt is None and sys.dt is not None) or \ - (dt is True and isdiscrete(sys)): - # Timebase was not specified; set to match this system - dt = sys.dt - elif dt != sys.dt: - return False - return dt - # Check to see if a system is a discrete time system def isdtime(sys, strict=False): @@ -379,24 +513,41 @@ def damp(sys, doprint=True): (p.real, p.imag, d, w)) return wn, damping, poles -def evalfr(sys, x): - """ - Evaluate the transfer function of an LTI system for a single complex - number x. +def evalfr(sys, x, squeeze=None): + """Evaluate the transfer function of an LTI system for complex frequency x. + + Returns the complex frequency response `sys(x)` where `x` is `s` for + continuous-time systems and `z` for discrete-time systems, with + `m = sys.ninputs` number of inputs and `p = sys.noutputs` number of + outputs. - To evaluate at a frequency, enter x = omega*j, where omega is the - frequency in radians + To evaluate at a frequency omega in radians per second, enter + ``x = omega * 1j`` for continuous-time systems, or + ``x = exp(1j * omega * dt)`` for discrete-time systems, or use + ``freqresp(sys, omega)``. Parameters ---------- sys: StateSpace or TransferFunction Linear system - x: scalar - Complex number + x : complex scalar or 1D array_like + Complex frequency(s) + squeeze : bool, optional (default=True) + If squeeze=True, remove single-dimensional entries from the shape of + the output even if the system is not SISO. If squeeze=False, keep all + indices (output, input and, if omega is array_like, frequency) even if + the system is SISO. The default value can be set using + config.defaults['control.squeeze_frequency_response']. Returns ------- - fresp: ndarray + fresp : complex ndarray + The frequency response of the system. If the system is SISO and + squeeze is not True, the shape of the array matches the shape of + omega. If the system is not SISO or squeeze is False, the first two + dimensions of the array are indices for the output and input and the + remaining dimensions match omega. If ``squeeze`` is True then + single-dimensional axes are removed. See Also -------- @@ -405,8 +556,8 @@ def evalfr(sys, x): Notes ----- - This function is a wrapper for StateSpace.evalfr and - TransferFunction.evalfr. + This function is a wrapper for :meth:`StateSpace.__call__` and + :meth:`TransferFunction.__call__`. Examples -------- @@ -416,33 +567,44 @@ def evalfr(sys, x): >>> # This is the transfer function matrix evaluated at s = i. .. todo:: Add example with MIMO system + """ - if issiso(sys): - return sys.horner(x)[0][0] - return sys.horner(x) + return sys.__call__(x, squeeze=squeeze) +def freqresp(sys, omega, squeeze=None): + """Frequency response of an LTI system at multiple angular frequencies. -def freqresp(sys, omega): - """ - Frequency response of an LTI system at multiple angular frequencies. + In general the system may be multiple input, multiple output (MIMO), where + `m = sys.ninputs` number of inputs and `p = sys.noutputs` number of + outputs. Parameters ---------- sys: StateSpace or TransferFunction Linear system - omega: array_like + omega : float or 1D array_like A list of frequencies in radians/sec at which the system should be evaluated. The list can be either a python list or a numpy array and will be sorted before evaluation. + squeeze : bool, optional + If squeeze=True, remove single-dimensional entries from the shape of + the output even if the system is not SISO. If squeeze=False, keep all + indices (output, input and, if omega is array_like, frequency) even if + the system is SISO. The default value can be set using + config.defaults['control.squeeze_frequency_response']. Returns ------- - mag : (self.outputs, self.inputs, len(omega)) ndarray + mag : ndarray The magnitude (absolute value, not dB or log10) of the system - frequency response. - phase : (self.outputs, self.inputs, len(omega)) ndarray + frequency response. If the system is SISO and squeeze is not True, + the array is 1D, indexed by frequency. If the system is not SISO or + squeeze is False, the array is 3D, indexed by the output, input, and + frequency. If ``squeeze`` is True then single-dimensional axes are + removed. + phase : ndarray The wrapped phase in radians of the system frequency response. - omega : ndarray or list or tuple + omega : ndarray The list of sorted frequencies at which the response was evaluated. @@ -453,9 +615,8 @@ def freqresp(sys, omega): Notes ----- - This function is a wrapper for StateSpace.freqresp and - TransferFunction.freqresp. The output omega is a sorted version of the - input omega. + This function is a wrapper for :meth:`StateSpace.frequency_response` and + :meth:`TransferFunction.frequency_response`. Examples -------- @@ -479,8 +640,9 @@ def freqresp(sys, omega): #>>> # input to the 1st output, and the phase (in radians) of the #>>> # frequency response from the 1st input to the 2nd output, for #>>> # s = 0.1i, i, 10i. + """ - return sys.freqresp(omega) + return sys.frequency_response(omega, squeeze=squeeze) def dcgain(sys): @@ -489,7 +651,40 @@ def dcgain(sys): Returns ------- gain : ndarray - The zero-frequency gain, or np.nan if the system has a pole - at the origin + The zero-frequency gain, or (inf + nanj) if the system has a pole at + the origin, (nan + nanj) if there is a pole/zero cancellation at the + origin. + """ return sys.dcgain() + + +# Process frequency responses in a uniform way +def _process_frequency_response(sys, omega, out, squeeze=None): + # Set value of squeeze argument if not set + if squeeze is None: + squeeze = config.defaults['control.squeeze_frequency_response'] + + if not hasattr(omega, '__len__'): + # received a scalar x, squeeze down the array along last dim + out = np.squeeze(out, axis=2) + + # + # Get rid of unneeded dimensions + # + # There are three possible values for the squeeze keyword at this point: + # + # squeeze=None: squeeze input/output axes iff SISO + # squeeze=True: squeeze all single dimensional axes (ala numpy) + # squeeze-False: don't squeeze any axes + # + if squeeze is True: + # Squeeze everything that we can if that's what the user wants + return np.squeeze(out) + elif squeeze is None and sys.issiso(): + # SISO system output squeezed unless explicitly specified otherwise + return out[0][0] + elif squeeze is False or squeeze is None: + return out + else: + raise ValueError("unknown squeeze value") diff --git a/control/margins.py b/control/margins.py index 03e78352f..0b53f26ed 100644 --- a/control/margins.py +++ b/control/margins.py @@ -51,11 +51,13 @@ """ import math +from warnings import warn import numpy as np import scipy as sp from . import xferfcn from .lti import issiso, evalfr from . import frdata +from . import freqplot from .exception import ControlMIMONotImplemented __all__ = ['stability_margins', 'phase_crossover_frequencies', 'margin'] @@ -156,13 +158,14 @@ def _poly_z_real_crossing(num, den, num_inv_zp, den_inv_zq, p_q, dt, epsw): return z, w -def _poly_z_mag1_crossing(num, den, num_inv, den_inv, p_q, dt, epsw): +def _poly_z_mag1_crossing(num, den, num_inv_zp, den_inv_zq, p_q, dt, epsw): # |H(z)| = 1, H(z)*H(1/z)=1, num(z)*num(1/z) == den(z)*den(1/z) - p1 = np.polymul(num, num_inv) - p2 = np.polymul(den, den_inv) + p1 = np.polymul(num, num_inv_zp) + p2 = np.polymul(den, den_inv_zq) if p_q < 0: + # * z**(-p_q) x = [1] + [0] * (-p_q) - p2 = np.polymul(p2, x) + p1 = np.polymul(p1, x) z = np.roots(np.polysub(p1, p2)) eps = np.finfo(float).eps**(1 / len(p2)) z, w = _z_filter(z, dt, eps) @@ -171,7 +174,7 @@ def _poly_z_mag1_crossing(num, den, num_inv, den_inv, p_q, dt, epsw): return z, w -def _poly_z_wstab(num, den, num_inv, den_inv, p_q, dt, epsw): +def _poly_z_wstab(num, den, num_inv_zp, den_inv_zq, p_q, dt, epsw): # Stability margin: Minimum distance to -1 # TODO: Find a way to solve for z or omega analytically with given @@ -205,68 +208,100 @@ def fun(wdt): return z, w +def _likely_numerical_inaccuracy(sys): + # crude, conservative check for if + # num(z)*num(1/z) << den(z)*den(1/z) for DT systems + num, den, num_inv_zp, den_inv_zq, p_q, dt = _poly_z_invz(sys) + p1 = np.polymul(num, num_inv_zp) + p2 = np.polymul(den, den_inv_zq) + if p_q < 0: + # * z**(-p_q) + x = [1] + [0] * (-p_q) + p1 = np.polymul(p1, x) + return np.linalg.norm(p1) < 1e-4 * np.linalg.norm(p2) # Took the framework for the old function by -# Sawyer B. Fuller , removed a lot of the innards +# Sawyer B. Fuller , removed a lot of the innards # and replaced with analytical polynomial functions for LTI systems. # -# idea for the frequency data solution copied/adapted from +# The idea for the frequency data solution copied/adapted from # https://github.com/alchemyst/Skogestad-Python/blob/master/BODE.py # Rene van Paassen # # RvP, July 8, 2014, corrected to exclude phase=0 crossing for the gain # margin polynomial +# # RvP, July 8, 2015, augmented to calculate all phase/gain crossings with # frd data. Correct to return smallest phase # margin, smallest gain margin and their frequencies -# RvP, Jun 10, 2017, modified the inclusion of roots found for phase -# crossing to include all >= 0, made subsequent calc -# insensitive to div by 0 -# also changed the selection of which crossings to -# return on basis of "A note on the Gain and Phase +# +# RvP, Jun 10, 2017, modified the inclusion of roots found for phase crossing +# to include all >= 0, made subsequent calc insensitive to +# div by 0. Also changed the selection of which crossings +# to return on basis of "A note on the Gain and Phase # Margin Concepts" Journal of Control and Systems -# Engineering, Yazdan Bavafi-Toosi, Dec 2015, vol 3 -# issue 1, pp 51-59, closer to Matlab behavior, but -# not completely identical in edge cases, which don't -# cross but touch gain=1 +# Engineering, Yazdan Bavafi-Toosi, Dec 2015, vol 3 issue +# 1, pp 51-59, closer to Matlab behavior, but not +# completely identical in edge cases, which don't cross but +# touch gain=1. +# # BG, Nov 9, 2020, removed duplicate implementations of the same code # for crossover frequencies and enhanced to handle discrete # systems -def stability_margins(sysdata, returnall=False, epsw=0.0): + + +def stability_margins(sysdata, returnall=False, epsw=0.0, method='best'): """Calculate stability margins and associated crossover frequencies. Parameters ---------- - sysdata: LTI system or (mag, phase, omega) sequence + sysdata : LTI system or (mag, phase, omega) sequence sys : LTI system - Linear SISO system + Linear SISO system representing the loop transfer function mag, phase, omega : sequence of array_like Arrays of magnitudes (absolute values, not dB), phases (degrees), and corresponding frequencies. Crossover frequencies returned are in the same units as those in `omega` (e.g., rad/sec or Hz). - returnall: bool, optional + returnall : bool, optional If true, return all margins found. If False (default), return only the minimum stability margins. For frequency data or FRD systems, only margins in the given frequency region can be found and returned. - epsw: float, optional + epsw : float, optional Frequencies below this value (default 0.0) are considered static gain, and not returned as margin. + method : string, optional + Method to use (default is 'best'): + 'poly': use polynomial method if passed a :class:`LTI` system. + 'frd': calculate crossover frequencies using numerical interpolation + of a :class:`FrequencyResponseData` representation of the system if + passed a :class:`LTI` system. + 'best': use the 'poly' method if possible, reverting to 'frd' if it is + detected that numerical inaccuracy is likey to arise in the 'poly' + method for for discrete-time systems. Returns ------- - gm: float or array_like + gm : float or array_like Gain margin - pm: float or array_loke + pm : float or array_loke Phase margin - sm: float or array_like + sm : float or array_like Stability margin, the minimum distance from the Nyquist plot to -1 - wg: float or array_like - Frequency for gain margin (at phase crossover, phase = -180 degrees) - wp: float or array_like - Frequency for phase margin (at gain crossover, gain = 1) - ws: float or array_like - Frequency for stability margin (complex gain closest to -1) + wpc : float or array_like + Phase crossover frequency (where phase crosses -180 degrees) + wgc : float or array_like + Gain crossover frequency (where gain crosses 1) + wms : float or array_like + Stability margin frequency (where Nyquist plot is closest to -1) + + Note that the gain margin is determined by the gain of the loop + transfer function at the phase crossover frequency(s), the phase + margin is determined by the phase of the loop transfer function at + the gain crossover frequency(s), and the stability margin is + determined by the frequency of maximum sensitivity (given by the + magnitude of 1/(1+L)). """ + # TODO: FRD method for cont-time systems doesn't work try: if isinstance(sysdata, frdata.FRD): sys = frdata.FRD(sysdata, smooth=True) @@ -288,35 +323,56 @@ def stability_margins(sysdata, returnall=False, epsw=0.0): raise ControlMIMONotImplemented( "Can only do margins for SISO system") + if method == 'frd': + # convert to FRD if we got a transfer function + if isinstance(sys, xferfcn.TransferFunction): + omega_sys = freqplot._default_frequency_range(sys) + if sys.isctime(): + sys = frdata.FRD(sys, omega_sys) + else: + omega_sys = omega_sys[omega_sys < np.pi / sys.dt] + sys = frdata.FRD(sys, omega_sys, smooth=True) + elif method == 'best': + # convert to FRD if anticipated numerical issues + if isinstance(sys, xferfcn.TransferFunction) and not sys.isctime(): + if _likely_numerical_inaccuracy(sys): + warn("stability_margins: Falling back to 'frd' method " + "because of chance of numerical inaccuracy in 'poly' method.", + stacklevel=2) + omega_sys = freqplot._default_frequency_range(sys) + omega_sys = omega_sys[omega_sys < np.pi / sys.dt] + sys = frdata.FRD(sys, omega_sys, smooth=True) + elif method != 'poly': + raise ValueError("method " + method + " unknown") + if isinstance(sys, xferfcn.TransferFunction): if sys.isctime(): num_iw, den_iw = _poly_iw(sys) # frequency for gain margin: phase crosses -180 degrees w_180 = _poly_iw_real_crossing(num_iw, den_iw, epsw) - with np.errstate(all='ignore'): # den=0 is okay - w180_resp = evalfr(sys, 1J * w_180) + w180_resp = sys(1J * w_180, warn_infinite=False) # den=0 is okay # frequency for phase margin : gain crosses magnitude 1 wc = _poly_iw_mag1_crossing(num_iw, den_iw, epsw) - wc_resp = evalfr(sys, 1J * wc) + wc_resp = sys(1J * wc) # stability margin wstab = _poly_iw_wstab(num_iw, den_iw, epsw) - ws_resp = evalfr(sys, 1J * wstab) + ws_resp = sys(1J * wstab) else: # Discrete Time zargs = _poly_z_invz(sys) # gain margin z, w_180 = _poly_z_real_crossing(*zargs, epsw=epsw) - w180_resp = evalfr(sys, z) + w180_resp = sys(z) # phase margin z, wc = _poly_z_mag1_crossing(*zargs, epsw=epsw) - wc_resp = evalfr(sys, z) + wc_resp = sys(z) # stability margin z, wstab = _poly_z_wstab(*zargs, epsw=epsw) - ws_resp = evalfr(sys, z) + ws_resp = sys(z) # only keep frequencies where the negative real axis is crossed w_180 = w_180[w180_resp <= 0.] @@ -339,24 +395,23 @@ def stability_margins(sysdata, returnall=False, epsw=0.0): # a bit coarse, have the interpolated frd evaluated again def _mod(w): """Calculate |G(jw)| - 1""" - return np.abs(sys._evalfr(w)[0][0]) - 1 + return np.abs(sys(1j * w)) - 1 def _arg(w): """Calculate the phase angle at -180 deg""" - return np.angle(-sys._evalfr(w)[0][0]) + return np.angle(-sys(1j * w)) def _dstab(w): """Calculate the distance from -1 point""" - return np.abs(sys._evalfr(w)[0][0] + 1.) + return np.abs(sys(1j * w) + 1.) # find the phase crossings ang(H(jw) == -180 widx = np.where(np.diff(np.sign(_arg(sys.omega))))[0] - widx = widx[np.real(sys._evalfr(sys.omega[widx])[0][0]) <= 0] + widx = widx[np.real(sys(1j * sys.omega[widx])) <= 0] w_180 = np.array( [sp.optimize.brentq(_arg, sys.omega[i], sys.omega[i+1]) for i in widx]) - # TODO: replace by evalfr(sys, 1J*w) or sys(1J*w), (needs gh-449) - w180_resp = sys._evalfr(w_180)[0][0] + w180_resp = sys(1j * w_180) # Find all crossings, note that this depends on omega having # a correct range @@ -364,7 +419,7 @@ def _dstab(w): wc = np.array( [sp.optimize.brentq(_mod, sys.omega[i], sys.omega[i+1]) for i in widx]) - wc_resp = sys._evalfr(wc)[0][0] + wc_resp = sys(1j * wc) # find all stab margins? widx, = np.where(np.diff(np.sign(np.diff(_dstab(sys.omega)))) > 0) @@ -374,7 +429,7 @@ def _dstab(w): ).x for i in widx]) wstab = wstab[(wstab >= sys.omega[0]) * (wstab <= sys.omega[-1])] - ws_resp = sys._evalfr(wstab)[0][0] + ws_resp = sys(1j * wstab) with np.errstate(all='ignore'): # |G|=0 is okay and yields inf GM = 1. / np.abs(w180_resp) @@ -398,7 +453,8 @@ def _dstab(w): (not SM.shape[0] and float('inf')) or np.amin(SM), (not gmidx != -1 and float('nan')) or w_180[gmidx][0], (not wc.shape[0] and float('nan')) or wc[pmidx][0], - (not wstab.shape[0] and float('nan')) or wstab[SM==np.amin(SM)][0]) + (not wstab.shape[0] and float('nan')) or + wstab[SM == np.amin(SM)][0]) # Contributed by Steffen Waldherr @@ -455,7 +511,7 @@ def margin(*args): ---------- sysdata : LTI system or (mag, phase, omega) sequence sys : StateSpace or TransferFunction - Linear SISO system + Linear SISO system representing the loop transfer function mag, phase, omega : sequence of array_like Input magnitude, phase (in deg.), and frequencies (rad/sec) from bode frequency response data @@ -466,17 +522,16 @@ def margin(*args): Gain margin pm : float Phase margin (in degrees) - wg: float - Frequency for gain margin (at phase crossover, phase = -180 degrees) - wp: float - Frequency for phase margin (at gain crossover, gain = 1) + wpc : float or array_like + Phase crossover frequency (where phase crosses -180 degrees) + wgc : float or array_like + Gain crossover frequency (where gain crosses 1) Margins are calculated for a SISO open-loop system. - If there is more than one gain crossover, the one at the smallest - margin (deviation from gain = 1), in absolute sense, is - returned. Likewise the smallest phase margin (in absolute sense) - is returned. + If there is more than one gain crossover, the one at the smallest margin + (deviation from gain = 1), in absolute sense, is returned. Likewise the + smallest phase margin (in absolute sense) is returned. Examples -------- @@ -491,6 +546,6 @@ def margin(*args): margin = stability_margins(args) else: raise ValueError("Margin needs 1 or 3 arguments; received %i." - % len(args)) + % len(args)) return margin[0], margin[1], margin[3], margin[4] diff --git a/control/mateqn.py b/control/mateqn.py index 0b129fd9e..28b01d287 100644 --- a/control/mateqn.py +++ b/control/mateqn.py @@ -5,9 +5,6 @@ # # Author: Bjorn Olofsson -# Python 3 compatibility (needs to go here) -from __future__ import print_function - # Copyright (c) 2011, All rights reserved. # Redistribution and use in source and binary forms, with or without @@ -44,6 +41,34 @@ from .exception import ControlSlycot, ControlArgument from .statesp import _ssmatrix +# Make sure we have access to the right slycot routines +try: + from slycot import sb03md57 + # wrap without the deprecation warning + def sb03md(n, C, A, U, dico, job='X',fact='N',trana='N',ldwork=None): + ret = sb03md57(A, U, C, dico, job, fact, trana, ldwork) + return ret[2:] +except ImportError: + try: + from slycot import sb03md + except ImportError: + sb03md = None + +try: + from slycot import sb04md +except ImportError: + sb04md = None + +try: + from slycot import sb04qd +except ImportError: + sb0qmd = None + +try: + from slycot import sg03ad +except ImportError: + sb04ad = None + __all__ = ['lyap', 'dlyap', 'dare', 'care'] # @@ -93,17 +118,12 @@ def lyap(A, Q, C=None, E=None): state space operations. See :func:`~control.use_numpy_matrix`. """ - # Make sure we have access to the right slycot routines - try: - from slycot import sb03md - except ImportError: + if sb03md is None: raise ControlSlycot("can't find slycot module 'sb03md'") - - try: - from slycot import sb04md - except ImportError: + if sb04md is None: raise ControlSlycot("can't find slycot module 'sb04md'") + # Reshape 1-d arrays if len(shape(A)) == 1: A = A.reshape(1, A.size) @@ -279,19 +299,11 @@ def dlyap(A, Q, C=None, E=None): of the same dimension. """ # Make sure we have access to the right slycot routines - try: - from slycot import sb03md - except ImportError: + if sb03md is None: raise ControlSlycot("can't find slycot module 'sb03md'") - - try: - from slycot import sb04qd - except ImportError: + if sb04qd is None: raise ControlSlycot("can't find slycot module 'sb04qd'") - - try: - from slycot import sg03ad - except ImportError: + if sg03ad is None: raise ControlSlycot("can't find slycot module 'sg03ad'") # Reshape 1-d arrays diff --git a/control/matlab/__init__.py b/control/matlab/__init__.py index 413dc6d86..196a4a6c8 100644 --- a/control/matlab/__init__.py +++ b/control/matlab/__init__.py @@ -70,7 +70,7 @@ # Import MATLAB-like functions that can be used as-is from ..ctrlutil import * -from ..freqplot import nyquist, gangof4 +from ..freqplot import gangof4 from ..nichols import nichols from ..bdalg import * from ..pzmap import * @@ -224,8 +224,8 @@ \* :func:`~control.nichols` Nichols plot \* :func:`margin` gain and phase margins \ lti/allmargin all crossover frequencies and margins -\* :func:`freqresp` frequency response over a frequency grid -\* :func:`evalfr` frequency response at single frequency +\* :func:`freqresp` frequency response +\* :func:`evalfr` frequency response at complex frequency s == ========================== ============================================ diff --git a/control/matlab/timeresp.py b/control/matlab/timeresp.py index 1ba7b2a0a..b1fa24bb0 100644 --- a/control/matlab/timeresp.py +++ b/control/matlab/timeresp.py @@ -59,45 +59,64 @@ def step(sys, T=None, X0=0., input=0, output=None, return_x=False): ''' from ..timeresp import step_response - T, yout, xout = step_response(sys, T, X0, input, output, - transpose=True, return_x=True) + # Switch output argument order and transpose outputs + out = step_response(sys, T, X0, input, output, + transpose=True, return_x=return_x) + return (out[1], out[0], out[2]) if return_x else (out[1], out[0]) - if return_x: - return yout, T, xout - return yout, T - -def stepinfo(sys, T=None, SettlingTimeThreshold=0.02, RiseTimeLimits=(0.1, 0.9)): - ''' +def stepinfo(sysdata, T=None, yfinal=None, SettlingTimeThreshold=0.02, + RiseTimeLimits=(0.1, 0.9)): + """ Step response characteristics (Rise time, Settling Time, Peak and others). Parameters ---------- - sys: StateSpace, or TransferFunction - LTI system to simulate - - T: array-like or number, optional + sysdata : StateSpace or TransferFunction or array_like + The system data. Either LTI system to similate (StateSpace, + TransferFunction), or a time series of step response data. + T : array_like or float, optional Time vector, or simulation time duration if a number (time vector is - autocomputed if not given) - - SettlingTimeThreshold: float value, optional + autocomputed if not given). + Required, if sysdata is a time series of response data. + yfinal : scalar or array_like, optional + Steady-state response. If not given, sysdata.dcgain() is used for + systems to simulate and the last value of the the response data is + used for a given time series of response data. Scalar for SISO, + (noutputs, ninputs) array_like for MIMO systems. + SettlingTimeThreshold : float, optional Defines the error to compute settling time (default = 0.02) - - RiseTimeLimits: tuple (lower_threshold, upper_theshold) + RiseTimeLimits : tuple (lower_threshold, upper_theshold) Defines the lower and upper threshold for RiseTime computation Returns ------- - S: a dictionary containing: - RiseTime: Time from 10% to 90% of the steady-state value. - SettlingTime: Time to enter inside a default error of 2% - SettlingMin: Minimum value after RiseTime - SettlingMax: Maximum value after RiseTime - Overshoot: Percentage of the Peak relative to steady value - Undershoot: Percentage of undershoot - Peak: Absolute peak value - PeakTime: time of the Peak - SteadyStateValue: Steady-state value + S : dict or list of list of dict + If `sysdata` corresponds to a SISO system, S is a dictionary + containing: + + RiseTime: + Time from 10% to 90% of the steady-state value. + SettlingTime: + Time to enter inside a default error of 2% + SettlingMin: + Minimum value after RiseTime + SettlingMax: + Maximum value after RiseTime + Overshoot: + Percentage of the Peak relative to steady value + Undershoot: + Percentage of undershoot + Peak: + Absolute peak value + PeakTime: + time of the Peak + SteadyStateValue: + Steady-state value + + If `sysdata` corresponds to a MIMO system, `S` is a 2D list of dicts. + To get the step response characteristics from the j-th input to the + i-th output, access ``S[i][j]`` See Also @@ -107,10 +126,13 @@ def stepinfo(sys, T=None, SettlingTimeThreshold=0.02, RiseTimeLimits=(0.1, 0.9)) Examples -------- >>> S = stepinfo(sys, T) - ''' + """ from ..timeresp import step_info - S = step_info(sys, T, None, SettlingTimeThreshold, RiseTimeLimits) + # Call step_info with MATLAB defaults + S = step_info(sysdata, T=T, T_num=None, yfinal=yfinal, + SettlingTimeThreshold=SettlingTimeThreshold, + RiseTimeLimits=RiseTimeLimits) return S @@ -164,13 +186,11 @@ def impulse(sys, T=None, X0=0., input=0, output=None, return_x=False): >>> yout, T = impulse(sys, T) ''' from ..timeresp import impulse_response - T, yout, xout = impulse_response(sys, T, X0, input, output, - transpose = True, return_x=True) - - if return_x: - return yout, T, xout - return yout, T + # Switch output argument order and transpose outputs + out = impulse_response(sys, T, X0, input, output, + transpose = True, return_x=return_x) + return (out[1], out[0], out[2]) if return_x else (out[1], out[0]) def initial(sys, T=None, X0=0., input=None, output=None, return_x=False): ''' @@ -222,13 +242,12 @@ def initial(sys, T=None, X0=0., input=None, output=None, return_x=False): ''' from ..timeresp import initial_response + + # Switch output argument order and transpose outputs T, yout, xout = initial_response(sys, T, X0, output=output, transpose=True, return_x=True) + return (yout, T, xout) if return_x else (yout, T) - if return_x: - return yout, T, xout - - return yout, T def lsim(sys, U=0., T=None, X0=0.): ''' @@ -273,5 +292,7 @@ def lsim(sys, U=0., T=None, X0=0.): >>> yout, T, xout = lsim(sys, U, T, X0) ''' from ..timeresp import forced_response - T, yout, xout = forced_response(sys, T, U, X0, transpose = True) - return yout, T, xout + + # Switch output argument order and transpose outputs (and always return x) + out = forced_response(sys, T, U, X0, return_x=True, transpose=True) + return out[1], out[0], out[2] diff --git a/control/matlab/wrappers.py b/control/matlab/wrappers.py index b0fda30a3..f7cbaea41 100644 --- a/control/matlab/wrappers.py +++ b/control/matlab/wrappers.py @@ -1,15 +1,18 @@ """ -Wrappers for the Matlab compatibility module +Wrappers for the MATLAB compatibility module """ import numpy as np from ..statesp import ss from ..xferfcn import tf +from ..ctrlutil import issys +from ..exception import ControlArgument from scipy.signal import zpk2tf +from warnings import warn -__all__ = ['bode', 'ngrid', 'dcgain'] +__all__ = ['bode', 'nyquist', 'ngrid', 'dcgain'] -def bode(*args, **keywords): +def bode(*args, **kwargs): """bode(syslist[, omega, dB, Hz, deg, ...]) Bode plot of the frequency response @@ -36,7 +39,7 @@ def bode(*args, **keywords): If True, plot frequency in Hz (omega must be provided in rad/sec) deg : boolean If True, return phase in degrees (else radians) - Plot : boolean + plot : boolean If True, plot magnitude and phase Examples @@ -53,19 +56,71 @@ def bode(*args, **keywords): * >>> bode(sys1, sys2, ..., sysN, w) * >>> bode(sys1, 'plotstyle1', ..., sysN, 'plotstyleN') """ + from ..freqplot import bode_plot - # If the first argument is a list, then assume python-control calling format - from ..freqplot import bode as bode_orig - if (getattr(args[0], '__iter__', False)): - return bode_orig(*args, **keywords) + # If first argument is a list, assume python-control calling format + if hasattr(args[0], '__iter__'): + return bode_plot(*args, **kwargs) - # Otherwise, run through the arguments and collect up arguments - syslist = []; plotstyle=[]; omega=None; + # Parse input arguments + syslist, omega, args, other = _parse_freqplot_args(*args) + kwargs.update(other) + + # Call the bode command + return bode_plot(syslist, omega, *args, **kwargs) + + +def nyquist(*args, **kwargs): + """nyquist(syslist[, omega]) + + Nyquist plot of the frequency response + + Plots a Nyquist plot for the system over a (optional) frequency range. + + Parameters + ---------- + sys1, ..., sysn : list of LTI + List of linear input/output systems (single system is OK). + omega : array_like + Set of frequencies to be evaluated, in rad/sec. + + Returns + ------- + real : ndarray (or list of ndarray if len(syslist) > 1)) + real part of the frequency response array + imag : ndarray (or list of ndarray if len(syslist) > 1)) + imaginary part of the frequency response array + omega : ndarray (or list of ndarray if len(syslist) > 1)) + frequencies in rad/s + + """ + from ..freqplot import nyquist_plot + + # If first argument is a list, assume python-control calling format + if hasattr(args[0], '__iter__'): + return nyquist_plot(*args, **kwargs) + + # Parse arguments + syslist, omega, args, other = _parse_freqplot_args(*args) + kwargs.update(other) + + # Call the nyquist command + kwargs['return_contour'] = True + _, contour = nyquist_plot(syslist, omega, *args, **kwargs) + + # Create the MATLAB output arguments + freqresp = syslist(contour) + real, imag = freqresp.real, freqresp.imag + return real, imag, contour.imag + + +def _parse_freqplot_args(*args): + """Parse arguments to frequency plot routines (bode, nyquist)""" + syslist, plotstyle, omega, other = [], [], None, {} i = 0; while i < len(args): # Check to see if this is a system of some sort - from ..ctrlutil import issys - if (issys(args[i])): + if issys(args[i]): # Append the system to our list of systems syslist.append(args[i]) i += 1 @@ -79,11 +134,16 @@ def bode(*args, **keywords): continue # See if this is a frequency list - elif (isinstance(args[i], (list, np.ndarray))): + elif isinstance(args[i], (list, np.ndarray)): omega = args[i] i += 1 break + # See if this is a frequency range + elif isinstance(args[i], tuple) and len(args[i]) == 2: + other['omega_limits'] = args[i] + i += 1 + else: raise ControlArgument("unrecognized argument type") @@ -93,22 +153,30 @@ def bode(*args, **keywords): # Check to make sure we got the same number of plotstyles as systems if (len(plotstyle) != 0 and len(syslist) != len(plotstyle)): - raise ControlArgument("number of systems and plotstyles should be equal") + raise ControlArgument( + "number of systems and plotstyles should be equal") # Warn about unimplemented plotstyles #! TODO: remove this when plot styles are implemented in bode() #! TODO: uncomment unit test code that tests this out if (len(plotstyle) != 0): - print("Warning (matlab.bode): plot styles not implemented"); + warn("Warning (matlab.bode): plot styles not implemented"); + + if len(syslist) == 0: + raise ControlArgument("no systems specified") + elif len(syslist) == 1: + # If only one system given, retun just that system (not a list) + syslist = syslist[0] + + return syslist, omega, plotstyle, other - # Call the bode command - return bode_orig(syslist, omega, **keywords) from ..nichols import nichols_grid def ngrid(): return nichols_grid() ngrid.__doc__ = nichols_grid.__doc__ + def dcgain(*args): ''' Compute the gain of the system in steady state. diff --git a/control/modelsimp.py b/control/modelsimp.py index 8f6124481..ec015c16b 100644 --- a/control/modelsimp.py +++ b/control/modelsimp.py @@ -395,7 +395,7 @@ def era(YY, m, n, nin, nout, r): raise NotImplementedError('This function is not implemented yet.') -def markov(Y, U, m=None, transpose=None): +def markov(Y, U, m=None, transpose=False): """Calculate the first `m` Markov parameters [D CB CAB ...] from input `U`, output `Y`. @@ -424,8 +424,7 @@ def markov(Y, U, m=None, transpose=None): Number of Markov parameters to output. Defaults to len(U). transpose : bool, optional Assume that input data is transposed relative to the standard - :ref:`time-series-convention`. The default value is true for - backward compatibility with legacy code. + :ref:`time-series-convention`. Default value is False. Returns ------- @@ -456,15 +455,6 @@ def markov(Y, U, m=None, transpose=None): >>> H = markov(Y, U, 3, transpose=False) """ - # Check on the specified format of the input - if transpose is None: - # For backwards compatibility, assume time series in rows but warn user - warnings.warn( - "Time-series data assumed to be in rows. This will change in a " - "future release. Use `transpose=True` to preserve current " - "behavior.") - transpose = True - # Convert input parameters to 2D arrays (if they aren't already) Umat = np.array(U, ndmin=2) Ymat = np.array(Y, ndmin=2) diff --git a/control/nichols.py b/control/nichols.py index ca0505957..a643d8580 100644 --- a/control/nichols.py +++ b/control/nichols.py @@ -52,7 +52,7 @@ import numpy as np import matplotlib.pyplot as plt from .ctrlutil import unwrap -from .freqplot import default_frequency_range +from .freqplot import _default_frequency_range from . import config __all__ = ['nichols_plot', 'nichols', 'nichols_grid'] @@ -91,11 +91,11 @@ def nichols_plot(sys_list, omega=None, grid=None): # Select a default range if none is provided if omega is None: - omega = default_frequency_range(sys_list) + omega = _default_frequency_range(sys_list) for sys in sys_list: # Get the magnitude and phase of the system - mag_tmp, phase_tmp, omega = sys.freqresp(omega) + mag_tmp, phase_tmp, omega = sys.frequency_response(omega) mag = np.squeeze(mag_tmp) phase = np.squeeze(phase_tmp) diff --git a/control/optimal.py b/control/optimal.py new file mode 100644 index 000000000..63509ef4f --- /dev/null +++ b/control/optimal.py @@ -0,0 +1,1328 @@ +# optimal.py - optimization based control module +# +# RMM, 11 Feb 2021 +# + +"""The :mod:`~control.optimal` module provides support for optimization-based +controllers for nonlinear systems with state and input constraints. + +""" + +import numpy as np +import scipy as sp +import scipy.optimize as opt +import control as ct +import warnings +import logging +import time + +from .timeresp import _process_time_response + +__all__ = ['find_optimal_input'] + + +class OptimalControlProblem(): + """Description of a finite horizon, optimal control problem + + The `OptimalControlProblem` class holds all of the information required to + specify and optimal control problem: the system dynamics, cost function, + and constraints. As much as possible, the information used to specify an + optimal control problem matches the notation and terminology of the SciPy + `optimize.minimize` module, with the hope that this makes it easier to + remember how to describe a problem. + + Notes + ----- + This class sets up an optimization over the inputs at each point in + time, using the integral and terminal costs as well as the + trajectory and terminal constraints. The `compute_trajectory` + method sets up an optimization problem that can be solved using + :func:`scipy.optimize.minimize`. + + The `_cost_function` method takes the information computes the cost of the + trajectory generated by the proposed input. It does this by calling a + user-defined function for the integral_cost given the current states and + inputs at each point along the trajetory and then adding the value of a + user-defined terminal cost at the final pint in the trajectory. + + The `_constraint_function` method evaluates the constraint functions along + the trajectory generated by the proposed input. As in the case of the + cost function, the constraints are evaluated at the state and input along + each point on the trjectory. This information is compared against the + constraint upper and lower bounds. The constraint function is processed + in the class initializer, so that it only needs to be computed once. + + If `basis` is specified, then the optimization is done over coefficients + of the basis elements. Otherwise, the optimization is performed over the + values of the input at the specified times (using linear interpolation for + continuous systems). + + """ + def __init__( + self, sys, timepts, integral_cost, trajectory_constraints=[], + terminal_cost=None, terminal_constraints=[], initial_guess=None, + basis=None, log=False, **kwargs): + """Set up an optimal control problem + + To describe an optimal control problem we need an input/output system, + a time horizon, a cost function, and (optionally) a set of constraints + on the state and/or input, either along the trajectory and at the + terminal time. + + Parameters + ---------- + sys : InputOutputSystem + 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. + integral_cost : callable + Function that returns the integral cost given the current state + and input. Called as integral_cost(x, u). + trajectory_constraints : list of tuples, optional + List of constraints that should hold at each point in the time + vector. Each element of the list should consist of a tuple with + first element given by :meth:`~scipy.optimize.LinearConstraint` or + :meth:`~scipy.optimize.NonlinearConstraint` and the remaining + elements of the tuple are the arguments that would be passed to + those functions. The constraints will be applied at each time + point along the trajectory. + terminal_cost : callable, optional + Function that returns the terminal cost given the current state + and input. Called as terminal_cost(x, u). + initial_guess : 1D or 2D array_like + Initial inputs to use as a guess for the optimal input. The + inputs should either be a 2D vector of shape (ninputs, horizon) + or a 1D input of shape (ninputs,) that will be broadcast by + extension of the time axis. + log : bool, optional + If `True`, turn on logging messages (using Python logging module). + kwargs : dict, optional + Additional parameters (passed to :func:`scipy.optimal.minimize`). + + Returns + ------- + ocp : OptimalControlProblem + Optimal control problem object, to be used in computing optimal + controllers. + + Additional parameters + --------------------- + solve_ivp_method : str, optional + Set the method used by :func:`scipy.integrate.solve_ivp`. + solve_ivp_kwargs : str, optional + Pass additional keywords to :func:`scipy.integrate.solve_ivp`. + minimize_method : str, optional + Set the method used by :func:`scipy.optimize.minimize`. + minimize_options : str, optional + Set the options keyword used by :func:`scipy.optimize.minimize`. + minimize_kwargs : str, optional + Pass additional keywords to :func:`scipy.optimize.minimize`. + + """ + # Save the basic information for use later + self.system = sys + self.timepts = timepts + self.integral_cost = integral_cost + self.terminal_cost = terminal_cost + self.terminal_constraints = terminal_constraints + self.basis = basis + + # Process keyword arguments + self.solve_ivp_kwargs = {} + self.solve_ivp_kwargs['method'] = kwargs.pop('solve_ivp_method', None) + self.solve_ivp_kwargs.update(kwargs.pop('solve_ivp_kwargs', {})) + + self.minimize_kwargs = {} + self.minimize_kwargs['method'] = kwargs.pop('minimize_method', None) + self.minimize_kwargs['options'] = kwargs.pop('minimize_options', {}) + self.minimize_kwargs.update(kwargs.pop('minimize_kwargs', {})) + + # Process trajectory constraints + if isinstance(trajectory_constraints, tuple): + self.trajectory_constraints = [trajectory_constraints] + elif not isinstance(trajectory_constraints, list): + raise TypeError("trajectory constraints must be a list") + else: + self.trajectory_constraints = trajectory_constraints + + # Process terminal constraints + if isinstance(terminal_constraints, tuple): + self.terminal_constraints = [terminal_constraints] + elif not isinstance(terminal_constraints, list): + raise TypeError("terminal constraints must be a list") + else: + self.terminal_constraints = terminal_constraints + + # + # Compute and store constraints + # + # While the constraints are evaluated during the execution of the + # SciPy optimization method itself, we go ahead and pre-compute the + # `scipy.optimize.NonlinearConstraint` function that will be passed to + # the optimizer on initialization, since it doesn't change. This is + # mainly a matter of computing the lower and upper bound vectors, + # which we need to "stack" to account for the evaluation at each + # trajectory time point plus any terminal constraints (in a way that + # is consistent with the `_constraint_function` that is used at + # evaluation time. + # + constraint_lb, constraint_ub, eqconst_value = [], [], [] + + # Go through each time point and stack the bounds + for t in self.timepts: + for type, fun, lb, ub in self.trajectory_constraints: + if np.all(lb == ub): + # Equality constraint + eqconst_value.append(lb) + else: + # Inequality constraint + constraint_lb.append(lb) + constraint_ub.append(ub) + + # Add on the terminal constraints + for type, fun, lb, ub in self.terminal_constraints: + if np.all(lb == ub): + # Equality constraint + eqconst_value.append(lb) + else: + # Inequality constraint + constraint_lb.append(lb) + constraint_ub.append(ub) + + # Turn constraint vectors into 1D arrays + self.constraint_lb = np.hstack(constraint_lb) if constraint_lb else [] + self.constraint_ub = np.hstack(constraint_ub) if constraint_ub else [] + self.eqconst_value = np.hstack(eqconst_value) if eqconst_value else [] + + # Create the constraints (inequality and equality) + self.constraints = [] + if len(self.constraint_lb) != 0: + self.constraints.append(sp.optimize.NonlinearConstraint( + self._constraint_function, self.constraint_lb, + self.constraint_ub)) + if len(self.eqconst_value) != 0: + self.constraints.append(sp.optimize.NonlinearConstraint( + self._eqconst_function, self.eqconst_value, + self.eqconst_value)) + + # Process the initial guess + self.initial_guess = self._process_initial_guess(initial_guess) + + # Store states, input, used later to minimize re-computation + self.last_x = np.full(self.system.nstates, np.nan) + self.last_coeffs = np.full(self.initial_guess.shape, np.nan) + + # Reset run-time statistics + self._reset_statistics(log) + + # Log information + if log: + logging.info("New optimal control problem initailized") + + # + # Cost function + # + # Given the input U = [u[0], ... u[N]], we need to compute the cost of + # the trajectory generated by that input. This means we have to + # simulate the system to get the state trajectory X = [x[0], ..., + # x[N]] and then compute the cost at each point: + # + # cost = sum_k integral_cost(x[k], u[k]) + terminal_cost(x[N], u[N]) + # + # The initial state used for generating the simulation is stored in the + # class parameter `x` prior to calling the optimization algorithm. + # + def _cost_function(self, coeffs): + if self.log: + start_time = time.process_time() + logging.info("_cost_function called at: %g", start_time) + + # Retrieve the initial state and reshape the input vector + x = self.x + coeffs = coeffs.reshape((self.system.ninputs, -1)) + + # Compute time points (if basis present) + if self.basis: + if self.log: + logging.debug("coefficients = " + str(coeffs)) + inputs = self._coeffs_to_inputs(coeffs) + else: + inputs = coeffs + + # See if we already have a simulation for this condition + if np.array_equal(coeffs, self.last_coeffs) and \ + np.array_equal(x, self.last_x): + states = self.last_states + else: + if self.log: + logging.debug("calling input_output_response from state\n" + + str(x)) + logging.debug("initial input[0:3] =\n" + str(inputs[:, 0:3])) + + # Simulate the system to get the state + _, _, states = ct.input_output_response( + self.system, self.timepts, inputs, x, return_x=True, + solve_ivp_kwargs=self.solve_ivp_kwargs) + self.system_simulations += 1 + self.last_x = x + self.last_coeffs = coeffs + self.last_states = states + + if self.log: + logging.debug("input_output_response returned states\n" + + str(states)) + + # Trajectory cost + # TODO: vectorize + if ct.isctime(self.system): + # Evaluate the costs + costs = [self.integral_cost(states[:, i], inputs[:, i]) for + i in range(self.timepts.size)] + + # Compute the time intervals + dt = np.diff(self.timepts) + + # Integrate the cost + cost = 0 + for i in range(self.timepts.size-1): + # Approximate the integral using trapezoidal rule + cost += 0.5 * (costs[i] + costs[i+1]) * dt[i] + + else: + # Sum the integral cost over the time (second) indices + # cost += self.integral_cost(states[:,i], inputs[:,i]) + cost = sum(map( + self.integral_cost, np.transpose(states), + np.transpose(inputs))) + + # Terminal cost + if self.terminal_cost is not None: + cost += self.terminal_cost(states[:, -1], inputs[:, -1]) + + # Update statistics + self.cost_evaluations += 1 + if self.log: + stop_time = time.process_time() + self.cost_process_time += stop_time - start_time + logging.info( + "_cost_function returning %g; elapsed time: %g", + cost, stop_time - start_time) + + # Return the total cost for this input sequence + return cost + + # + # Constraints + # + # We are given the constraints along the trajectory and the terminal + # constraints, which each take inputs [x, u] and evaluate the + # constraint. How we handle these depends on the type of constraint: + # + # * For linear constraints (LinearConstraint), a combined (hstack'd) + # vector of the state and input is multiplied by the polytope A matrix + # for comparison against the upper and lower bounds. + # + # * For nonlinear constraints (NonlinearConstraint), a user-specific + # constraint function having the form + # + # constraint_fun(x, u) + # + # is called at each point along the trajectory and compared against the + # upper and lower bounds. + # + # * If the upper and lower bound for the constraint are identical, then we + # separate out the evaluation into two different constraints, which + # allows the SciPy optimizers to be more efficient (and stops them from + # generating a warning about mixed constraints). This is handled + # through the use of the `_eqconst_function` and `eqconst_value` members. + # + # In both cases, the constraint is specified at a single point, but we + # extend this to apply to each point in the trajectory. This means + # that for N time points with m trajectory constraints and p terminal + # constraints we need to compute N*m + p constraints, each of which + # holds at a specific point in time, and implements the original + # constraint. + # + # To do this, we basically create a function that simulates the system + # dynamics and returns a vector of values corresponding to the value of + # the function at each time. The class initialization methods takes + # care of replicating the upper and lower bounds for each point in time + # so that the SciPy optimization algorithm can do the proper + # evaluation. + # + # In addition, since SciPy's optimization function does not allow us to + # pass arguments to the constraint function, we have to store the initial + # state prior to optimization and retrieve it here. + # + def _constraint_function(self, coeffs): + if self.log: + start_time = time.process_time() + logging.info("_constraint_function called at: %g", start_time) + + # Retrieve the initial state and reshape the input vector + x = self.x + coeffs = coeffs.reshape((self.system.ninputs, -1)) + + # Compute time points (if basis present) + if self.basis: + inputs = self._coeffs_to_inputs(coeffs) + else: + inputs = coeffs + + # See if we already have a simulation for this condition + if np.array_equal(coeffs, self.last_coeffs) \ + and np.array_equal(x, self.last_x): + states = self.last_states + else: + if self.log: + logging.debug("calling input_output_response from state\n" + + str(x)) + logging.debug("initial input[0:3] =\n" + str(inputs[:, 0:3])) + + # Simulate the system to get the state + _, _, states = ct.input_output_response( + self.system, self.timepts, inputs, x, return_x=True, + solve_ivp_kwargs=self.solve_ivp_kwargs) + self.system_simulations += 1 + self.last_x = x + self.last_coeffs = coeffs + self.last_states = states + + # Evaluate the constraint function along the trajectory + value = [] + for i, t in enumerate(self.timepts): + for type, fun, lb, ub in self.trajectory_constraints: + if np.all(lb == ub): + # Skip equality constraints + continue + elif type == opt.LinearConstraint: + # `fun` is the A matrix associated with the polytope... + value.append( + np.dot(fun, np.hstack([states[:, i], inputs[:, i]]))) + elif type == opt.NonlinearConstraint: + value.append(fun(states[:, i], inputs[:, i])) + else: + raise TypeError("unknown constraint type %s" % + constraint[0]) + + # Evaluate the terminal constraint functions + for type, fun, lb, ub in self.terminal_constraints: + if np.all(lb == ub): + # Skip equality constraints + continue + elif type == opt.LinearConstraint: + value.append( + np.dot(fun, np.hstack([states[:, i], inputs[:, i]]))) + elif type == opt.NonlinearConstraint: + value.append(fun(states[:, i], inputs[:, i])) + else: + raise TypeError("unknown constraint type %s" % + constraint[0]) + + # Update statistics + self.constraint_evaluations += 1 + if self.log: + stop_time = time.process_time() + self.constraint_process_time += stop_time - start_time + logging.info( + "_constraint_function elapsed time: %g", + stop_time - start_time) + + # Debugging information + if self.log: + logging.debug( + "constraint values\n" + str(value) + "\n" + + "lb, ub =\n" + str(self.constraint_lb) + "\n" + + str(self.constraint_ub)) + + # Return the value of the constraint function + return np.hstack(value) + + def _eqconst_function(self, coeffs): + if self.log: + start_time = time.process_time() + logging.info("_eqconst_function called at: %g", start_time) + + # Retrieve the initial state and reshape the input vector + x = self.x + coeffs = coeffs.reshape((self.system.ninputs, -1)) + + # Compute time points (if basis present) + if self.basis: + inputs = self._coeffs_to_inputs(coeffs) + else: + inputs = coeffs + + # See if we already have a simulation for this condition + if np.array_equal(coeffs, self.last_coeffs) and \ + np.array_equal(x, self.last_x): + states = self.last_states + else: + if self.log: + logging.debug("calling input_output_response from state\n" + + str(x)) + logging.debug("initial input[0:3] =\n" + str(inputs[:, 0:3])) + + # Simulate the system to get the state + _, _, states = ct.input_output_response( + self.system, self.timepts, inputs, x, return_x=True, + solve_ivp_kwargs=self.solve_ivp_kwargs) + self.system_simulations += 1 + self.last_x = x + self.last_coeffs = coeffs + self.last_states = states + + if self.log: + logging.debug("input_output_response returned states\n" + + str(states)) + + # Evaluate the constraint function along the trajectory + value = [] + for i, t in enumerate(self.timepts): + for type, fun, lb, ub in self.trajectory_constraints: + if np.any(lb != ub): + # Skip inequality constraints + continue + elif type == opt.LinearConstraint: + # `fun` is the A matrix associated with the polytope... + value.append( + np.dot(fun, np.hstack([states[:, i], inputs[:, i]]))) + elif type == opt.NonlinearConstraint: + value.append(fun(states[:, i], inputs[:, i])) + else: + raise TypeError("unknown constraint type %s" % + constraint[0]) + + # Evaluate the terminal constraint functions + for type, fun, lb, ub in self.terminal_constraints: + if np.any(lb != ub): + # Skip inequality constraints + continue + elif type == opt.LinearConstraint: + value.append( + np.dot(fun, np.hstack([states[:, i], inputs[:, i]]))) + elif type == opt.NonlinearConstraint: + value.append(fun(states[:, i], inputs[:, i])) + else: + raise TypeError("unknown constraint type %s" % + constraint[0]) + + # Update statistics + self.eqconst_evaluations += 1 + if self.log: + stop_time = time.process_time() + self.eqconst_process_time += stop_time - start_time + logging.info( + "_eqconst_function elapsed time: %g", stop_time - start_time) + + # Debugging information + if self.log: + logging.debug( + "eqconst values\n" + str(value) + "\n" + + "desired =\n" + str(self.eqconst_value)) + + # Return the value of the constraint function + return np.hstack(value) + + # + # Initial guess + # + # We store an initial guess in case it is not specified later. Note + # that create_mpc_iosystem() will reset the initial guess based on + # the current state of the MPC controller. + # + # Note: the initial guess is passed as the inputs at the given time + # vector. If a basis is specified, this is converted to coefficient + # values (which are generally of smaller dimension). + # + def _process_initial_guess(self, initial_guess): + if initial_guess is not None: + # Convert to a 1D array (or higher) + initial_guess = np.atleast_1d(initial_guess) + + # See whether we got entire guess or just first time point + if len(initial_guess.shape) == 1: + # Broadcast inputs to entire time vector + try: + initial_guess = np.broadcast_to( + initial_guess.reshape(-1, 1), + (self.system.ninputs, self.timepts.size)) + except ValueError: + raise ValueError("initial guess is the wrong shape") + + elif initial_guess.shape != \ + (self.system.ninputs, self.timepts.size): + raise ValueError("initial guess is the wrong shape") + + # If we were given a basis, project onto the basis elements + if self.basis is not None: + initial_guess = self._inputs_to_coeffs(initial_guess) + + # Reshape for use by scipy.optimize.minimize() + return initial_guess.reshape(-1) + + # Default is zero + return np.zeros( + self.system.ninputs * + (self.timepts.size if self.basis is None else self.basis.N)) + + # + # Utility function to convert input vector to coefficient vector + # + # Initially guesses from the user are passed as input vectors as a + # function of time, but internally we store the guess in terms of the + # basis coefficients. We do this by solving a least squares probelm to + # find coefficients that match the input functions at the time points (as + # much as possible, if the problem is under-determined). + # + def _inputs_to_coeffs(self, inputs): + # If there is no basis function, just return inputs as coeffs + if self.basis is None: + return inputs + + # Solve least squares problems (M x = b) for coeffs on each input + coeffs = np.zeros((self.system.ninputs, self.basis.N)) + for i in range(self.system.ninputs): + # Set up the matrices to get inputs + M = np.zeros((self.timepts.size, self.basis.N)) + b = np.zeros(self.timepts.size) + + # Evaluate at each time point and for each basis function + # TODO: vectorize + for j, t in enumerate(self.timepts): + for k in range(self.basis.N): + M[j, k] = self.basis(k, t) + b[j] = inputs[i, j] + + # Solve a least squares problem for the coefficients + alpha, residuals, rank, s = np.linalg.lstsq(M, b, rcond=None) + coeffs[i, :] = alpha + + return coeffs + + # Utility function to convert coefficient vector to input vector + def _coeffs_to_inputs(self, coeffs): + # TODO: vectorize + inputs = np.zeros((self.system.ninputs, self.timepts.size)) + for i, t in enumerate(self.timepts): + for k in range(self.basis.N): + phi_k = self.basis(k, t) + for inp in range(self.system.ninputs): + inputs[inp, i] += coeffs[inp, k] * phi_k + return inputs + + # + # Log and statistics + # + # To allow some insight into where time is being spent, we keep track of + # the number of times that various functions are called and (optionally) + # how long we spent inside each function. + # + def _reset_statistics(self, log=False): + """Reset counters for keeping track of statistics""" + self.log = log + self.cost_evaluations, self.cost_process_time = 0, 0 + self.constraint_evaluations, self.constraint_process_time = 0, 0 + self.eqconst_evaluations, self.eqconst_process_time = 0, 0 + self.system_simulations = 0 + + def _print_statistics(self, reset=True): + """Print out summary statistics from last run""" + print("Summary statistics:") + print("* Cost function calls:", self.cost_evaluations) + if self.log: + print("* Cost function process time:", self.cost_process_time) + if self.constraint_evaluations: + print("* Constraint calls:", self.constraint_evaluations) + if self.log: + print( + "* Constraint process time:", self.constraint_process_time) + if self.eqconst_evaluations: + print("* Eqconst calls:", self.eqconst_evaluations) + if self.log: + print( + "* Eqconst process time:", self.eqconst_process_time) + print("* System simulations:", self.system_simulations) + if reset: + self._reset_statistics(self.log) + + # Create an input/output system implementing an MPC controller + def _create_mpc_iosystem(self, dt=True): + """Create an I/O system implementing an MPC controller""" + def _update(t, x, u, params={}): + coeffs = x.reshape((self.system.ninputs, -1)) + if self.basis: + # Keep the coeffecients unchanged + # TODO: could compute input vector, shift, and re-project (?) + self.initial_guess = coeffs + else: + # Shift the basis elements by one time step + self.initial_guess = np.hstack( + [coeffs[:, 1:], coeffs[:, -1:]]).reshape(-1) + res = self.compute_trajectory(u, print_summary=False) + return res.inputs.reshape(-1) + + def _output(t, x, u, params={}): + if self.basis: + # TODO: compute inputs from basis elements + raise NotImplementedError("basis elements not implemented") + else: + inputs = x.reshape((self.system.ninputs, -1)) + return inputs[:, 0] + + return ct.NonlinearIOSystem( + _update, _output, dt=dt, + inputs=self.system.nstates, outputs=self.system.ninputs, + states=self.system.ninputs * + (self.timepts.size if self.basis is None else self.basis.N)) + + # Compute the optimal trajectory from the current state + def compute_trajectory( + self, x, squeeze=None, transpose=None, return_states=None, + initial_guess=None, print_summary=True, **kwargs): + """Compute the optimal input at state x + + Parameters + ---------- + x : array-like or number, optional + Initial state for the system. + return_states : bool, optional + If True, return the values of the state at each time (default = + False). + 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 + system output as a 2D array even if the system is SISO. Default + value set by config.defaults['control.squeeze_time_response']. + transpose : bool, optional + If True, assume that 2D input arrays are transposed from the + standard format. Used to convert MATLAB-style inputs to our + format. + + Returns + ------- + res : OptimalControlResult + Bundle object with the results of the optimal control problem. + res.success: bool + Boolean flag indicating whether the optimization was successful. + res.time : array + Time values of the input. + res.inputs : array + Optimal inputs for the system. If the system is SISO and squeeze + is not True, the array is 1D (indexed by time). If the system is + not SISO or squeeze is False, the array is 2D (indexed by the + output number and time). + res.states : array + Time evolution of the state vector (if return_states=True). + + """ + # Allow 'return_x` as a synonym for 'return_states' + return_states = ct.config._get_param( + 'optimal', 'return_x', kwargs, return_states, pop=True, last=True) + + # Store the initial state (for use in _constraint_function) + self.x = x + + # Allow the initial guess to be overriden + if initial_guess is None: + initial_guess = self.initial_guess + else: + initial_guess = self._process_initial_guess(initial_guess) + + # Call ScipPy optimizer + res = sp.optimize.minimize( + self._cost_function, initial_guess, + constraints=self.constraints, **self.minimize_kwargs) + + # Process and return the results + return OptimalControlResult( + self, res, transpose=transpose, return_states=return_states, + squeeze=squeeze, print_summary=print_summary) + + # Compute the current input to apply from the current state (MPC style) + def compute_mpc(self, x, squeeze=None): + """Compute the optimal input at state x + + This function calls the :meth:`compute_trajectory` method and returns + the input at the first time point. + + Parameters + ---------- + x: array-like or number, optional + Initial state for the system. + 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 + system output as a 2D array even if the system is SISO. Default + value set by config.defaults['control.squeeze_time_response']. + + Returns + ------- + input : array + Optimal input for the system at the current time. If the system + is SISO and squeeze is not True, the array is 1D (indexed by + time). If the system is not SISO or squeeze is False, the array + is 2D (indexed by the output number and time). Set to `None` + if the optimization failed. + + """ + res = self.compute_trajectory(x, squeeze=squeeze) + return inputs[:, 0] if res.success else None + + +# Optimal control result +class OptimalControlResult(sp.optimize.OptimizeResult): + """Represents the optimal control result + + This class is a subclass of :class:`sp.optimize.OptimizeResult` with + additional attributes associated with solving optimal control problems. + + Attributes + ---------- + inputs : ndarray + The optimal inputs associated with the optimal control problem. + states : ndarray + If `return_states` was set to true, stores the state trajectory + associated with the optimal input. + success : bool + Whether or not the optimizer exited successful. + problem : OptimalControlProblem + Optimal control problem that generated this solution. + + """ + def __init__( + self, ocp, res, return_states=False, print_summary=False, + transpose=None, squeeze=None): + """Create a OptimalControlResult object""" + + # Copy all of the fields we were sent by sp.optimize.minimize() + for key, val in res.items(): + setattr(self, key, val) + + # Remember the optimal control problem that we solved + self.problem = ocp + + # Reshape and process the input vector + coeffs = res.x.reshape((ocp.system.ninputs, -1)) + + # Compute time points (if basis present) + if ocp.basis: + inputs = ocp._coeffs_to_inputs(coeffs) + else: + inputs = coeffs + + # See if we got an answer + if not res.success: + warnings.warn( + "unable to solve optimal control problem\n" + "scipy.optimize.minimize returned " + res.message, UserWarning) + + # Optionally print summary information + if print_summary: + ocp._print_statistics() + + if return_states and inputs.shape[1] == ocp.timepts.shape[0]: + # Simulate the system if we need the state back + _, _, states = ct.input_output_response( + ocp.system, ocp.timepts, inputs, ocp.x, return_x=True, + solve_ivp_kwargs=ocp.solve_ivp_kwargs) + ocp.system_simulations += 1 + else: + states = None + + retval = _process_time_response( + ocp.system, ocp.timepts, inputs, states, + transpose=transpose, return_x=return_states, squeeze=squeeze) + + self.time = retval[0] + self.inputs = retval[1] + self.states = None if states is None else retval[2] + + +# Compute the input for a nonlinear, (constrained) optimal control problem +def solve_ocp( + sys, horizon, X0, cost, constraints=[], terminal_cost=None, + terminal_constraints=[], initial_guess=None, basis=None, squeeze=None, + transpose=None, return_states=False, log=False, **kwargs): + + """Compute the solution to an optimal control problem + + Parameters + ---------- + sys : InputOutputSystem + I/O system for which the optimal input will be computed. + + horizon : 1D array_like + List of times at which the optimal input should be computed. + + X0: array-like or number, optional + Initial condition (default = 0). + + cost : callable + Function that returns the integral cost given the current state + and input. Called as `cost(x, u)`. + + constraints : list of tuples, optional + List of constraints that should hold at each point in the time vector. + Each element of the list should consist of a tuple with first element + given by :meth:`scipy.optimize.LinearConstraint` or + :meth:`scipy.optimize.NonlinearConstraint` and the remaining + elements of the tuple are the arguments that would be passed to those + functions. The following tuples are supported: + + * (LinearConstraint, A, lb, ub): The matrix A is multiplied by stacked + vector of the state and input at each point on the trajectory for + comparison against the upper and lower bounds. + + * (NonlinearConstraint, fun, lb, ub): a user-specific constraint + function `fun(x, u)` is called at each point along the trajectory + and compared against the upper and lower bounds. + + The constraints are applied at each time point along the trajectory. + + terminal_cost : callable, optional + Function that returns the terminal cost given the current state + and input. Called as terminal_cost(x, u). + + terminal_constraint : list of tuples, optional + List of constraints that should hold at the end of the trajectory. + Same format as `constraints`. + + initial_guess : 1D or 2D array_like + Initial inputs to use as a guess for the optimal input. The inputs + should either be a 2D vector of shape (ninputs, horizon) or a 1D + input of shape (ninputs,) that will be broadcast by extension of the + time axis. + + log : bool, optional + If `True`, turn on logging messages (using Python logging module). + + return_states : bool, optional + If True, return the values of the state at each time (default = False). + + 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 + system output as a 2D array even if the system is SISO. Default value + set by config.defaults['control.squeeze_time_response']. + + transpose : bool, optional + If True, assume that 2D input arrays are transposed from the standard + format. Used to convert MATLAB-style inputs to our format. + + kwargs : dict, optional + Additional parameters (passed to :func:`scipy.optimal.minimize`). + + Returns + ------- + res : OptimalControlResult + Bundle object with the results of the optimal control problem. + + res.success: bool + Boolean flag indicating whether the optimization was successful. + + res.time : array + Time values of the input. + + res.inputs : array + Optimal inputs for the system. If the system is SISO and squeeze is + not True, the array is 1D (indexed by time). If the system is not + SISO or squeeze is False, the array is 2D (indexed by the output + number and time). + + res.states : array + Time evolution of the state vector (if return_states=True). + + Notes + ----- + Additional keyword parameters can be used to fine tune the behavior of + the underlying optimization and integrations functions. See + :func:`OptimalControlProblem` for more information. + + """ + # Allow 'return_x` as a synonym for 'return_states' + return_states = ct.config._get_param( + 'optimal', 'return_x', kwargs, return_states, pop=True) + + # Set up the optimal control problem + ocp = OptimalControlProblem( + sys, horizon, cost, trajectory_constraints=constraints, + terminal_cost=terminal_cost, terminal_constraints=terminal_constraints, + initial_guess=initial_guess, basis=basis, log=log, **kwargs) + + # Solve for the optimal input from the current state + return ocp.compute_trajectory( + X0, squeeze=squeeze, transpose=transpose, return_states=return_states) + + +# Create a model predictive controller for an optimal control problem +def create_mpc_iosystem( + sys, horizon, cost, constraints=[], terminal_cost=None, + terminal_constraints=[], dt=True, log=False, **kwargs): + """Create a model predictive I/O control system + + This function creates an input/output system that implements a model + predictive control for a system given the time horizon, cost function and + constraints that define the finite-horizon optimization that should be + carried out at each state. + + Parameters + ---------- + sys : InputOutputSystem + I/O system for which the optimal input will be computed. + + horizon : 1D array_like + List of times at which the optimal input should be computed. + + cost : callable + Function that returns the integral cost given the current state + and input. Called as cost(x, u). + + constraints : list of tuples, optional + List of constraints that should hold at each point in the time vector. + See :func:`~control.optimal.solve_ocp` for more details. + + terminal_cost : callable, optional + Function that returns the terminal cost given the current state + and input. Called as terminal_cost(x, u). + + terminal_constraint : list of tuples, optional + List of constraints that should hold at the end of the trajectory. + Same format as `constraints`. + + kwargs : dict, optional + Additional parameters (passed to :func:`scipy.optimal.minimize`). + + Returns + ------- + ctrl : InputOutputSystem + An I/O system taking the currrent state of the model system and + returning the current input to be applied that minimizes the cost + function while satisfying the constraints. + + Notes + ----- + Additional keyword parameters can be used to fine tune the behavior of + the underlying optimization and integrations functions. See + :func:`OptimalControlProblem` for more information. + + """ + + # Set up the optimal control problem + ocp = OptimalControlProblem( + sys, horizon, cost, trajectory_constraints=constraints, + terminal_cost=terminal_cost, terminal_constraints=terminal_constraints, + log=log, **kwargs) + + # Return an I/O system implementing the model predictive controller + return ocp._create_mpc_iosystem(dt=dt) + + +# +# Functions to create cost functions (quadratic cost function) +# +# Since a quadratic function is common as a cost function, we provide a +# function that will take a Q and R matrix and return a callable that +# evaluates to associted quadratic cost. This is compatible with the way that +# the `_cost_function` evaluates the cost at each point in the trajectory. +# +def quadratic_cost(sys, Q, R, x0=0, u0=0): + """Create quadratic cost function + + Returns a quadratic cost function that can be used for an optimal control + problem. The cost function is of the form + + cost = (x - x0)^T Q (x - x0) + (u - u0)^T R (u - u0) + + Parameters + ---------- + sys : InputOutputSystem + I/O system for which the cost function is being defined. + Q : 2D array_like + Weighting matrix for state cost. Dimensions must match system state. + R : 2D array_like + Weighting matrix for input cost. Dimensions must match system input. + x0 : 1D array + Nomimal value of the system state (for which cost should be zero). + u0 : 1D array + Nomimal value of the system input (for which cost should be zero). + + Returns + ------- + cost_fun : callable + Function that can be used to evaluate the cost at a given state and + input. The call signature of the function is cost_fun(x, u). + + """ + # Process the input arguments + if Q is not None: + Q = np.atleast_2d(Q) + if Q.size == 1: # allow scalar weights + Q = np.eye(sys.nstates) * Q.item() + elif Q.shape != (sys.nstates, sys.nstates): + raise ValueError("Q matrix is the wrong shape") + + if R is not None: + R = np.atleast_2d(R) + if R.size == 1: # allow scalar weights + R = np.eye(sys.ninputs) * R.item() + elif R.shape != (sys.ninputs, sys.ninputs): + raise ValueError("R matrix is the wrong shape") + + if Q is None: + return lambda x, u: ((u-u0) @ R @ (u-u0)).item() + + if R is None: + return lambda x, u: ((x-x0) @ Q @ (x-x0)).item() + + # Received both Q and R matrices + return lambda x, u: ((x-x0) @ Q @ (x-x0) + (u-u0) @ R @ (u-u0)).item() + + +# +# Functions to create constraints: either polytopes (A x <= b) or ranges +# (lb # <= x <= ub). +# +# As in the cost function evaluation, the main "trick" in creating a constrain +# on the state or input is to properly evaluate the constraint on the stacked +# state and input vector at the current time point. The constraint itself +# will be called at each poing along the trajectory (or the endpoint) via the +# constrain_function() method. +# +# Note that these functions to not actually evaluate the constraint, they +# simply return the information required to do so. We use the SciPy +# optimization methods LinearConstraint and NonlinearConstraint as "types" to +# keep things consistent with the terminology in scipy.optimize. +# +def state_poly_constraint(sys, A, b): + """Create state constraint from polytope + + Creates a linear constraint on the system state of the form A x <= b that + can be used as an optimal control constraint (trajectory or terminal). + + Parameters + ---------- + sys : InputOutputSystem + I/O system for which the constraint is being defined. + A : 2D array + Constraint matrix + b : 1D array + Upper bound for the constraint + + Returns + ------- + constraint : tuple + A tuple consisting of the constraint type and parameter values. + + """ + # Convert arguments to arrays and make sure dimensions are right + A = np.atleast_2d(A) + b = np.atleast_1d(b) + if len(A.shape) != 2 or A.shape[1] != sys.nstates: + raise ValueError("polytope matrix must match number of states") + elif len(b.shape) != 1 or A.shape[0] != b.shape[0]: + raise ValueError("number of bounds must match number of constraints") + + # Return a linear constraint object based on the polynomial + return (opt.LinearConstraint, + np.hstack([A, np.zeros((A.shape[0], sys.ninputs))]), + np.full(A.shape[0], -np.inf), b) + + +def state_range_constraint(sys, lb, ub): + """Create state constraint from polytope + + Creates a linear constraint on the system state that bounds the range of + the individual states to be between `lb` and `ub`. The upper and lower + bounds can be set of `inf` and `-inf` to indicate there is no constraint + or to the same value to describe an equality constraint. + + Parameters + ---------- + sys : InputOutputSystem + I/O system for which the constraint is being defined. + lb : 1D array + Lower bound for each of the states. + ub : 1D array + Upper bound for each of the states. + + Returns + ------- + constraint : tuple + A tuple consisting of the constraint type and parameter values. + + """ + # Convert bounds to lists and make sure they are the right dimension + lb = np.atleast_1d(lb) + ub = np.atleast_1d(ub) + if lb.shape != (sys.nstates,) or ub.shape != (sys.nstates,): + raise ValueError("state bounds must match number of states") + + # Return a linear constraint object based on the polynomial + return (opt.LinearConstraint, + np.hstack( + [np.eye(sys.nstates), np.zeros((sys.nstates, sys.ninputs))]), + np.array(lb), np.array(ub)) + + +# Create a constraint polytope on the system input +def input_poly_constraint(sys, A, b): + """Create input constraint from polytope + + Creates a linear constraint on the system input of the form A u <= b that + can be used as an optimal control constraint (trajectory or terminal). + + Parameters + ---------- + sys : InputOutputSystem + I/O system for which the constraint is being defined. + A : 2D array + Constraint matrix + b : 1D array + Upper bound for the constraint + + Returns + ------- + constraint : tuple + A tuple consisting of the constraint type and parameter values. + + """ + # Convert arguments to arrays and make sure dimensions are right + A = np.atleast_2d(A) + b = np.atleast_1d(b) + if len(A.shape) != 2 or A.shape[1] != sys.ninputs: + raise ValueError("polytope matrix must match number of inputs") + elif len(b.shape) != 1 or A.shape[0] != b.shape[0]: + raise ValueError("number of bounds must match number of constraints") + + # Return a linear constraint object based on the polynomial + return (opt.LinearConstraint, + np.hstack( + [np.zeros((A.shape[0], sys.nstates)), A]), + np.full(A.shape[0], -np.inf), b) + + +def input_range_constraint(sys, lb, ub): + """Create input constraint from polytope + + Creates a linear constraint on the system input that bounds the range of + the individual states to be between `lb` and `ub`. The upper and lower + bounds can be set of `inf` and `-inf` to indicate there is no constraint + or to the same value to describe an equality constraint. + + Parameters + ---------- + sys : InputOutputSystem + I/O system for which the constraint is being defined. + lb : 1D array + Lower bound for each of the inputs. + ub : 1D array + Upper bound for each of the inputs. + + Returns + ------- + constraint : tuple + A tuple consisting of the constraint type and parameter values. + + """ + # Convert bounds to lists and make sure they are the right dimension + lb = np.atleast_1d(lb) + ub = np.atleast_1d(ub) + if lb.shape != (sys.ninputs,) or ub.shape != (sys.ninputs,): + raise ValueError("input bounds must match number of inputs") + + # Return a linear constraint object based on the polynomial + return (opt.LinearConstraint, + np.hstack( + [np.zeros((sys.ninputs, sys.nstates)), np.eye(sys.ninputs)]), + lb, ub) + + +# +# Create a constraint polytope/range constraint on the system output +# +# Unlike the state and input constraints, for the output constraint we need to +# do a function evaluation before applying the constraints. +# +# TODO: for the special case of an LTI system, we can avoid the extra function +# call by multiplying the state by the C matrix for the system and then +# imposing a linear constraint: +# +# np.hstack( +# [A @ sys.C, np.zeros((A.shape[0], sys.ninputs))]) +# +def output_poly_constraint(sys, A, b): + """Create output constraint from polytope + + Creates a linear constraint on the system ouput of the form A y <= b that + can be used as an optimal control constraint (trajectory or terminal). + + Parameters + ---------- + sys : InputOutputSystem + I/O system for which the constraint is being defined. + A : 2D array + Constraint matrix + b : 1D array + Upper bound for the constraint + + Returns + ------- + constraint : tuple + A tuple consisting of the constraint type and parameter values. + + """ + # Convert arguments to arrays and make sure dimensions are right + A = np.atleast_2d(A) + b = np.atleast_1d(b) + if len(A.shape) != 2 or A.shape[1] != sys.noutputs: + raise ValueError("polytope matrix must match number of outputs") + elif len(b.shape) != 1 or A.shape[0] != b.shape[0]: + raise ValueError("number of bounds must match number of constraints") + + # Function to create the output + def _evaluate_output_poly_constraint(x, u): + return A @ sys._out(0, x, u) + + # Return a nonlinear constraint object based on the polynomial + return (opt.NonlinearConstraint, + _evaluate_output_poly_constraint, + np.full(A.shape[0], -np.inf), b) + + +def output_range_constraint(sys, lb, ub): + """Create output constraint from range + + Creates a linear constraint on the system output that bounds the range of + the individual states to be between `lb` and `ub`. The upper and lower + bounds can be set of `inf` and `-inf` to indicate there is no constraint + or to the same value to describe an equality constraint. + + Parameters + ---------- + sys : InputOutputSystem + I/O system for which the constraint is being defined. + lb : 1D array + Lower bound for each of the outputs. + ub : 1D array + Upper bound for each of the outputs. + + Returns + ------- + constraint : tuple + A tuple consisting of the constraint type and parameter values. + + """ + # Convert bounds to lists and make sure they are the right dimension + lb = np.atleast_1d(lb) + ub = np.atleast_1d(ub) + if lb.shape != (sys.noutputs,) or ub.shape != (sys.noutputs,): + raise ValueError("output bounds must match number of outputs") + + # Function to create the output + def _evaluate_output_range_constraint(x, u): + # Separate the constraint into states and inputs + return sys._out(0, x, u) + + # Return a nonlinear constraint object based on the polynomial + return (opt.NonlinearConstraint, _evaluate_output_range_constraint, lb, ub) diff --git a/control/pzmap.py b/control/pzmap.py index a7752e484..d1323e103 100644 --- a/control/pzmap.py +++ b/control/pzmap.py @@ -74,7 +74,7 @@ def pzmap(sys, plot=None, grid=None, title='Pole Zero Map', **kwargs): Returns ------- - pole: array + poles: array The systems poles zeros: array The system's zeros. diff --git a/control/rlocus.py b/control/rlocus.py index 479a833ab..2dae5a77e 100644 --- a/control/rlocus.py +++ b/control/rlocus.py @@ -137,8 +137,10 @@ def root_locus(sys, kvect=None, xlim=None, ylim=None, print_gain = config._get_param( 'rlocus', 'print_gain', print_gain, _rlocus_defaults) + sys_loop = sys if sys.issiso() else sys[0,0] + # Convert numerator and denominator to polynomials if they aren't - (nump, denp) = _systopoly1d(sys) + (nump, denp) = _systopoly1d(sys_loop) # if discrete-time system and if xlim and ylim are not given, # that we a view of the unit circle @@ -222,6 +224,14 @@ def root_locus(sys, kvect=None, xlim=None, ylim=None, ax.set_xlabel('Real') ax.set_ylabel('Imaginary') + # Set up the limits for the plot + # Note: need to do this before computing grid lines + if xlim: + ax.set_xlim(xlim) + if ylim: + ax.set_ylim(ylim) + + # Draw the grid if grid and sisotool: if isdtime(sys, strict=True): zgrid(ax=ax) @@ -233,17 +243,12 @@ def root_locus(sys, kvect=None, xlim=None, ylim=None, else: _sgrid_func() else: - ax.axhline(0., linestyle=':', color='k', zorder=-20) - ax.axvline(0., linestyle=':', color='k', zorder=-20) + ax.axhline(0., linestyle=':', color='k', linewidth=.75, zorder=-20) + ax.axvline(0., linestyle=':', color='k', linewidth=.75, zorder=-20) if isdtime(sys, strict=True): - ax.add_patch(plt.Circle((0,0), radius=1.0, - linestyle=':', edgecolor='k', linewidth=1.5, - fill=False, zorder=-20)) - - if xlim: - ax.set_xlim(xlim) - if ylim: - ax.set_ylim(ylim) + ax.add_patch(plt.Circle( + (0, 0), radius=1.0, linestyle=':', edgecolor='k', + linewidth=0.75, fill=False, zorder=-20)) return mymat, kvect @@ -476,7 +481,7 @@ def _systopoly1d(sys): sys = _convert_to_transfer_function(sys) # Make sure we have a SISO system - if (sys.inputs > 1 or sys.outputs > 1): + if not sys.issiso(): raise ControlMIMONotImplemented() # Start by extracting the numerator and denominator from system object @@ -497,7 +502,7 @@ def _RLFindRoots(nump, denp, kvect): """Find the roots for the root locus.""" # Convert numerator and denominator to polynomials if they aren't roots = [] - for k in kvect: + for k in np.array(kvect, ndmin=1): curpoly = denp + k * nump curroots = curpoly.r if len(curroots) < denp.order: @@ -537,8 +542,9 @@ def _RLSortRoots(mymat): def _RLZoomDispatcher(event, sys, ax_rlocus, plotstr): """Rootlocus plot zoom dispatcher""" + sys_loop = sys if sys.issiso() else sys[0,0] - nump, denp = _systopoly1d(sys) + nump, denp = _systopoly1d(sys_loop) xlim, ylim = ax_rlocus.get_xlim(), ax_rlocus.get_ylim() kvect, mymat, xlim, ylim = _default_gains( @@ -570,21 +576,23 @@ def _RLClickDispatcher(event, sys, fig, ax_rlocus, plotstr, sisotool=False, def _RLFeedbackClicksPoint(event, sys, fig, ax_rlocus, sisotool=False): """Display root-locus gain feedback point for clicks on root-locus plot""" - (nump, denp) = _systopoly1d(sys) + sys_loop = sys if sys.issiso() else sys[0,0] + + (nump, denp) = _systopoly1d(sys_loop) xlim = ax_rlocus.get_xlim() ylim = ax_rlocus.get_ylim() - x_tolerance = 0.05 * abs((xlim[1] - xlim[0])) - y_tolerance = 0.05 * abs((ylim[1] - ylim[0])) + x_tolerance = 0.1 * abs((xlim[1] - xlim[0])) + y_tolerance = 0.1 * abs((ylim[1] - ylim[0])) gain_tolerance = np.mean([x_tolerance, y_tolerance])*0.1 # Catch type error when event click is in the figure but not in an axis try: s = complex(event.xdata, event.ydata) - K = -1. / sys.horner(s) - K_xlim = -1. / sys.horner( + K = -1. / sys_loop(s) + K_xlim = -1. / sys_loop( complex(event.xdata + 0.05 * abs(xlim[1] - xlim[0]), event.ydata)) - K_ylim = -1. / sys.horner( + K_ylim = -1. / sys_loop( complex(event.xdata, event.ydata + 0.05 * abs(ylim[1] - ylim[0]))) except TypeError: @@ -625,7 +633,7 @@ def _RLFeedbackClicksPoint(event, sys, fig, ax_rlocus, sisotool=False): ax_rlocus.plot(s.real, s.imag, 'k.', marker='s', markersize=8, zorder=20, label='gain_point') - return K.real[0][0] + return K.real def _removeLine(label, ax): @@ -642,16 +650,22 @@ def _sgrid_func(fig=None, zeta=None, wn=None): ax = fig.gca() else: ax = fig.axes[1] + + # Get locator function for x-axis, y-axis tick marks xlocator = ax.get_xaxis().get_major_locator() + ylocator = ax.get_yaxis().get_major_locator() + # Decide on the location for the labels (?) ylim = ax.get_ylim() ytext_pos_lim = ylim[1] - (ylim[1] - ylim[0]) * 0.03 xlim = ax.get_xlim() xtext_pos_lim = xlim[0] + (xlim[1] - xlim[0]) * 0.0 + # Create a list of damping ratios, if needed if zeta is None: zeta = _default_zetas(xlim, ylim) + # Figure out the angles for the different damping ratios angles = [] for z in zeta: if (z >= 1e-4) and (z <= 1): @@ -661,11 +675,8 @@ def _sgrid_func(fig=None, zeta=None, wn=None): y_over_x = np.tan(angles) # zeta-constant lines - - index = 0 - - for yp in y_over_x: - ax.plot([0, xlocator()[0]], [0, yp*xlocator()[0]], color='gray', + for index, yp in enumerate(y_over_x): + ax.plot([0, xlocator()[0]], [0, yp * xlocator()[0]], color='gray', linestyle='dashed', linewidth=0.5) ax.plot([0, xlocator()[0]], [0, -yp * xlocator()[0]], color='gray', linestyle='dashed', linewidth=0.5) @@ -679,46 +690,104 @@ def _sgrid_func(fig=None, zeta=None, wn=None): ytext_pos = ytext_pos_lim ax.annotate(an, textcoords='data', xy=[xtext_pos, ytext_pos], fontsize=8) - index += 1 ax.plot([0, 0], [ylim[0], ylim[1]], color='gray', linestyle='dashed', linewidth=0.5) - angles = np.linspace(-90, 90, 20)*np.pi/180 + # omega-constant lines + angles = np.linspace(-90, 90, 20) * np.pi/180 if wn is None: - wn = _default_wn(xlocator(), ylim) + wn = _default_wn(xlocator(), ylocator()) for om in wn: if om < 0: - yp = np.sin(angles)*np.abs(om) - xp = -np.cos(angles)*np.abs(om) - ax.plot(xp, yp, color='gray', - linestyle='dashed', linewidth=0.5) - an = "%.2f" % -om - ax.annotate(an, textcoords='data', xy=[om, 0], fontsize=8) + # Generate the lines for natural frequency curves + yp = np.sin(angles) * np.abs(om) + xp = -np.cos(angles) * np.abs(om) + + # Plot the natural frequency contours + ax.plot(xp, yp, color='gray', linestyle='dashed', linewidth=0.5) + + # Annotate the natural frequencies by listing on x-axis + # Note: need to filter values for proper plotting in Jupyter + if (om > xlim[0]): + an = "%.2f" % -om + ax.annotate(an, textcoords='data', xy=[om, 0], fontsize=8) def _default_zetas(xlim, ylim): - """Return default list of damping coefficients""" - sep1 = -xlim[0]/4 + """Return default list of damping coefficients + + This function computes a list of damping coefficients based on the limits + of the graph. A set of 4 damping coefficients are computed for the x-axis + and a set of three damping coefficients are computed for the y-axis + (corresponding to the normal 4:3 plot aspect ratio in `matplotlib`?). + + Parameters + ---------- + xlim : array_like + List of x-axis limits [min, max] + ylim : array_like + List of y-axis limits [min, max] + + Returns + ------- + zeta : list + List of default damping coefficients for the plot + + """ + # Damping coefficient lines that intersect the x-axis + sep1 = -xlim[0] / 4 ang1 = [np.arctan((sep1*i)/ylim[1]) for i in np.arange(1, 4, 1)] + + # Damping coefficient lines that intersection the y-axis sep2 = ylim[1] / 3 ang2 = [np.arctan(-xlim[0]/(ylim[1]-sep2*i)) for i in np.arange(1, 3, 1)] + # Put the lines together and add one at -pi/2 (negative real axis) angles = np.concatenate((ang1, ang2)) angles = np.insert(angles, len(angles), np.pi/2) + + # Return the damping coefficients corresponding to these angles zeta = np.sin(angles) return zeta.tolist() -def _default_wn(xloc, ylim): - """Return default wn for root locus plot""" +def _default_wn(xloc, yloc, max_lines=7): + """Return default wn for root locus plot + + This function computes a list of natural frequencies based on the grid + parameters of the graph. + + Parameters + ---------- + xloc : array_like + List of x-axis tick values + ylim : array_like + List of y-axis limits [min, max] + max_lines : int, optional + Maximum number of frequencies to generate (default = 7) + + Returns + ------- + wn : list + List of default natural frequencies for the plot + + """ + sep = xloc[1]-xloc[0] # separation between x-ticks + + # Decide whether to use the x or y axis for determining wn + if yloc[-1] / sep > max_lines*10: + # y-axis scale >> x-axis scale + wn = yloc # one frequency per y-axis tick mark + else: + wn = xloc # one frequency per x-axis tick mark - wn = xloc - sep = xloc[1]-xloc[0] - while np.abs(wn[0]) < ylim[1]: - wn = np.insert(wn, 0, wn[0]-sep) + # Insert additional frequencies to span the y-axis + while np.abs(wn[0]) < yloc[-1]: + wn = np.insert(wn, 0, wn[0]-sep) - while len(wn) > 7: + # If there are too many values, cut them in half + while len(wn) > max_lines: wn = wn[0:-1:2] return wn diff --git a/control/robust.py b/control/robust.py index 2584339ac..aa5c922dc 100644 --- a/control/robust.py +++ b/control/robust.py @@ -206,12 +206,12 @@ def _size_as_needed(w, wname, n): if w is not None: if not isinstance(w, StateSpace): w = ss(w) - if 1 == w.inputs and 1 == w.outputs: + if 1 == w.ninputs and 1 == w.noutputs: w = append(*(w,) * n) else: - if w.inputs != n: + if w.ninputs != n: msg = ("{}: weighting function has {} inputs, expected {}". - format(wname, w.inputs, n)) + format(wname, w.ninputs, n)) raise ValueError(msg) else: w = ss([], [], [], []) @@ -253,8 +253,8 @@ def augw(g, w1=None, w2=None, w3=None): if w1 is None and w2 is None and w3 is None: raise ValueError("At least one weighting must not be None") - ny = g.outputs - nu = g.inputs + ny = g.noutputs + nu = g.ninputs w1, w2, w3 = [_size_as_needed(w, wname, n) for w, wname, n in zip((w1, w2, w3), @@ -278,13 +278,13 @@ def augw(g, w1=None, w2=None, w3=None): sysall = append(w1, w2, w3, Ie, g, Iu) - niw1 = w1.inputs - niw2 = w2.inputs - niw3 = w3.inputs + niw1 = w1.ninputs + niw2 = w2.ninputs + niw3 = w3.ninputs - now1 = w1.outputs - now2 = w2.outputs - now3 = w3.outputs + now1 = w1.noutputs + now2 = w2.noutputs + now3 = w3.noutputs q = np.zeros((niw1 + niw2 + niw3 + ny + nu, 2)) q[:, 0] = np.arange(1, q.shape[0] + 1) @@ -358,8 +358,8 @@ def mixsyn(g, w1=None, w2=None, w3=None): -------- hinfsyn, augw """ - nmeas = g.outputs - ncon = g.inputs + nmeas = g.noutputs + ncon = g.ninputs p = augw(g, w1, w2, w3) k, cl, gamma, rcond = hinfsyn(p, nmeas, ncon) diff --git a/control/sisotool.py b/control/sisotool.py index 32853971a..bfd93736e 100644 --- a/control/sisotool.py +++ b/control/sisotool.py @@ -1,17 +1,19 @@ __all__ = ['sisotool'] +from control.exception import ControlMIMONotImplemented from .freqplot import bode_plot from .timeresp import step_response from .lti import issiso, isdtime +from .xferfcn import TransferFunction +from .bdalg import append, connect import matplotlib import matplotlib.pyplot as plt import warnings -def sisotool(sys, kvect = None, xlim_rlocus = None, ylim_rlocus = None, - plotstr_rlocus = 'b' if int(matplotlib.__version__[0]) == 1 else 'C0', - rlocus_grid = False, omega = None, dB = None, Hz = None, - deg = None, omega_limits = None, omega_num = None, - margins_bode = True, tvect=None): +def sisotool(sys, kvect=None, xlim_rlocus=None, ylim_rlocus=None, + plotstr_rlocus='C0', rlocus_grid=False, omega=None, dB=None, + Hz=None, deg=None, omega_limits=None, omega_num=None, + margins_bode=True, tvect=None): """ Sisotool style collection of plots inspired by MATLAB's sisotool. The left two plots contain the bode magnitude and phase diagrams. @@ -22,7 +24,16 @@ def sisotool(sys, kvect = None, xlim_rlocus = None, ylim_rlocus = None, Parameters ---------- sys : LTI object - Linear input/output systems (SISO only) + Linear input/output systems. If sys is SISO, use the same + system for the root locus and step response. If it is desired to + see a different step response than feedback(K*loop,1), sys can be + provided as a two-input, two-output system (e.g. by using + :func:`bdgalg.connect' or :func:`iosys.interconnect`). Sisotool + inserts the negative of the selected gain K between the first output + and first input and uses the second input and output for computing + the step response. This allows you to see the step responses of more + complex systems, for example, systems with a feedforward path into the + plant or in which the gain appears in the feedback path. kvect : list or ndarray, optional List of gains to use for plotting root locus xlim_rlocus : tuple or list, optional @@ -32,21 +43,23 @@ def sisotool(sys, kvect = None, xlim_rlocus = None, ylim_rlocus = None, control of y-axis range plotstr_rlocus : :func:`matplotlib.pyplot.plot` format string, optional plotting style for the root locus plot(color, linestyle, etc) - rlocus_grid: boolean (default = False) - If True plot s-plane grid. - omega : freq_range - Range of frequencies in rad/sec for the bode plot + rlocus_grid : boolean (default = False) + If True plot s- or z-plane grid. + omega : array_like + List of frequencies in rad/sec to be used for bode plot dB : boolean If True, plot result in dB for the bode plot Hz : boolean If True, plot frequency in Hz for the bode plot (omega must be provided in rad/sec) deg : boolean If True, plot phase in degrees for the bode plot (else radians) - omega_limits: tuple, list, ... of two values + omega_limits : array_like of two values Limits of the to generate frequency vector. - If Hz=True the limits are in Hz otherwise in rad/s. - omega_num: int - number of samples + If Hz=True the limits are in Hz otherwise in rad/s. Ignored if omega + is provided, and auto-generated if omitted. + omega_num : int + Number of samples to plot. Defaults to + config.defaults['freqplot.number_of_samples']. margins_bode : boolean If True, plot gain and phase margin in the bode plot tvect : list or ndarray, optional @@ -60,8 +73,11 @@ def sisotool(sys, kvect = None, xlim_rlocus = None, ylim_rlocus = None, """ from .rlocus import root_locus - # Check if it is a single SISO system - issiso(sys,strict=True) + # sys as loop transfer function if SISO + if not sys.issiso(): + if not (sys.ninputs == 2 and sys.noutputs == 2): + raise ControlMIMONotImplemented( + 'sys must be SISO or 2-input, 2-output') # Setup sisotool figure or superimpose if one is already present fig = plt.gcf() @@ -84,64 +100,74 @@ def sisotool(sys, kvect = None, xlim_rlocus = None, ylim_rlocus = None, } # First time call to setup the bode and step response plots - _SisotoolUpdate(sys, fig,1 if kvect is None else kvect[0],bode_plot_params) + _SisotoolUpdate(sys, fig, + 1 if kvect is None else kvect[0], bode_plot_params) # Setup the root-locus plot window - root_locus(sys,kvect=kvect,xlim=xlim_rlocus,ylim = ylim_rlocus,plotstr=plotstr_rlocus,grid = rlocus_grid,fig=fig,bode_plot_params=bode_plot_params,tvect=tvect,sisotool=True) + root_locus(sys, kvect=kvect, xlim=xlim_rlocus, + ylim=ylim_rlocus, plotstr=plotstr_rlocus, grid=rlocus_grid, + fig=fig, bode_plot_params=bode_plot_params, tvect=tvect, sisotool=True) -def _SisotoolUpdate(sys,fig,K,bode_plot_params,tvect=None): +def _SisotoolUpdate(sys, fig, K, bode_plot_params, tvect=None): - if int(matplotlib.__version__[0]) == 1: - title_font_size = 12 - label_font_size = 10 - else: - title_font_size = 10 - label_font_size = 8 + title_font_size = 10 + label_font_size = 8 # Get the subaxes and clear them - ax_mag,ax_rlocus,ax_phase,ax_step = fig.axes[0],fig.axes[1],fig.axes[2],fig.axes[3] + ax_mag, ax_rlocus, ax_phase, ax_step = \ + fig.axes[0], fig.axes[1], fig.axes[2], fig.axes[3] # Catch matplotlib 2.1.x and higher userwarnings when clearing a log axis with warnings.catch_warnings(): warnings.simplefilter("ignore") ax_step.clear(), ax_mag.clear(), ax_phase.clear() + sys_loop = sys if sys.issiso() else sys[0,0] + # Update the bodeplot - bode_plot_params['syslist'] = sys*K.real + bode_plot_params['syslist'] = sys_loop*K.real bode_plot(**bode_plot_params) # Set the titles and labels ax_mag.set_title('Bode magnitude',fontsize = title_font_size) ax_mag.set_ylabel(ax_mag.get_ylabel(), fontsize=label_font_size) + ax_mag.tick_params(axis='both', which='major', labelsize=label_font_size) ax_phase.set_title('Bode phase',fontsize=title_font_size) ax_phase.set_xlabel(ax_phase.get_xlabel(),fontsize=label_font_size) ax_phase.set_ylabel(ax_phase.get_ylabel(),fontsize=label_font_size) ax_phase.get_xaxis().set_label_coords(0.5, -0.15) ax_phase.get_shared_x_axes().join(ax_phase, ax_mag) + ax_phase.tick_params(axis='both', which='major', labelsize=label_font_size) ax_step.set_title('Step response',fontsize = title_font_size) ax_step.set_xlabel('Time (seconds)',fontsize=label_font_size) - ax_step.set_ylabel('Amplitude',fontsize=label_font_size) + ax_step.set_ylabel('Output',fontsize=label_font_size) ax_step.get_xaxis().set_label_coords(0.5, -0.15) ax_step.get_yaxis().set_label_coords(-0.15, 0.5) + ax_step.tick_params(axis='both', which='major', labelsize=label_font_size) ax_rlocus.set_title('Root locus',fontsize = title_font_size) ax_rlocus.set_ylabel('Imag', fontsize=label_font_size) ax_rlocus.set_xlabel('Real', fontsize=label_font_size) ax_rlocus.get_xaxis().set_label_coords(0.5, -0.15) ax_rlocus.get_yaxis().set_label_coords(-0.15, 0.5) - - + ax_rlocus.tick_params(axis='both', which='major',labelsize=label_font_size) # Generate the step response and plot it - sys_closed = (K*sys).feedback(1) + if sys.issiso(): + sys_closed = (K*sys).feedback(1) + else: + sys_closed = append(sys, -K) + connects = [[1, 3], + [3, 1]] + sys_closed = connect(sys_closed, connects, 2, 2) if tvect is None: tvect, yout = step_response(sys_closed, T_num=100) else: - tvect, yout = step_response(sys_closed,tvect) + tvect, yout = step_response(sys_closed, tvect) if isdtime(sys_closed, strict=True): - ax_step.plot(tvect, yout, 'o') + ax_step.plot(tvect, yout, '.') else: ax_step.plot(tvect, yout) ax_step.axhline(1.,linestyle=':',color='k',zorder=-20) diff --git a/control/statefbk.py b/control/statefbk.py index c08c645e9..0017412a4 100644 --- a/control/statefbk.py +++ b/control/statefbk.py @@ -41,12 +41,31 @@ # External packages and modules import numpy as np -import scipy as sp + from . import statesp from .mateqn import care from .statesp import _ssmatrix from .exception import ControlSlycot, ControlArgument, ControlDimension +# Make sure we have access to the right slycot routines +try: + from slycot import sb03md57 + # wrap without the deprecation warning + def sb03md(n, C, A, U, dico, job='X',fact='N',trana='N',ldwork=None): + ret = sb03md57(A, U, C, dico, job, fact, trana, ldwork) + return ret[2:] +except ImportError: + try: + from slycot import sb03md + except ImportError: + sb03md = None + +try: + from slycot import sb03od +except ImportError: + sb03od = None + + __all__ = ['ctrb', 'obsv', 'gram', 'place', 'place_varga', 'lqr', 'lqe', 'acker'] @@ -59,11 +78,11 @@ def place(A, B, p): Parameters ---------- - A : 2D array + A : 2D array_like Dynamics matrix - B : 2D array + B : 2D array_like Input matrix - p : 1D list + p : 1D array_like Desired eigenvalue locations Returns @@ -120,7 +139,7 @@ def place(A, B, p): raise ControlDimension(err_str) # Convert desired poles to numpy array - placed_eigs = np.array(p) + placed_eigs = np.atleast_1d(np.squeeze(np.asarray(p))) result = place_poles(A_mat, B_mat, placed_eigs, method='YT') K = result.gain_matrix @@ -133,11 +152,11 @@ def place_varga(A, B, p, dtime=False, alpha=None): Required Parameters ---------- - A : 2D array + A : 2D array_like Dynamics matrix - B : 2D array + B : 2D array_like Input matrix - p : 1D list + p : 1D array_like Desired eigenvalue locations Optional Parameters @@ -201,7 +220,7 @@ def place_varga(A, B, p, dtime=False, alpha=None): # Compute the system eigenvalues and convert poles to numpy array system_eigs = np.linalg.eig(A_mat)[0] - placed_eigs = np.array(p) + placed_eigs = np.atleast_1d(np.squeeze(np.asarray(p))) # Need a character parameter for SB01BD if dtime: @@ -264,9 +283,9 @@ def lqe(A, G, C, QN, RN, NN=None): Parameters ---------- - A, G : 2D array + A, G : 2D array_like Dynamics and noise input matrices - QN, RN : 2D array + QN, RN : 2D array_like Process and sensor noise covariance matrices NN : 2D array, optional Cross covariance matrix @@ -292,8 +311,8 @@ def lqe(A, G, C, QN, RN, NN=None): Examples -------- - >>> K, P, E = lqe(A, G, C, QN, RN) - >>> K, P, E = lqe(A, G, C, QN, RN, NN) + >>> L, P, E = lqe(A, G, C, QN, RN) + >>> L, P, E = lqe(A, G, C, QN, RN, NN) See Also -------- @@ -324,9 +343,9 @@ def acker(A, B, poles): Parameters ---------- - A, B : 2D arrays + A, B : 2D array_like State and input matrix of the system - poles : 1D list + poles : 1D array_like Desired eigenvalue locations Returns @@ -574,7 +593,7 @@ def gram(sys, type): * if `type` is not 'c', 'o', 'cf' or 'of' * if system is unstable (sys.A has eigenvalues not in left half plane) - ImportError + ControlSlycot if slycot routine sb03md cannot be found if slycot routine sb03od cannot be found @@ -614,9 +633,7 @@ def gram(sys, type): if type == 'c' or type == 'o': # Compute Gramian by the Slycot routine sb03md # make sure Slycot is installed - try: - from slycot import sb03md - except ImportError: + if sb03md is None: raise ControlSlycot("can't find slycot module 'sb03md'") if type == 'c': tra = 'T' @@ -624,7 +641,7 @@ def gram(sys, type): elif type == 'o': tra = 'N' C = -np.dot(sys.C.transpose(), sys.C) - n = sys.states + n = sys.nstates U = np.zeros((n, n)) A = np.array(sys.A) # convert to NumPy array for slycot X, scale, sep, ferr, w = sb03md( @@ -634,12 +651,10 @@ def gram(sys, type): elif type == 'cf' or type == 'of': # Compute cholesky factored gramian from slycot routine sb03od - try: - from slycot import sb03od - except ImportError: + if sb03od is None: raise ControlSlycot("can't find slycot module 'sb03od'") tra = 'N' - n = sys.states + n = sys.nstates Q = np.zeros((n, n)) A = np.array(sys.A) # convert to NumPy array for slycot if type == 'cf': diff --git a/control/statesp.py b/control/statesp.py index dd0ea6f5e..03349b0ac 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -10,7 +10,7 @@ # Python 3 compatibility (needs to go here) from __future__ import print_function -from __future__ import division # for _convertToStateSpace +from __future__ import division # for _convert_to_statespace """Copyright (c) 2010 by California Institute of Technology All rights reserved. @@ -54,14 +54,15 @@ import math import numpy as np from numpy import any, array, asarray, concatenate, cos, delete, \ - dot, empty, exp, eye, isinf, ones, pad, sin, zeros, squeeze + dot, empty, exp, eye, isinf, ones, pad, sin, zeros, squeeze, pi from numpy.random import rand, randn from numpy.linalg import solve, eigvals, matrix_rank from numpy.linalg.linalg import LinAlgError import scipy as sp -from scipy.signal import lti, cont2discrete +from scipy.signal import cont2discrete +from scipy.signal import StateSpace as signalStateSpace from warnings import warn -from .lti import LTI, timebase, timebaseEqual, isdtime +from .lti import LTI, common_timebase, isdtime, _process_frequency_response from . import config from copy import deepcopy @@ -70,9 +71,10 @@ # Define module default parameter values _statesp_defaults = { - 'statesp.use_numpy_matrix': True, - 'statesp.default_dt': None, - 'statesp.remove_useless_states': True, + 'statesp.use_numpy_matrix': False, # False is default in 0.9.0 and above + 'statesp.remove_useless_states': False, + 'statesp.latex_num_format': '.3g', + 'statesp.latex_repr_type': 'partitioned', } @@ -127,13 +129,40 @@ def _ssmatrix(data, axis=1): return arr.reshape(shape) +def _f2s(f): + """Format floating point number f for StateSpace._repr_latex_. + + Numbers are converted to strings with statesp.latex_num_format. + + Inserts column separators, etc., as needed. + """ + fmt = "{:" + config.defaults['statesp.latex_num_format'] + "}" + sraw = fmt.format(f) + # significand-exponent + se = sraw.lower().split('e') + # whole-fraction + wf = se[0].split('.') + s = wf[0] + if wf[1:]: + s += r'.&\hspace{{-1em}}{frac}'.format(frac=wf[1]) + else: + s += r'\phantom{.}&\hspace{-1em}' + + if se[1:]: + s += r'&\hspace{{-1em}}\cdot10^{{{:d}}}'.format(int(se[1])) + else: + s += r'&\hspace{-1em}\phantom{\cdot}' + + return s + + class StateSpace(LTI): """StateSpace(A, B, C, D[, dt]) A class for representing state-space models - The StateSpace class is used to represent state-space realizations of linear - time-invariant (LTI) systems: + The StateSpace class is used to represent state-space realizations of + linear time-invariant (LTI) systems: dx/dt = A x + B u y = C x + D u @@ -148,60 +177,89 @@ class StateSpace(LTI): `numpy.ndarray` objects. The :func:`~control.use_numpy_matrix` function can be used to set the storage type. - Discrete-time state space system are implemented by using the 'dt' - instance variable and setting it to the sampling period. If 'dt' is not - None, then it must match whenever two state space systems are combined. - Setting dt = 0 specifies a continuous system, while leaving dt = None - means the system timebase is not specified. If 'dt' is set to True, the - system will be treated as a discrete time system with unspecified sampling - time. The default value of 'dt' is None and can be changed by changing the - value of ``control.config.defaults['statesp.default_dt']``. - + A discrete time system is created by specifying a nonzero 'timebase', dt + when the system is constructed: + + * dt = 0: continuous time system (default) + * dt > 0: discrete time system with sampling period 'dt' + * dt = True: discrete time with unspecified sampling period + * dt = None: no timebase specified + + Systems must have compatible timebases in order to be combined. A discrete + time system with unspecified sampling time (`dt = True`) can be combined + with a system having a specified sampling time; the result will be a + discrete time system with the sample time of the latter system. Similarly, + a system with timebase `None` can be combined with a system having any + timebase; the result will have the timebase of the latter system. + The default value of dt can be changed by changing the value of + ``control.config.defaults['control.default_dt']``. + + StateSpace instances have support for IPython LaTeX output, + intended for pretty-printing in Jupyter notebooks. The LaTeX + output can be configured using + `control.config.defaults['statesp.latex_num_format']` and + `control.config.defaults['statesp.latex_repr_type']`. The LaTeX output is + tailored for MathJax, as used in Jupyter, and may look odd when + typeset by non-MathJax LaTeX systems. + + `control.config.defaults['statesp.latex_num_format']` is a format string + fragment, specifically the part of the format string after `'{:'` + used to convert floating-point numbers to strings. By default it + is `'.3g'`. + + `control.config.defaults['statesp.latex_repr_type']` must either be + `'partitioned'` or `'separate'`. If `'partitioned'`, the A, B, C, D + matrices are shown as a single, partitioned matrix; if + `'separate'`, the matrices are shown separately. """ # Allow ndarray * StateSpace to give StateSpace._rmul_() priority __array_priority__ = 11 # override ndarray and matrix types - - def __init__(self, *args, **kw): - """ - StateSpace(A, B, C, D[, dt]) + def __init__(self, *args, **kwargs): + """StateSpace(A, B, C, D[, dt]) Construct a state space object. The default constructor is StateSpace(A, B, C, D), where A, B, C, D are matrices or equivalent objects. To create a discrete time system, - use StateSpace(A, B, C, D, dt) where 'dt' is the sampling time (or + use StateSpace(A, B, C, D, dt) where `dt` is the sampling time (or True for unspecified sampling time). To call the copy constructor, call StateSpace(sys), where sys is a StateSpace object. + The `remove_useless_states` keyword can be used to scan the A, B, and + C matrices for rows or columns of zeros. If the zeros are such that a + particular state has no effect on the input-output dynamics, then that + state is removed from the A, B, and C matrices. If not specified, the + value is read from `config.defaults['statesp.remove_useless_states']` + (default = False). + """ + # first get A, B, C, D matrices if len(args) == 4: # The user provided A, B, C, and D matrices. (A, B, C, D) = args - dt = config.defaults['statesp.default_dt'] elif len(args) == 5: # Discrete time system - (A, B, C, D, dt) = args + (A, B, C, D, _) = args elif len(args) == 1: # Use the copy constructor. if not isinstance(args[0], StateSpace): - raise TypeError("The one-argument constructor can only take in a StateSpace " - "object. Received %s." % type(args[0])) + raise TypeError( + "The one-argument constructor can only take in a " + "StateSpace object. Received %s." % type(args[0])) A = args[0].A B = args[0].B C = args[0].C D = args[0].D - try: - dt = args[0].dt - except NameError: - dt = config.defaults['statesp.default_dt'] else: - raise ValueError("Needs 1 or 4 arguments; received %i." % len(args)) + raise ValueError( + "Expected 1, 4, or 5 arguments; received %i." % len(args)) # Process keyword arguments - remove_useless = kw.get('remove_useless', - config.defaults['statesp.remove_useless_states']) + remove_useless_states = kwargs.get( + 'remove_useless_states', + config.defaults['statesp.remove_useless_states']) # Convert all matrices to standard form A = _ssmatrix(A) @@ -213,49 +271,93 @@ def __init__(self, *args, **kw): if np.asarray(C).ndim == 1 and len(C) == A.shape[0]: C = _ssmatrix(C, axis=1) else: - C = _ssmatrix(C, axis=0) #if this doesn't work, error below + C = _ssmatrix(C, axis=0) # if this doesn't work, error below if np.isscalar(D) and D == 0 and B.shape[1] > 0 and C.shape[0] > 0: # If D is a scalar zero, broadcast it to the proper size D = np.zeros((C.shape[0], B.shape[1])) D = _ssmatrix(D) # TODO: use super here? - LTI.__init__(self, inputs=D.shape[1], outputs=D.shape[0], dt=dt) + LTI.__init__(self, inputs=D.shape[1], outputs=D.shape[0]) self.A = A self.B = B self.C = C self.D = D - self.states = A.shape[1] - - if 0 == self.states: + # now set dt + if len(args) == 4: + if 'dt' in kwargs: + dt = kwargs['dt'] + elif self._isstatic(): + dt = None + else: + dt = config.defaults['control.default_dt'] + elif len(args) == 5: + dt = args[4] + if 'dt' in kwargs: + warn('received multiple dt arguments, using positional arg dt=%s'%dt) + elif len(args) == 1: + try: + dt = args[0].dt + except AttributeError: + if self._isstatic(): + dt = None + else: + dt = config.defaults['control.default_dt'] + self.dt = dt + self.nstates = A.shape[1] + + if 0 == self.nstates: # static gain # matrix's default "empty" shape is 1x0 - A.shape = (0,0) - B.shape = (0,self.inputs) - C.shape = (self.outputs,0) + A.shape = (0, 0) + B.shape = (0, self.ninputs) + C.shape = (self.noutputs, 0) # Check that the matrix sizes are consistent. - if self.states != A.shape[0]: + if self.nstates != A.shape[0]: raise ValueError("A must be square.") - if self.states != B.shape[0]: + if self.nstates != B.shape[0]: raise ValueError("A and B must have the same number of rows.") - if self.states != C.shape[1]: + if self.nstates != C.shape[1]: raise ValueError("A and C must have the same number of columns.") - if self.inputs != B.shape[1]: + if self.ninputs != B.shape[1]: raise ValueError("B and D must have the same number of columns.") - if self.outputs != C.shape[0]: + if self.noutputs != C.shape[0]: raise ValueError("C and D must have the same number of rows.") # Check for states that don't do anything, and remove them. - if remove_useless: self._remove_useless_states() + if remove_useless_states: + self._remove_useless_states() + + # + # Getter and setter functions for legacy state attributes + # + # For this iteration, generate a deprecation warning whenever the + # getter/setter is called. For a future iteration, turn it into a + # future warning, so that users will see it. + # + + @property + def states(self): + warn("The StateSpace `states` attribute will be deprecated in a " + "future release. Use `nstates` instead.", + DeprecationWarning, stacklevel=2) + return self.nstates + + @states.setter + def states(self, value): + warn("The StateSpace `states` attribute will be deprecated in a " + "future release. Use `nstates` instead.", + DeprecationWarning, stacklevel=2) + self.nstates = value def _remove_useless_states(self): """Check for states that don't do anything, and remove them. Scan the A, B, and C matrices for rows or columns of zeros. If the - zeros are such that a particular state has no effect on the input-output - dynamics, then remove that state from the A, B, and C matrices. + zeros are such that a particular state has no effect on the input- + output dynamics, then remove that state from the A, B, and C matrices. """ @@ -278,9 +380,9 @@ def _remove_useless_states(self): self.B = delete(self.B, useless, 0) self.C = delete(self.C, useless, 1) - self.states = self.A.shape[0] - self.inputs = self.B.shape[1] - self.outputs = self.C.shape[0] + self.nstates = self.A.shape[0] + self.ninputs = self.B.shape[1] + self.noutputs = self.C.shape[0] def __str__(self): """Return string representation of the state space system.""" @@ -305,6 +407,137 @@ def __repr__(self): C=asarray(self.C).__repr__(), D=asarray(self.D).__repr__(), dt=(isdtime(self, strict=True) and ", {}".format(self.dt)) or '') + def _latex_partitioned_stateless(self): + """`Partitioned` matrix LaTeX representation for stateless systems + + Model is presented as a matrix, D. No partition lines are shown. + + Returns + ------- + s : string with LaTeX representation of model + """ + lines = [ + r'\[', + r'\left(', + (r'\begin{array}' + + r'{' + 'rll' * self.ninputs + '}') + ] + + for Di in asarray(self.D): + lines.append('&'.join(_f2s(Dij) for Dij in Di) + + '\\\\') + + lines.extend([ + r'\end{array}' + r'\right)', + r'\]']) + + return '\n'.join(lines) + + def _latex_partitioned(self): + """Partitioned matrix LaTeX representation of state-space model + + Model is presented as a matrix partitioned into A, B, C, and D + parts. + + Returns + ------- + s : string with LaTeX representation of model + """ + if self.nstates == 0: + return self._latex_partitioned_stateless() + + lines = [ + r'\[', + r'\left(', + (r'\begin{array}' + + r'{' + 'rll' * self.nstates + '|' + 'rll' * self.ninputs + '}') + ] + + for Ai, Bi in zip(asarray(self.A), asarray(self.B)): + lines.append('&'.join([_f2s(Aij) for Aij in Ai] + + [_f2s(Bij) for Bij in Bi]) + + '\\\\') + lines.append(r'\hline') + for Ci, Di in zip(asarray(self.C), asarray(self.D)): + lines.append('&'.join([_f2s(Cij) for Cij in Ci] + + [_f2s(Dij) for Dij in Di]) + + '\\\\') + + lines.extend([ + r'\end{array}' + r'\right)', + r'\]']) + + return '\n'.join(lines) + + def _latex_separate(self): + """Separate matrices LaTeX representation of state-space model + + Model is presented as separate, named, A, B, C, and D matrices. + + Returns + ------- + s : string with LaTeX representation of model + """ + lines = [ + r'\[', + r'\begin{array}{ll}', + ] + + def fmt_matrix(matrix, name): + matlines = [name + + r' = \left(\begin{array}{' + + 'rll' * matrix.shape[1] + + '}'] + for row in asarray(matrix): + matlines.append('&'.join(_f2s(entry) for entry in row) + + '\\\\') + matlines.extend([ + r'\end{array}' + r'\right)']) + return matlines + + if self.nstates > 0: + lines.extend(fmt_matrix(self.A, 'A')) + lines.append('&') + lines.extend(fmt_matrix(self.B, 'B')) + lines.append('\\\\') + + lines.extend(fmt_matrix(self.C, 'C')) + lines.append('&') + lines.extend(fmt_matrix(self.D, 'D')) + + lines.extend([ + r'\end{array}', + r'\]']) + + return '\n'.join(lines) + + def _repr_latex_(self): + """LaTeX representation of state-space model + + Output is controlled by config options statesp.latex_repr_type + and statesp.latex_num_format. + + The output is primarily intended for Jupyter notebooks, which + use MathJax to render the LaTeX, and the results may look odd + when processed by a 'conventional' LaTeX system. + + Returns + ------- + s : string with LaTeX representation of model + + """ + if config.defaults['statesp.latex_repr_type'] == 'partitioned': + return self._latex_partitioned() + elif config.defaults['statesp.latex_repr_type'] == 'separate': + return self._latex_separate() + else: + cfg = config.defaults['statesp.latex_repr_type'] + raise ValueError( + "Unknown statesp.latex_repr_type '{cfg}'".format(cfg=cfg)) + # Negation of a system def __neg__(self): """Negate a state space system.""" @@ -322,29 +555,21 @@ def __add__(self, other): D = self.D + other dt = self.dt else: - other = _convertToStateSpace(other) + other = _convert_to_statespace(other) # Check to make sure the dimensions are OK - if ((self.inputs != other.inputs) or - (self.outputs != other.outputs)): + if ((self.ninputs != other.ninputs) or + (self.noutputs != other.noutputs)): raise ValueError("Systems have different shapes.") - # Figure out the sampling time to use - if self.dt is None and other.dt is not None: - dt = other.dt # use dt from second argument - elif (other.dt is None and self.dt is not None) or \ - (timebaseEqual(self, other)): - dt = self.dt # use dt from first argument - else: - raise ValueError("Systems have different sampling times") + dt = common_timebase(self.dt, other.dt) # Concatenate the various arrays A = concatenate(( concatenate((self.A, zeros((self.A.shape[0], - other.A.shape[-1]))),axis=1), + other.A.shape[-1]))), axis=1), concatenate((zeros((other.A.shape[0], self.A.shape[-1])), - other.A),axis=1) - ),axis=0) + other.A), axis=1)), axis=0) B = concatenate((self.B, other.B), axis=0) C = concatenate((self.C, other.C), axis=1) D = self.D + other.D @@ -380,21 +605,13 @@ def __mul__(self, other): D = self.D * other dt = self.dt else: - other = _convertToStateSpace(other) + other = _convert_to_statespace(other) # Check to make sure the dimensions are OK - if self.inputs != other.outputs: + if self.ninputs != other.noutputs: raise ValueError("C = A * B: A has %i column(s) (input(s)), \ -but B has %i row(s)\n(output(s))." % (self.inputs, other.outputs)) - - # Figure out the sampling time to use - if (self.dt == None and other.dt != None): - dt = other.dt # use dt from second argument - elif (other.dt == None and self.dt != None) or \ - (timebaseEqual(self, other)): - dt = self.dt # use dt from first argument - else: - raise ValueError("Systems have different sampling times") + but B has %i row(s)\n(output(s))." % (self.ninputs, other.noutputs)) + dt = common_timebase(self.dt, other.dt) # Concatenate the various arrays A = concatenate( @@ -404,7 +621,7 @@ def __mul__(self, other): concatenate((np.dot(self.B, other.C), self.A), axis=1)), axis=0) B = concatenate((other.B, np.dot(self.B, other.D)), axis=0) - C = concatenate((np.dot(self.D, other.C), self.C),axis=1) + C = concatenate((np.dot(self.D, other.C), self.C), axis=1) D = np.dot(self.D, other.D) return StateSpace(A, B, C, D, dt) @@ -425,7 +642,7 @@ def __rmul__(self, other): # is lti, and convertible? if isinstance(other, LTI): - return _convertToStateSpace(other) * self + return _convert_to_statespace(other) * self # try to treat this as a matrix try: @@ -448,140 +665,191 @@ def __div__(self, other): def __rdiv__(self, other): """Right divide two LTI systems.""" - raise NotImplementedError("StateSpace.__rdiv__ is not implemented yet.") + raise NotImplementedError( + "StateSpace.__rdiv__ is not implemented yet.") - def evalfr(self, omega): - """Evaluate a SS system's transfer function at a single frequency. + def __call__(self, x, squeeze=None, warn_infinite=True): + """Evaluate system's transfer function at complex frequency. - self._evalfr(omega) returns the value of the transfer function matrix - with input value s = i * omega. + Returns the complex frequency response `sys(x)` where `x` is `s` for + continuous-time systems and `z` for discrete-time systems. - """ - warn("StateSpace.evalfr(omega) will be deprecated in a future " - "release of python-control; use evalfr(sys, omega*1j) instead", - PendingDeprecationWarning) - return self._evalfr(omega) - - def _evalfr(self, omega): - """Evaluate a SS system's transfer function at a single frequency""" - # Figure out the point to evaluate the transfer function - if isdtime(self, strict=True): - dt = timebase(self) - s = exp(1.j * omega * dt) - if omega * dt > math.pi: - warn("_evalfr: frequency evaluation above Nyquist frequency") - else: - s = omega * 1.j + To evaluate at a frequency omega in radians per second, enter + ``x = omega * 1j``, for continuous-time systems, or + ``x = exp(1j * omega * dt)`` for discrete-time systems. Or use + :meth:`StateSpace.frequency_response`. - return self.horner(s) + Parameters + ---------- + x : complex or complex 1D array_like + Complex frequencies + squeeze : bool, optional + If squeeze=True, remove single-dimensional entries from the shape + of the output even if the system is not SISO. If squeeze=False, + keep all indices (output, input and, if omega is array_like, + frequency) even if the system is SISO. The default value can be + set using config.defaults['control.squeeze_frequency_response']. + warn_infinite : bool, optional + If set to `False`, don't warn if frequency response is infinite. - def horner(self, s): - """Evaluate the systems's transfer function for a complex variable + Returns + ------- + fresp : complex ndarray + The frequency response of the system. If the system is SISO and + squeeze is not True, the shape of the array matches the shape of + omega. If the system is not SISO or squeeze is False, the first + two dimensions of the array are indices for the output and input + and the remaining dimensions match omega. If ``squeeze`` is True + then single-dimensional axes are removed. - Returns a matrix of values evaluated at complex variable s. """ - resp = np.dot(self.C, solve(s * eye(self.states) - self.A, - self.B)) + self.D - return array(resp) + # Use Slycot if available + out = self.horner(x, warn_infinite=warn_infinite) + return _process_frequency_response(self, x, out, squeeze=squeeze) - def freqresp(self, omega): - """Evaluate the system's transfer function at a list of frequencies + def slycot_laub(self, x): + """Evaluate system's transfer function at complex frequency + using Laub's method from Slycot. - Reports the frequency response of the system, + Expects inputs and outputs to be formatted correctly. Use ``sys(x)`` + for a more user-friendly interface. + + Parameters + ---------- + x : complex array_like or complex + Complex frequency + + Returns + ------- + output : (number_outputs, number_inputs, len(x)) complex ndarray + Frequency response + """ + from slycot import tb05ad + + # Make sure the argument is a 1D array of complex numbers + x_arr = np.atleast_1d(x).astype(complex, copy=False) + + # Make sure that we are operating on a simple list + if len(x_arr.shape) > 1: + raise ValueError("input list must be 1D") + + # preallocate + n = self.nstates + m = self.ninputs + p = self.noutputs + out = np.empty((p, m, len(x_arr)), dtype=complex) + # The first call both evaluates C(sI-A)^-1 B and also returns + # Hessenberg transformed matrices at, bt, ct. + result = tb05ad(n, m, p, x_arr[0], self.A, self.B, self.C, job='NG') + # When job='NG', result = (at, bt, ct, g_i, hinvb, info) + at = result[0] + bt = result[1] + ct = result[2] + + # TB05AD frequency evaluation does not include direct feedthrough. + out[:, :, 0] = result[3] + self.D + + # Now, iterate through the remaining frequencies using the + # transformed state matrices, at, bt, ct. + + # Start at the second frequency, already have the first. + for kk, x_kk in enumerate(x_arr[1:len(x_arr)]): + result = tb05ad(n, m, p, x_kk, at, bt, ct, job='NH') + # When job='NH', result = (g_i, hinvb, info) + + # kk+1 because enumerate starts at kk = 0. + # but zero-th spot is already filled. + out[:, :, kk+1] = result[0] + self.D + return out - G(j*omega) = mag*exp(j*phase) + def horner(self, x, warn_infinite=True): + """Evaluate system's transfer function at complex frequency + using Laub's or Horner's method. - for continuous time. For discrete time systems, the response is - evaluated around the unit circle such that + Evaluates `sys(x)` where `x` is `s` for continuous-time systems and `z` + for discrete-time systems. - G(exp(j*omega*dt)) = mag*exp(j*phase). + Expects inputs and outputs to be formatted correctly. Use ``sys(x)`` + for a more user-friendly interface. Parameters ---------- - omega : array_like - A list of frequencies in radians/sec at which the system should be - evaluated. The list can be either a python list or a numpy array - and will be sorted before evaluation. + x : complex array_like or complex + Complex frequencies Returns ------- - mag : (self.outputs, self.inputs, len(omega)) ndarray - The magnitude (absolute value, not dB or log10) of the system - frequency response. - phase : (self.outputs, self.inputs, len(omega)) ndarray - The wrapped phase in radians of the system frequency response. - omega : ndarray - The list of sorted frequencies at which the response was - evaluated. - """ - # In case omega is passed in as a list, rather than a proper array. - omega = np.asarray(omega) - - numFreqs = len(omega) - Gfrf = np.empty((self.outputs, self.inputs, numFreqs), - dtype=np.complex128) - - # Sort frequency and calculate complex frequencies on either imaginary - # axis (continuous time) or unit circle (discrete time). - omega.sort() - if isdtime(self, strict=True): - dt = timebase(self) - cmplx_freqs = exp(1.j * omega * dt) - if max(np.abs(omega)) * dt > math.pi: - warn("freqresp: frequency evaluation above Nyquist frequency") - else: - cmplx_freqs = omega * 1.j + output : (self.noutputs, self.ninputs, len(x)) complex ndarray + Frequency response - # Do the frequency response evaluation. Use TB05AD from Slycot - # if it's available, otherwise use the built-in horners function. + Notes + ----- + Attempts to use Laub's method from Slycot library, with a + fall-back to python code. + """ try: - from slycot import tb05ad - - n = np.shape(self.A)[0] - m = self.inputs - p = self.outputs - # The first call both evaluates C(sI-A)^-1 B and also returns - # Hessenberg transformed matrices at, bt, ct. - result = tb05ad(n, m, p, cmplx_freqs[0], self.A, - self.B, self.C, job='NG') - # When job='NG', result = (at, bt, ct, g_i, hinvb, info) - at = result[0] - bt = result[1] - ct = result[2] - - # TB05AD frequency evaluation does not include direct feedthrough. - Gfrf[:, :, 0] = result[3] + self.D - - # Now, iterate through the remaining frequencies using the - # transformed state matrices, at, bt, ct. - - # Start at the second frequency, already have the first. - for kk, cmplx_freqs_kk in enumerate(cmplx_freqs[1:numFreqs]): - result = tb05ad(n, m, p, cmplx_freqs_kk, at, - bt, ct, job='NH') - # When job='NH', result = (g_i, hinvb, info) - - # kk+1 because enumerate starts at kk = 0. - # but zero-th spot is already filled. - Gfrf[:, :, kk+1] = result[0] + self.D - - except ImportError: # Slycot unavailable. Fall back to horner. - for kk, cmplx_freqs_kk in enumerate(cmplx_freqs): - Gfrf[:, :, kk] = self.horner(cmplx_freqs_kk) - - # mag phase omega - return np.abs(Gfrf), np.angle(Gfrf), omega + out = self.slycot_laub(x) + except (ImportError, Exception): + # Fall back because either Slycot unavailable or cannot handle + # certain cases. + + # Make sure the argument is a 1D array of complex numbers + x_arr = np.atleast_1d(x).astype(complex, copy=False) + + # Make sure that we are operating on a simple list + if len(x_arr.shape) > 1: + raise ValueError("input list must be 1D") + + # Preallocate + out = empty((self.noutputs, self.ninputs, len(x_arr)), + dtype=complex) + + #TODO: can this be vectorized? + for idx, x_idx in enumerate(x_arr): + try: + out[:,:,idx] = np.dot( + self.C, + solve(x_idx * eye(self.nstates) - self.A, self.B)) \ + + self.D + except LinAlgError: + # Issue a warning messsage, for consistency with xferfcn + if warn_infinite: + warn("singular matrix in frequency response", + RuntimeWarning) + + # Evaluating at a pole. Return value depends if there + # is a zero at the same point or not. + if x_idx in self.zero(): + out[:,:,idx] = complex(np.nan, np.nan) + else: + out[:,:,idx] = complex(np.inf, np.nan) + + return out + + def freqresp(self, omega): + """(deprecated) Evaluate transfer function at complex frequencies. + + .. deprecated::0.9.0 + Method has been given the more pythonic name + :meth:`StateSpace.frequency_response`. Or use + :func:`freqresp` in the MATLAB compatibility module. + """ + 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) + return self.frequency_response(omega) # Compute poles and zeros def pole(self): """Compute the poles of a state space system.""" - return eigvals(self.A) if self.states else np.array([]) + return eigvals(self.A) if self.nstates else np.array([]) def zero(self): """Compute the zeros of a state space system.""" - if not self.states: + if not self.nstates: return np.array([]) # Use AB08ND from Slycot if it's available, otherwise use @@ -595,7 +863,8 @@ def zero(self): if nu == 0: return np.array([]) else: - return sp.linalg.eigvals(out[8][0:nu, 0:nu], out[9][0:nu, 0:nu]) + return sp.linalg.eigvals(out[8][0:nu, 0:nu], + out[9][0:nu, 0:nu]) except ImportError: # Slycot unavailable. Fall back to scipy. if self.C.shape[0] != self.D.shape[1]: @@ -617,27 +886,21 @@ def zero(self): concatenate((self.C, self.D), axis=1)), axis=0) M = pad(eye(self.A.shape[0]), ((0, self.C.shape[0]), (0, self.B.shape[1])), "constant") - return np.array([x for x in sp.linalg.eigvals(L, M, overwrite_a=True) + return np.array([x for x in sp.linalg.eigvals(L, M, + overwrite_a=True) if not isinf(x)]) # Feedback around a state space system def feedback(self, other=1, sign=-1): """Feedback interconnection between two LTI systems.""" - other = _convertToStateSpace(other) + other = _convert_to_statespace(other) # Check to make sure the dimensions are OK - if (self.inputs != other.outputs) or (self.outputs != other.inputs): - raise ValueError("State space systems don't have compatible inputs/outputs for " - "feedback.") - - # Figure out the sampling time to use - if self.dt is None and other.dt is not None: - dt = other.dt # use dt from second argument - elif other.dt is None and self.dt is not None or timebaseEqual(self, other): - dt = self.dt # use dt from first argument - else: - raise ValueError("Systems have different sampling times") + if (self.ninputs != other.noutputs) or (self.noutputs != other.ninputs): + raise ValueError("State space systems don't have compatible " + "inputs/outputs for feedback.") + dt = common_timebase(self.dt, other.dt) A1 = self.A B1 = self.B @@ -648,9 +911,10 @@ def feedback(self, other=1, sign=-1): C2 = other.C D2 = other.D - F = eye(self.inputs) - sign * np.dot(D2, D1) - if matrix_rank(F) != self.inputs: - raise ValueError("I - sign * D2 * D1 is singular to working precision.") + F = eye(self.ninputs) - sign * np.dot(D2, D1) + if matrix_rank(F) != self.ninputs: + raise ValueError( + "I - sign * D2 * D1 is singular to working precision.") # Precompute F\D2 and F\C2 (E = inv(F)) # We can solve two linear systems in one pass, since the @@ -658,11 +922,11 @@ def feedback(self, other=1, sign=-1): # decomposition (cubic runtime complexity) of F only once! # The remaining back substitutions are only quadratic in runtime. E_D2_C2 = solve(F, concatenate((D2, C2), axis=1)) - E_D2 = E_D2_C2[:, :other.inputs] - E_C2 = E_D2_C2[:, other.inputs:] + E_D2 = E_D2_C2[:, :other.ninputs] + E_C2 = E_D2_C2[:, other.ninputs:] - T1 = eye(self.outputs) + sign * np.dot(D1, E_D2) - T2 = eye(self.inputs) + sign * np.dot(E_D2, D1) + T1 = eye(self.noutputs) + sign * np.dot(D1, E_D2) + T2 = eye(self.ninputs) + sign * np.dot(E_D2, D1) A = concatenate( (concatenate( @@ -698,34 +962,27 @@ def lft(self, other, nu=-1, ny=-1): Dimension of (plant) control input. """ - other = _convertToStateSpace(other) + other = _convert_to_statespace(other) # maximal values for nu, ny if ny == -1: - ny = min(other.inputs, self.outputs) + ny = min(other.ninputs, self.noutputs) if nu == -1: - nu = min(other.outputs, self.inputs) + nu = min(other.noutputs, self.ninputs) # dimension check # TODO - # Figure out the sampling time to use - if (self.dt == None and other.dt != None): - dt = other.dt # use dt from second argument - elif (other.dt == None and self.dt != None) or \ - timebaseEqual(self, other): - dt = self.dt # use dt from first argument - else: - raise ValueError("Systems have different time bases") + dt = common_timebase(self.dt, other.dt) # submatrices A = self.A - B1 = self.B[:, :self.inputs - nu] - B2 = self.B[:, self.inputs - nu:] - C1 = self.C[:self.outputs - ny, :] - C2 = self.C[self.outputs - ny:, :] - D11 = self.D[:self.outputs - ny, :self.inputs - nu] - D12 = self.D[:self.outputs - ny, self.inputs - nu:] - D21 = self.D[self.outputs - ny:, :self.inputs - nu] - D22 = self.D[self.outputs - ny:, self.inputs - nu:] + B1 = self.B[:, :self.ninputs - nu] + B2 = self.B[:, self.ninputs - nu:] + C1 = self.C[:self.noutputs - ny, :] + C2 = self.C[self.noutputs - ny:, :] + D11 = self.D[:self.noutputs - ny, :self.ninputs - nu] + D12 = self.D[:self.noutputs - ny, self.ninputs - nu:] + D21 = self.D[self.noutputs - ny:, :self.ninputs - nu] + D22 = self.D[self.noutputs - ny:, self.ninputs - nu:] # submatrices Abar = other.A @@ -746,17 +1003,21 @@ def lft(self, other, nu=-1, ny=-1): # solve for the resulting ss by solving for [y, u] using [x, # xbar] and [w1, w2]. TH = np.linalg.solve(F, np.block( - [[C2, np.zeros((ny, other.states)), D21, np.zeros((ny, other.inputs - ny))], - [np.zeros((nu, self.states)), Cbar1, np.zeros((nu, self.inputs - nu)), Dbar12]] + [[C2, np.zeros((ny, other.nstates)), + D21, np.zeros((ny, other.ninputs - ny))], + [np.zeros((nu, self.nstates)), Cbar1, + np.zeros((nu, self.ninputs - nu)), Dbar12]] )) - T11 = TH[:ny, :self.states] - T12 = TH[:ny, self.states: self.states + other.states] - T21 = TH[ny:, :self.states] - T22 = TH[ny:, self.states: self.states + other.states] - H11 = TH[:ny, self.states + other.states: self.states + other.states + self.inputs - nu] - H12 = TH[:ny, self.states + other.states + self.inputs - nu:] - H21 = TH[ny:, self.states + other.states: self.states + other.states + self.inputs - nu] - H22 = TH[ny:, self.states + other.states + self.inputs - nu:] + T11 = TH[:ny, :self.nstates] + T12 = TH[:ny, self.nstates: self.nstates + other.nstates] + T21 = TH[ny:, :self.nstates] + T22 = TH[ny:, self.nstates: self.nstates + other.nstates] + H11 = TH[:ny, self.nstates + other.nstates:self.nstates + + other.nstates + self.ninputs - nu] + H12 = TH[:ny, self.nstates + other.nstates + self.ninputs - nu:] + H21 = TH[ny:, self.nstates + other.nstates:self.nstates + + other.nstates + self.ninputs - nu] + H22 = TH[ny:, self.nstates + other.nstates + self.ninputs - nu:] Ares = np.block([ [A + B2.dot(T21), B2.dot(T22)], @@ -782,25 +1043,23 @@ def lft(self, other, nu=-1, ny=-1): def minreal(self, tol=0.0): """Calculate a minimal realization, removes unobservable and uncontrollable states""" - if self.states: + if self.nstates: try: from slycot import tb01pd - B = empty((self.states, max(self.inputs, self.outputs))) - B[:,:self.inputs] = self.B - C = empty((max(self.outputs, self.inputs), self.states)) - C[:self.outputs,:] = self.C - A, B, C, nr = tb01pd(self.states, self.inputs, self.outputs, + B = empty((self.nstates, max(self.ninputs, self.noutputs))) + B[:, :self.ninputs] = self.B + C = empty((max(self.noutputs, self.ninputs), self.nstates)) + C[:self.noutputs, :] = self.C + A, B, C, nr = tb01pd(self.nstates, self.ninputs, self.noutputs, self.A, B, C, tol=tol) - return StateSpace(A[:nr,:nr], B[:nr,:self.inputs], - C[:self.outputs,:nr], self.D) + return StateSpace(A[:nr, :nr], B[:nr, :self.ninputs], + C[:self.noutputs, :nr], self.D) except ImportError: raise TypeError("minreal requires slycot tb01pd") else: return StateSpace(self) - - # TODO: add discrete time check - def returnScipySignalLTI(self): + def returnScipySignalLTI(self, strict=True): """Return a list of a list of :class:`scipy.signal.lti` objects. For instance, @@ -809,15 +1068,45 @@ def returnScipySignalLTI(self): >>> out[3][5] is a :class:`scipy.signal.lti` object corresponding to the transfer - function from the 6th input to the 4th output.""" + function from the 6th input to the 4th output. + + Parameters + ---------- + strict : bool, optional + True (default): + The timebase `ssobject.dt` cannot be None; it must + be continuous (0) or discrete (True or > 0). + False: + If `ssobject.dt` is None, continuous time + :class:`scipy.signal.lti` objects are returned. + + Returns + ------- + out : list of list of :class:`scipy.signal.StateSpace` + continuous time (inheriting from :class:`scipy.signal.lti`) + or discrete time (inheriting from :class:`scipy.signal.dlti`) + SISO objects + """ + if strict and self.dt is None: + raise ValueError("with strict=True, dt cannot be None") + + if self.dt: + kwdt = {'dt': self.dt} + else: + # scipy convention for continuous time lti systems: call without + # dt keyword argument + kwdt = {} # Preallocate the output. - out = [[[] for _ in range(self.inputs)] for _ in range(self.outputs)] + out = [[[] for _ in range(self.ninputs)] for _ in range(self.noutputs)] - for i in range(self.outputs): - for j in range(self.inputs): - out[i][j] = lti(asarray(self.A), asarray(self.B[:, j]), - asarray(self.C[i, :]), self.D[i, j]) + for i in range(self.noutputs): + for j in range(self.ninputs): + out[i][j] = signalStateSpace(asarray(self.A), + asarray(self.B[:, j:j + 1]), + asarray(self.C[i:i + 1, :]), + asarray(self.D[i:i + 1, j:j + 1]), + **kwdt) return out @@ -827,26 +1116,25 @@ def append(self, other): The second model is converted to state-space if necessary, inputs and outputs are appended and their order is preserved""" if not isinstance(other, StateSpace): - other = _convertToStateSpace(other) + other = _convert_to_statespace(other) - if self.dt != other.dt: - raise ValueError("Systems must have the same time step") + self.dt = common_timebase(self.dt, other.dt) - n = self.states + other.states - m = self.inputs + other.inputs - p = self.outputs + other.outputs + n = self.nstates + other.nstates + m = self.ninputs + other.ninputs + p = self.noutputs + other.noutputs A = zeros((n, n)) B = zeros((n, m)) C = zeros((p, n)) D = zeros((p, m)) - A[:self.states, :self.states] = self.A - A[self.states:, self.states:] = other.A - B[:self.states, :self.inputs] = self.B - B[self.states:, self.inputs:] = other.B - C[:self.outputs, :self.states] = self.C - C[self.outputs:, self.states:] = other.C - D[:self.outputs, :self.inputs] = self.D - D[self.outputs:, self.inputs:] = other.D + A[:self.nstates, :self.nstates] = self.A + A[self.nstates:, self.nstates:] = other.A + B[:self.nstates, :self.ninputs] = self.B + B[self.nstates:, self.ninputs:] = other.B + C[:self.noutputs, :self.nstates] = self.C + C[self.noutputs:, self.nstates:] = other.C + D[:self.noutputs, :self.ninputs] = self.D + D[self.noutputs:, self.ninputs:] = other.D return StateSpace(A, B, C, D, self.dt) def __getitem__(self, indices): @@ -855,7 +1143,8 @@ def __getitem__(self, indices): raise IOError('must provide indices of length 2 for state space') i = indices[0] j = indices[1] - return StateSpace(self.A, self.B[:, j], self.C[i, :], self.D[i, j], self.dt) + return StateSpace(self.A, self.B[:, j], self.C[i, :], + self.D[i, j], self.dt) def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None): """Convert a continuous time system to discrete time @@ -907,15 +1196,15 @@ def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None): raise ValueError("System must be continuous time system") sys = (self.A, self.B, self.C, self.D) - if (method=='bilinear' or (method=='gbt' and alpha==0.5)) and \ + if (method == 'bilinear' or (method == 'gbt' and alpha == 0.5)) and \ prewarp_frequency is not None: - Twarp = 2*np.tan(prewarp_frequency*Ts/2)/prewarp_frequency + Twarp = 2 * np.tan(prewarp_frequency * Ts/2)/prewarp_frequency else: Twarp = Ts Ad, Bd, C, D, _ = cont2discrete(sys, Twarp, method, alpha) return StateSpace(Ad, Bd, C, D, Ts) - def dcgain(self): + def dcgain(self, warn_infinite=False): """Return the zero-frequency gain The zero-frequency gain of a continuous-time state-space @@ -927,59 +1216,160 @@ def dcgain(self): .. math: G(1) = C (I - A)^{-1} B + D + Parameters + ---------- + warn_infinite : bool, optional + By default, don't issue a warning message if the zero-frequency + gain is infinite. Setting `warn_infinite` to generate the warning + message. + Returns ------- - gain : ndarray - An array of shape (outputs,inputs); the array will either - be the zero-frequency (or DC) gain, or, if the frequency - response is singular, the array will be filled with np.nan. + gain : (noutputs, ninputs) ndarray or scalar + Array or scalar value for SISO systems, depending on + config.defaults['control.squeeze_frequency_response']. + The value of the array elements or the scalar is either the + zero-frequency (or DC) gain, or `inf`, if the frequency response + is singular. + + For real valued systems, the empty imaginary part of the + complex zero-frequency response is discarded and a real array or + scalar is returned. """ - try: - if self.isctime(): - gain = np.asarray(self.D-self.C.dot(np.linalg.solve(self.A, self.B))) - else: - gain = self.horner(1) - except LinAlgError: - # eigenvalue at DC - gain = np.tile(np.nan, (self.outputs, self.inputs)) - return np.squeeze(gain) + return self._dcgain(warn_infinite) + + def dynamics(self, t, x, u=None): + """Compute the dynamics of the system + + Given input `u` and state `x`, returns the dynamics of the state-space + system. If the system is continuous, returns the time derivative dx/dt + + dx/dt = A x + B u + + where A and B are the state-space matrices of the system. If the + system is discrete-time, returns the next value of `x`: + + x[t+dt] = A x[t] + B u[t] + + The inputs `x` and `u` must be of the correct length for the system. + + The first argument `t` is ignored because :class:`StateSpace` systems + are time-invariant. It is included so that the dynamics can be passed + to most numerical integrators, such as :func:`scipy.integrate.solve_ivp` + and for consistency with :class:`IOSystem` systems. + + Parameters + ---------- + t : float (ignored) + time + x : array_like + current state + u : array_like (optional) + input, zero if omitted + + Returns + ------- + dx/dt or x[t+dt] : ndarray + """ + x = np.reshape(x, (-1, 1)) # force to a column in case matrix + if np.size(x) != self.nstates: + raise ValueError("len(x) must be equal to number of states") + if u is None: + return self.A.dot(x).reshape((-1,)) # return as row vector + else: # received t, x, and u, ignore t + u = np.reshape(u, (-1, 1)) # force to a column in case matrix + if np.size(u) != self.ninputs: + raise ValueError("len(u) must be equal to number of inputs") + return self.A.dot(x).reshape((-1,)) \ + + self.B.dot(u).reshape((-1,)) # return as row vector + + def output(self, t, x, u=None): + """Compute the output of the system + + Given input `u` and state `x`, returns the output `y` of the + state-space system: + + y = C x + D u + + where A and B are the state-space matrices of the system. + + The first argument `t` is ignored because :class:`StateSpace` systems + are time-invariant. It is included so that the dynamics can be passed + to most numerical integrators, such as scipy's `integrate.solve_ivp` and + for consistency with :class:`IOSystem` systems. + + The inputs `x` and `u` must be of the correct length for the system. + + Parameters + ---------- + t : float (ignored) + time + x : array_like + current state + u : array_like (optional) + input (zero if omitted) + + Returns + ------- + y : ndarray + """ + x = np.reshape(x, (-1, 1)) # force to a column in case matrix + if np.size(x) != self.nstates: + raise ValueError("len(x) must be equal to number of states") + + if u is None: + return self.C.dot(x).reshape((-1,)) # return as row vector + else: # received t, x, and u, ignore t + u = np.reshape(u, (-1, 1)) # force to a column in case matrix + if np.size(u) != self.ninputs: + raise ValueError("len(u) must be equal to number of inputs") + return self.C.dot(x).reshape((-1,)) \ + + self.D.dot(u).reshape((-1,)) # return as row vector + + def _isstatic(self): + """True if and only if the system has no dynamics, that is, + if A and B are zero. """ + return not np.any(self.A) and not np.any(self.B) + - def is_static_gain(self): - """True if and only if the system has no dynamics, that is, - if A and B are zero. """ - return not np.any(self.A) and not np.any(self.B) # TODO: add discrete time check -def _convertToStateSpace(sys, **kw): +def _convert_to_statespace(sys, **kw): """Convert a system to state space form (if needed). - If sys is already a state space, then it is returned. If sys is a transfer - function object, then it is converted to a state space and returned. If sys - is a scalar, then the number of inputs and outputs can be specified - manually, as in: + If sys is already a state space, then it is returned. If sys is a + transfer function object, then it is converted to a state space and + returned. If sys is a scalar, then the number of inputs and outputs can + be specified manually, as in: - >>> sys = _convertToStateSpace(3.) # Assumes inputs = outputs = 1 - >>> sys = _convertToStateSpace(1., inputs=3, outputs=2) + >>> sys = _convert_to_statespace(3.) # Assumes inputs = outputs = 1 + >>> sys = _convert_to_statespace(1., inputs=3, outputs=2) In the latter example, A = B = C = 0 and D = [[1., 1., 1.] [1., 1., 1.]]. - """ from .xferfcn import TransferFunction import itertools + if isinstance(sys, StateSpace): if len(kw): - raise TypeError("If sys is a StateSpace, _convertToStateSpace \ -cannot take keywords.") + raise TypeError("If sys is a StateSpace, _convert_to_statespace " + "cannot take keywords.") # Already a state space system; just return it return sys + elif isinstance(sys, TransferFunction): + # Make sure the transfer function is proper + if any([[len(num) for num in col] for col in sys.num] > + [[len(num) for num in col] for col in sys.den]): + raise ValueError("Transfer function is non-proper; can't " + "convert to StateSpace system.") try: from slycot import td04ad if len(kw): raise TypeError("If sys is a TransferFunction, " - "_convertToStateSpace cannot take keywords.") + "_convert_to_statespace cannot take keywords.") # Change the numerator and denominator arrays so that the transfer # function matrix has a common denominator. @@ -987,12 +1377,14 @@ def _convertToStateSpace(sys, **kw): num, den, denorder = sys.minreal()._common_den() # transfer function to state space conversion now should work! - ssout = td04ad('C', sys.inputs, sys.outputs, + ssout = td04ad('C', sys.ninputs, sys.noutputs, denorder, den, num, tol=0) states = ssout[0] - return StateSpace(ssout[1][:states, :states], ssout[2][:states, :sys.inputs], - ssout[3][:sys.outputs, :states], ssout[4], sys.dt) + return StateSpace(ssout[1][:states, :states], + ssout[2][:states, :sys.ninputs], + ssout[3][:sys.noutputs, :states], ssout[4], + sys.dt) except ImportError: # No Slycot. Scipy tf->ss can't handle MIMO, but static # MIMO is an easy special case we can check for here @@ -1001,18 +1393,20 @@ def _convertToStateSpace(sys, **kw): maxd = max(max(len(d) for d in drow) for drow in sys.den) if 1 == maxn and 1 == maxd: - D = empty((sys.outputs, sys.inputs), dtype=float) - for i, j in itertools.product(range(sys.outputs), range(sys.inputs)): + D = empty((sys.noutputs, sys.ninputs), dtype=float) + for i, j in itertools.product(range(sys.noutputs), + range(sys.ninputs)): D[i, j] = sys.num[i][j][0] / sys.den[i][j][0] return StateSpace([], [], [], D, sys.dt) else: - if sys.inputs != 1 or sys.outputs != 1: + if sys.ninputs != 1 or sys.noutputs != 1: raise TypeError("No support for MIMO without slycot") # TODO: do we want to squeeze first and check dimenations? # I think this will fail if num and den aren't 1-D after # the squeeze - A, B, C, D = sp.signal.tf2ss(squeeze(sys.num), squeeze(sys.den)) + A, B, C, D = \ + sp.signal.tf2ss(squeeze(sys.num), squeeze(sys.den)) return StateSpace(A, B, C, D, sys.dt) elif isinstance(sys, (int, float, complex, np.number)): @@ -1028,21 +1422,19 @@ def _convertToStateSpace(sys, **kw): # Generate a simple state space system of the desired dimension # The following Doesn't work due to inconsistencies in ltisys: # return StateSpace([[]], [[]], [[]], eye(outputs, inputs)) - return StateSpace(0., zeros((1, inputs)), zeros((outputs, 1)), - sys * ones((outputs, inputs))) + return StateSpace([], zeros((0, inputs)), zeros((outputs, 0)), + sys * ones((outputs, inputs))) # If this is a matrix, try to create a constant feedthrough try: D = _ssmatrix(sys) return StateSpace([], [], [], D) - except Exception as e: - print("Failure to assume argument is matrix-like in" \ - " _convertToStateSpace, result %s" % e) + except: + raise TypeError("Can't convert given type to StateSpace system.") - raise TypeError("Can't convert given type to StateSpace system.") # TODO: add discrete time option -def _rss_generate(states, inputs, outputs, type): +def _rss_generate(states, inputs, outputs, type, strictly_proper=False): """Generate a random state space. This does the actual random state space generation expected from rss and @@ -1066,13 +1458,13 @@ def _rss_generate(states, inputs, outputs, type): # Check for valid input arguments. if states < 1 or states % 1: raise ValueError("states must be a positive integer. states = %g." % - states) + states) if inputs < 1 or inputs % 1: raise ValueError("inputs must be a positive integer. inputs = %g." % - inputs) + inputs) if outputs < 1 or outputs % 1: raise ValueError("outputs must be a positive integer. outputs = %g." % - outputs) + outputs) # Make some poles for A. Preallocate a complex array. poles = zeros(states) + zeros(states) * 0.j @@ -1152,7 +1544,7 @@ def _rss_generate(states, inputs, outputs, type): # Apply masks. B = B * Bmask C = C * Cmask - D = D * Dmask + D = D * Dmask if not strictly_proper else zeros(D.shape) return StateSpace(A, B, C, D) @@ -1160,7 +1552,7 @@ def _rss_generate(states, inputs, outputs, type): # Convert a MIMO system to a SISO system # TODO: add discrete time check def _mimo2siso(sys, input, output, warn_conversion=False): - #pylint: disable=W0622 + # pylint: disable=W0622 """ Convert a MIMO system to a SISO system. (Convert a system with multiple inputs and/or outputs, to a system with a single input and output.) @@ -1190,18 +1582,18 @@ def _mimo2siso(sys, input, output, warn_conversion=False): if not (isinstance(input, int) and isinstance(output, int)): raise TypeError("Parameters ``input`` and ``output`` must both " "be integer numbers.") - if not (0 <= input < sys.inputs): + if not (0 <= input < sys.ninputs): raise ValueError("Selected input does not exist. " "Selected input: {sel}, " "number of system inputs: {ext}." - .format(sel=input, ext=sys.inputs)) - if not (0 <= output < sys.outputs): + .format(sel=input, ext=sys.ninputs)) + if not (0 <= output < sys.noutputs): raise ValueError("Selected output does not exist. " "Selected output: {sel}, " "number of system outputs: {ext}." - .format(sel=output, ext=sys.outputs)) - #Convert sys to SISO if necessary - if sys.inputs > 1 or sys.outputs > 1: + .format(sel=output, ext=sys.noutputs)) + # Convert sys to SISO if necessary + if sys.ninputs > 1 or sys.noutputs > 1: if warn_conversion: warn("Converting MIMO system to SISO system. " "Only input {i} and output {o} are used." @@ -1246,13 +1638,13 @@ def _mimo2simo(sys, input, warn_conversion=False): """ if not (isinstance(input, int)): raise TypeError("Parameter ``input`` be an integer number.") - if not (0 <= input < sys.inputs): + if not (0 <= input < sys.ninputs): raise ValueError("Selected input does not exist. " "Selected input: {sel}, " "number of system inputs: {ext}." - .format(sel=input, ext=sys.inputs)) + .format(sel=input, ext=sys.ninputs)) # Convert sys to SISO if necessary - if sys.inputs > 1: + if sys.ninputs > 1: if warn_conversion: warn("Converting MIMO system to SIMO system. " "Only input {i} is used." .format(i=input)) @@ -1264,8 +1656,7 @@ def _mimo2simo(sys, input, warn_conversion=False): return sys - -def ss(*args): +def ss(*args, **kwargs): """ss(A, B, C, D[, dt]) Create a state space system. @@ -1310,8 +1701,7 @@ def ss(*args): Output matrix D: array_like or string Feed forward matrix - dt: If present, specifies the sampling period and a discrete time - system is created + dt: If present, specifies the timebase of the system Returns ------- @@ -1342,7 +1732,7 @@ def ss(*args): """ if len(args) == 4 or len(args) == 5: - return StateSpace(*args) + return StateSpace(*args, **kwargs) elif len(args) == 1: from .xferfcn import TransferFunction sys = args[0] @@ -1351,10 +1741,10 @@ def ss(*args): elif isinstance(sys, TransferFunction): return tf2ss(sys) else: - raise TypeError("ss(sys): sys must be a StateSpace or \ -TransferFunction object. It is %s." % type(sys)) + raise TypeError("ss(sys): sys must be a StateSpace or " + "TransferFunction object. It is %s." % type(sys)) else: - raise ValueError("Needs 1 or 4 arguments; received %i." % len(args)) + raise ValueError("Needs 1, 4, or 5 arguments; received %i." % len(args)) def tf2ss(*args): @@ -1376,16 +1766,16 @@ def tf2ss(*args): 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 Returns ------- - out: StateSpace + out : StateSpace New linear system in state space form Raises @@ -1417,19 +1807,19 @@ def tf2ss(*args): from .xferfcn import TransferFunction if len(args) == 2 or len(args) == 3: # Assume we were given the num, den - return _convertToStateSpace(TransferFunction(*args)) + return _convert_to_statespace(TransferFunction(*args)) elif len(args) == 1: sys = args[0] if not isinstance(sys, TransferFunction): - raise TypeError("tf2ss(sys): sys must be a TransferFunction \ -object.") - return _convertToStateSpace(sys) + raise TypeError("tf2ss(sys): sys must be a TransferFunction " + "object.") + return _convert_to_statespace(sys) else: raise ValueError("Needs 1 or 2 arguments; received %i." % len(args)) -def rss(states=1, outputs=1, inputs=1): +def rss(states=1, outputs=1, inputs=1, strictly_proper=False): """ Create a stable *continuous* random state space object. @@ -1441,6 +1831,9 @@ def rss(states=1, outputs=1, inputs=1): Number of system inputs outputs : integer Number of system outputs + strictly_proper : bool, optional + If set to 'True', returns a proper system (no direct term). Default + value is 'False'. Returns ------- @@ -1464,10 +1857,11 @@ def rss(states=1, outputs=1, inputs=1): """ - return _rss_generate(states, inputs, outputs, 'c') + return _rss_generate(states, inputs, outputs, 'c', + strictly_proper=strictly_proper) -def drss(states=1, outputs=1, inputs=1): +def drss(states=1, outputs=1, inputs=1, strictly_proper=False): """ Create a stable *discrete* random state space object. @@ -1502,7 +1896,8 @@ def drss(states=1, outputs=1, inputs=1): """ - return _rss_generate(states, inputs, outputs, 'd') + return _rss_generate(states, inputs, outputs, 'd', + strictly_proper=strictly_proper) def ssdata(sys): @@ -1519,5 +1914,5 @@ def ssdata(sys): (A, B, C, D): list of matrices State space data for the system """ - ss = _convertToStateSpace(sys) + ss = _convert_to_statespace(sys) return ss.A, ss.B, ss.C, ss.D diff --git a/control/tests/bdalg_test.py b/control/tests/bdalg_test.py index a7ec6c14b..433a584cc 100644 --- a/control/tests/bdalg_test.py +++ b/control/tests/bdalg_test.py @@ -1,50 +1,53 @@ -#!/usr/bin/env python -# -# bdalg_test.py - test suite for block diagram algebra -# RMM, 30 Mar 2011 (based on TestBDAlg from v0.4a) +"""bdalg_test.py - test suite for block diagram algebra + +RMM, 30 Mar 2011 (based on TestBDAlg from v0.4a) +""" -import unittest import numpy as np from numpy import sort +import pytest + import control as ctrl from control.xferfcn import TransferFunction from control.statesp import StateSpace from control.bdalg import feedback, append, connect from control.lti import zero, pole -class TestFeedback(unittest.TestCase): + +class TestFeedback: """These are tests for the feedback function in bdalg.py. Currently, some of the tests are not implemented, or are not working properly. TODO: these need to be fixed.""" - def setUp(self): - """This contains some random LTI systems and scalars for testing.""" - - # Two random SISO systems. - self.sys1 = TransferFunction([1, 2], [1, 2, 3]) - self.sys2 = StateSpace([[1., 4.], [3., 2.]], [[1.], [-4.]], - [[1., 0.]], [[0.]]) # 2 states, SISO - self.sys3 = StateSpace([[-1.]], [[1.]], [[1.]], [[0.]]) # 1 state, SISO + @pytest.fixture + def tsys(self): + class T: + pass + # Three SISO systems. + T.sys1 = TransferFunction([1, 2], [1, 2, 3]) + T.sys2 = StateSpace([[1., 4.], [3., 2.]], [[1.], [-4.]], + [[1., 0.]], [[0.]]) + T.sys3 = StateSpace([[-1.]], [[1.]], [[1.]], [[0.]]) # 1 state, SISO # Two random scalars. - self.x1 = 2.5 - self.x2 = -3. + T.x1 = 2.5 + T.x2 = -3. + return T - def testScalarScalar(self): + def testScalarScalar(self, tsys): """Scalar system with scalar feedback block.""" + ans1 = feedback(tsys.x1, tsys.x2) + ans2 = feedback(tsys.x1, tsys.x2, 1.) - ans1 = feedback(self.x1, self.x2) - ans2 = feedback(self.x1, self.x2, 1.) - - self.assertAlmostEqual(ans1.num[0][0][0] / ans1.den[0][0][0], - -2.5 / 6.5) - self.assertAlmostEqual(ans2.num[0][0][0] / ans2.den[0][0][0], 2.5 / 8.5) + np.testing.assert_almost_equal( + ans1.num[0][0][0] / ans1.den[0][0][0], -2.5 / 6.5) + np.testing.assert_almost_equal( + ans2.num[0][0][0] / ans2.den[0][0][0], 2.5 / 8.5) - def testScalarSS(self): + def testScalarSS(self, tsys): """Scalar system with state space feedback block.""" - - ans1 = feedback(self.x1, self.sys2) - ans2 = feedback(self.x1, self.sys2, 1.) + ans1 = feedback(tsys.x1, tsys.sys2) + ans2 = feedback(tsys.x1, tsys.sys2, 1.) np.testing.assert_array_almost_equal(ans1.A, [[-1.5, 4.], [13., 2.]]) np.testing.assert_array_almost_equal(ans1.B, [[2.5], [-10.]]) @@ -56,18 +59,17 @@ def testScalarSS(self): np.testing.assert_array_almost_equal(ans2.D, [[2.5]]) # Make sure default arugments work as well - ans3 = feedback(self.sys2, 1) - ans4 = feedback(self.sys2) + ans3 = feedback(tsys.sys2, 1) + ans4 = feedback(tsys.sys2) np.testing.assert_array_almost_equal(ans3.A, ans4.A) np.testing.assert_array_almost_equal(ans3.B, ans4.B) np.testing.assert_array_almost_equal(ans3.C, ans4.C) np.testing.assert_array_almost_equal(ans3.D, ans4.D) - def testScalarTF(self): + def testScalarTF(self, tsys): """Scalar system with transfer function feedback block.""" - - ans1 = feedback(self.x1, self.sys1) - ans2 = feedback(self.x1, self.sys1, 1.) + ans1 = feedback(tsys.x1, tsys.sys1) + ans2 = feedback(tsys.x1, tsys.sys1, 1.) np.testing.assert_array_almost_equal(ans1.num, [[[2.5, 5., 7.5]]]) np.testing.assert_array_almost_equal(ans1.den, [[[1., 4.5, 8.]]]) @@ -75,16 +77,15 @@ def testScalarTF(self): np.testing.assert_array_almost_equal(ans2.den, [[[1., -0.5, -2.]]]) # Make sure default arugments work as well - ans3 = feedback(self.sys1, 1) - ans4 = feedback(self.sys1) + ans3 = feedback(tsys.sys1, 1) + ans4 = feedback(tsys.sys1) np.testing.assert_array_almost_equal(ans3.num, ans4.num) np.testing.assert_array_almost_equal(ans3.den, ans4.den) - def testSSScalar(self): + def testSSScalar(self, tsys): """State space system with scalar feedback block.""" - - ans1 = feedback(self.sys2, self.x1) - ans2 = feedback(self.sys2, self.x1, 1.) + ans1 = feedback(tsys.sys2, tsys.x1) + ans2 = feedback(tsys.sys2, tsys.x1, 1.) np.testing.assert_array_almost_equal(ans1.A, [[-1.5, 4.], [13., 2.]]) np.testing.assert_array_almost_equal(ans1.B, [[1.], [-4.]]) @@ -95,11 +96,10 @@ def testSSScalar(self): np.testing.assert_array_almost_equal(ans2.C, [[1., 0.]]) np.testing.assert_array_almost_equal(ans2.D, [[0.]]) - def testSSSS1(self): + def testSSSS1(self, tsys): """State space system with state space feedback block.""" - - ans1 = feedback(self.sys2, self.sys2) - ans2 = feedback(self.sys2, self.sys2, 1.) + ans1 = feedback(tsys.sys2, tsys.sys2) + ans2 = feedback(tsys.sys2, tsys.sys2, 1.) np.testing.assert_array_almost_equal(ans1.A, [[1., 4., -1., 0.], [3., 2., 4., 0.], [1., 0., 1., 4.], [-4., 0., 3., 2]]) @@ -112,10 +112,9 @@ def testSSSS1(self): np.testing.assert_array_almost_equal(ans2.C, [[1., 0., 0., 0.]]) np.testing.assert_array_almost_equal(ans2.D, [[0.]]) - def testSSSS2(self): + def testSSSS2(self, tsys): """State space system with state space feedback block, including a direct feedthrough term.""" - sys3 = StateSpace([[-1., 4.], [2., -3]], [[2.], [3.]], [[-3., 1.]], [[-2.]]) sys4 = StateSpace([[-3., -2.], [1., 4.]], [[-2.], [-6.]], [[2., -3.]], @@ -147,42 +146,39 @@ def testSSSS2(self): np.testing.assert_array_almost_equal(ans2.D, [[-0.285714285714286]]) - def testSSTF(self): + def testSSTF(self, tsys): """State space system with transfer function feedback block.""" - # This functionality is not implemented yet. pass - def testTFScalar(self): + def testTFScalar(self, tsys): """Transfer function system with scalar feedback block.""" - - ans1 = feedback(self.sys1, self.x1) - ans2 = feedback(self.sys1, self.x1, 1.) + ans1 = feedback(tsys.sys1, tsys.x1) + ans2 = feedback(tsys.sys1, tsys.x1, 1.) np.testing.assert_array_almost_equal(ans1.num, [[[1., 2.]]]) np.testing.assert_array_almost_equal(ans1.den, [[[1., 4.5, 8.]]]) np.testing.assert_array_almost_equal(ans2.num, [[[1., 2.]]]) np.testing.assert_array_almost_equal(ans2.den, [[[1., -0.5, -2.]]]) - def testTFSS(self): + def testTFSS(self, tsys): """Transfer function system with state space feedback block.""" - # This functionality is not implemented yet. pass - def testTFTF(self): + def testTFTF(self, tsys): """Transfer function system with transfer function feedback block.""" - - ans1 = feedback(self.sys1, self.sys1) - ans2 = feedback(self.sys1, self.sys1, 1.) + ans1 = feedback(tsys.sys1, tsys.sys1) + ans2 = feedback(tsys.sys1, tsys.sys1, 1.) np.testing.assert_array_almost_equal(ans1.num, [[[1., 4., 7., 6.]]]) np.testing.assert_array_almost_equal(ans1.den, - [[[1., 4., 11., 16., 13.]]]) + [[[1., 4., 11., 16., 13.]]]) np.testing.assert_array_almost_equal(ans2.num, [[[1., 4., 7., 6.]]]) - np.testing.assert_array_almost_equal(ans2.den, [[[1., 4., 9., 8., 5.]]]) + np.testing.assert_array_almost_equal(ans2.den, + [[[1., 4., 9., 8., 5.]]]) - def testLists(self): + def testLists(self, tsys): """Make sure that lists of various lengths work for operations""" sys1 = ctrl.tf([1, 1], [1, 2]) sys2 = ctrl.tf([1, 3], [1, 4]) @@ -195,19 +191,19 @@ def testLists(self): np.testing.assert_array_almost_equal(sort(pole(sys1_2)), [-4., -2.]) np.testing.assert_array_almost_equal(sort(zero(sys1_2)), [-3., -1.]) - sys1_3 = ctrl.series(sys1, sys2, sys3); + sys1_3 = ctrl.series(sys1, sys2, sys3) np.testing.assert_array_almost_equal(sort(pole(sys1_3)), [-6., -4., -2.]) np.testing.assert_array_almost_equal(sort(zero(sys1_3)), [-5., -3., -1.]) - sys1_4 = ctrl.series(sys1, sys2, sys3, sys4); + sys1_4 = ctrl.series(sys1, sys2, sys3, sys4) np.testing.assert_array_almost_equal(sort(pole(sys1_4)), [-8., -6., -4., -2.]) np.testing.assert_array_almost_equal(sort(zero(sys1_4)), [-7., -5., -3., -1.]) - sys1_5 = ctrl.series(sys1, sys2, sys3, sys4, sys5); + sys1_5 = ctrl.series(sys1, sys2, sys3, sys4, sys5) np.testing.assert_array_almost_equal(sort(pole(sys1_5)), [-8., -6., -4., -2., -0.]) np.testing.assert_array_almost_equal(sort(zero(sys1_5)), @@ -219,109 +215,103 @@ def testLists(self): np.testing.assert_array_almost_equal(sort(zero(sys1_2)), sort(zero(sys1 + sys2))) - sys1_3 = ctrl.parallel(sys1, sys2, sys3); + sys1_3 = ctrl.parallel(sys1, sys2, sys3) np.testing.assert_array_almost_equal(sort(pole(sys1_3)), [-6., -4., -2.]) np.testing.assert_array_almost_equal(sort(zero(sys1_3)), sort(zero(sys1 + sys2 + sys3))) - sys1_4 = ctrl.parallel(sys1, sys2, sys3, sys4); + sys1_4 = ctrl.parallel(sys1, sys2, sys3, sys4) np.testing.assert_array_almost_equal(sort(pole(sys1_4)), [-8., -6., -4., -2.]) - np.testing.assert_array_almost_equal(sort(zero(sys1_4)), - sort(zero(sys1 + sys2 + - sys3 + sys4))) - + np.testing.assert_array_almost_equal( + sort(zero(sys1_4)), + sort(zero(sys1 + sys2 + sys3 + sys4))) - sys1_5 = ctrl.parallel(sys1, sys2, sys3, sys4, sys5); + sys1_5 = ctrl.parallel(sys1, sys2, sys3, sys4, sys5) np.testing.assert_array_almost_equal(sort(pole(sys1_5)), [-8., -6., -4., -2., -0.]) - np.testing.assert_array_almost_equal(sort(zero(sys1_5)), - sort(zero(sys1 + sys2 + - sys3 + sys4 + sys5))) - def testMimoSeries(self): + np.testing.assert_array_almost_equal( + sort(zero(sys1_5)), + sort(zero(sys1 + sys2 + sys3 + sys4 + sys5))) + + def testMimoSeries(self, tsys): """regression: bdalg.series reverses order of arguments""" - g1 = ctrl.ss([],[],[],[[1,2],[0,3]]) - g2 = ctrl.ss([],[],[],[[1,0],[2,3]]) - ref = g2*g1 - tst = ctrl.series(g1,g2) - # assert_array_equal on mismatched matrices gives - # "repr failed for : ..." - def assert_equal(x,y): - np.testing.assert_array_equal(np.asarray(x), - np.asarray(y)) - assert_equal(ref.A, tst.A) - assert_equal(ref.B, tst.B) - assert_equal(ref.C, tst.C) - assert_equal(ref.D, tst.D) - - def test_feedback_args(self): + g1 = ctrl.ss([], [], [], [[1, 2], [0, 3]]) + g2 = ctrl.ss([], [], [], [[1, 0], [2, 3]]) + ref = g2 * g1 + tst = ctrl.series(g1, g2) + + np.testing.assert_array_equal(ref.A, tst.A) + np.testing.assert_array_equal(ref.B, tst.B) + np.testing.assert_array_equal(ref.C, tst.C) + np.testing.assert_array_equal(ref.D, tst.D) + + def test_feedback_args(self, tsys): # Added 25 May 2019 to cover missing exception handling in feedback() # If first argument is not LTI or convertable, generate an exception - args = ([1], self.sys2) - self.assertRaises(TypeError, ctrl.feedback, *args) + args = ([1], tsys.sys2) + with pytest.raises(TypeError): + ctrl.feedback(*args) # If second argument is not LTI or convertable, generate an exception - args = (self.sys1, np.array([1])) - self.assertRaises(TypeError, ctrl.feedback, *args) + args = (tsys.sys1, 'hello world') + with pytest.raises(TypeError): + ctrl.feedback(*args) # Convert first argument to FRD, if needed h = TransferFunction([1], [1, 2, 2]) omega = np.logspace(-1, 2, 10) frd = ctrl.FRD(h, omega) sys = ctrl.feedback(1, frd) - self.assertTrue(isinstance(sys, ctrl.FRD)) + assert isinstance(sys, ctrl.FRD) - def testConnect(self): - sys = append(self.sys2, self.sys3) # two siso systems + def testConnect(self, tsys): + sys = append(tsys.sys2, tsys.sys3) # two siso systems # should not raise error connect(sys, [[1, 2], [2, -2]], [2], [1, 2]) connect(sys, [[1, 2], [2, 0]], [2], [1, 2]) connect(sys, [[1, 2, 0], [2, -2, 1]], [2], [1, 2]) connect(sys, [[1, 2], [2, -2]], [2, 1], [1]) - sys3x3 = append(sys, self.sys3) # 3x3 mimo + sys3x3 = append(sys, tsys.sys3) # 3x3 mimo connect(sys3x3, [[1, 2, 0], [2, -2, 1], [3, -3, 0]], [2], [1, 2]) connect(sys3x3, [[1, 2, 0], [2, -2, 1], [3, -3, 0]], [1, 2, 3], [3]) connect(sys3x3, [[1, 2, 0], [2, -2, 1], [3, -3, 0]], [2, 3], [2, 1]) # feedback interconnection out of bounds: input too high Q = [[1, 3], [2, -2]] - with self.assertRaises(IndexError): + with pytest.raises(IndexError): connect(sys, Q, [2], [1, 2]) # feedback interconnection out of bounds: input too low Q = [[0, 2], [2, -2]] - with self.assertRaises(IndexError): + with pytest.raises(IndexError): connect(sys, Q, [2], [1, 2]) # feedback interconnection out of bounds: output too high Q = [[1, 2], [2, -3]] - with self.assertRaises(IndexError): + with pytest.raises(IndexError): connect(sys, Q, [2], [1, 2]) Q = [[1, 2], [2, 4]] - with self.assertRaises(IndexError): + with pytest.raises(IndexError): connect(sys, Q, [2], [1, 2]) # input/output index testing - Q = [[1, 2], [2, -2]] # OK interconnection + Q = [[1, 2], [2, -2]] # OK interconnection # input index is out of bounds: too high - with self.assertRaises(IndexError): + with pytest.raises(IndexError): connect(sys, Q, [3], [1, 2]) # input index is out of bounds: too low - with self.assertRaises(IndexError): + with pytest.raises(IndexError): connect(sys, Q, [0], [1, 2]) - with self.assertRaises(IndexError): + with pytest.raises(IndexError): connect(sys, Q, [-2], [1, 2]) # output index is out of bounds: too high - with self.assertRaises(IndexError): + with pytest.raises(IndexError): connect(sys, Q, [2], [1, 3]) # output index is out of bounds: too low - with self.assertRaises(IndexError): + with pytest.raises(IndexError): connect(sys, Q, [2], [1, 0]) - with self.assertRaises(IndexError): + with pytest.raises(IndexError): connect(sys, Q, [2], [1, -1]) - - -if __name__ == "__main__": - unittest.main() diff --git a/control/tests/canonical_test.py b/control/tests/canonical_test.py index 7d4ae4e27..0db6b924c 100644 --- a/control/tests/canonical_test.py +++ b/control/tests/canonical_test.py @@ -1,24 +1,27 @@ -#!/usr/bin/env python +"""canonical_test.py""" -import unittest import numpy as np -from control import ss, tf, tf2ss, ss2tf +import pytest +import scipy.linalg + +from control.tests.conftest import slycotonly + +from control import ss, tf, tf2ss from control.canonical import canonical_form, reachable_form, \ - observable_form, modal_form, similarity_transform + observable_form, modal_form, similarity_transform, bdschur from control.exception import ControlNotImplemented -class TestCanonical(unittest.TestCase): +class TestCanonical: """Tests for the canonical forms class""" def test_reachable_form(self): """Test the reachable canonical form""" - # Create a system in the reachable canonical form coeffs = [1.0, 2.0, 3.0, 4.0, 1.0] A_true = np.polynomial.polynomial.polycompanion(coeffs) A_true = np.fliplr(np.rot90(A_true)) - B_true = np.matrix("1.0 0.0 0.0 0.0").T - C_true = np.matrix("1.0 1.0 1.0 1.0") + B_true = np.array([[1.0, 0.0, 0.0, 0.0]]).T + C_true = np.array([[1.0, 1.0, 1.0, 1.0]]) D_true = 42.0 # Perform a coordinate transform with a random invertible matrix @@ -44,132 +47,29 @@ def test_reachable_form(self): # Reachable form only supports SISO sys = tf([[ [1], [1] ]], [[ [1, 2, 1], [1, 2, 1] ]]) np.testing.assert_raises(ControlNotImplemented, reachable_form, sys) - def test_unreachable_system(self): """Test reachable canonical form with an unreachable system""" - # Create an unreachable system - A = np.matrix("1.0 2.0 2.0; 4.0 5.0 5.0; 7.0 8.0 8.0") - B = np.matrix("1.0 1.0 1.0").T - C = np.matrix("1.0 1.0 1.0") - D = 42.0 + A = np.array([[1., 2., 2.], + [4., 5., 5.], + [7., 8., 8.]]) + B = np.array([[1.], [1.],[1.]]) + C = np.array([[1., 1.,1.]]) + D = np.array([[42.0]]) sys = ss(A, B, C, D) # Check if an exception is raised np.testing.assert_raises(ValueError, canonical_form, sys, "reachable") - def test_modal_form(self): - """Test the modal canonical form""" - - # Create a system in the modal canonical form - A_true = np.diag([4.0, 3.0, 2.0, 1.0]) # order from the largest to the smallest - B_true = np.matrix("1.1 2.2 3.3 4.4").T - C_true = np.matrix("1.3 1.4 1.5 1.6") - D_true = 42.0 - - # Perform a coordinate transform with a random invertible matrix - T_true = np.array([[-0.27144004, -0.39933167, 0.75634684, 0.44135471], - [-0.74855725, -0.39136285, -0.18142339, -0.50356997], - [-0.40688007, 0.81416369, 0.38002113, -0.16483334], - [-0.44769516, 0.15654653, -0.50060858, 0.72419146]]) - A = np.linalg.solve(T_true, A_true).dot(T_true) - B = np.linalg.solve(T_true, B_true) - C = C_true*T_true - D = D_true - - # Create a state space system and convert it to the modal canonical form - sys_check, T_check = canonical_form(ss(A, B, C, D), "modal") - - # Check against the true values - # TODO: Test in respect to ambiguous transformation (system characteristics?) - np.testing.assert_array_almost_equal(sys_check.A, A_true) - #np.testing.assert_array_almost_equal(sys_check.B, B_true) - #np.testing.assert_array_almost_equal(sys_check.C, C_true) - np.testing.assert_array_almost_equal(sys_check.D, D_true) - #np.testing.assert_array_almost_equal(T_check, T_true) - - # Check conversion when there are complex eigenvalues - A_true = np.array([[-1, 1, 0, 0], - [-1, -1, 0, 0], - [ 0, 0, -2, 0], - [ 0, 0, 0, -3]]) - B_true = np.array([[0], [1], [0], [1]]) - C_true = np.array([[1, 0, 0, 1]]) - D_true = np.array([[0]]) - - A = np.linalg.solve(T_true, A_true).dot(T_true) - B = np.linalg.solve(T_true, B_true) - C = C_true.dot(T_true) - D = D_true - - # Create state space system and convert to modal canonical form - sys_check, T_check = canonical_form(ss(A, B, C, D), 'modal') - - # Check A and D matrix, which are uniquely defined - np.testing.assert_array_almost_equal(sys_check.A, A_true) - np.testing.assert_array_almost_equal(sys_check.D, D_true) - - # B matrix should be all ones (or zero if not controllable) - # TODO: need to update modal_form() to implement this - if np.allclose(T_check, T_true): - np.testing.assert_array_almost_equal(sys_check.B, B_true) - np.testing.assert_array_almost_equal(sys_check.C, C_true) - - # Make sure Hankel coefficients are OK - from numpy.linalg import matrix_power - for i in range(A.shape[0]): - np.testing.assert_almost_equal( - np.dot(np.dot(C_true, matrix_power(A_true, i)), B_true), - np.dot(np.dot(C, matrix_power(A, i)), B)) - - # Reorder rows to get complete coverage (real eigenvalue cxrtvfirst) - A_true = np.array([[-1, 0, 0, 0], - [ 0, -2, 1, 0], - [ 0, -1, -2, 0], - [ 0, 0, 0, -3]]) - B_true = np.array([[0], [0], [1], [1]]) - C_true = np.array([[0, 1, 0, 1]]) - D_true = np.array([[0]]) - - A = np.linalg.solve(T_true, A_true).dot(T_true) - B = np.linalg.solve(T_true, B_true) - C = C_true.dot(T_true) - D = D_true - - # Create state space system and convert to modal canonical form - sys_check, T_check = canonical_form(ss(A, B, C, D), 'modal') - - # Check A and D matrix, which are uniquely defined - np.testing.assert_array_almost_equal(sys_check.A, A_true) - np.testing.assert_array_almost_equal(sys_check.D, D_true) - - # B matrix should be all ones (or zero if not controllable) - # TODO: need to update modal_form() to implement this - if np.allclose(T_check, T_true): - np.testing.assert_array_almost_equal(sys_check.B, B_true) - np.testing.assert_array_almost_equal(sys_check.C, C_true) - - # Make sure Hankel coefficients are OK - from numpy.linalg import matrix_power - for i in range(A.shape[0]): - np.testing.assert_almost_equal( - np.dot(np.dot(C_true, matrix_power(A_true, i)), B_true), - np.dot(np.dot(C, matrix_power(A, i)), B)) - - # Modal form only supports SISO - sys = tf([[ [1], [1] ]], [[ [1, 2, 1], [1, 2, 1] ]]) - np.testing.assert_raises(ControlNotImplemented, modal_form, sys) - def test_observable_form(self): """Test the observable canonical form""" - # Create a system in the observable canonical form coeffs = [1.0, 2.0, 3.0, 4.0, 1.0] A_true = np.polynomial.polynomial.polycompanion(coeffs) A_true = np.fliplr(np.flipud(A_true)) - B_true = np.matrix("1.0 1.0 1.0 1.0").T - C_true = np.matrix("1.0 0.0 0.0 0.0") + B_true = np.array([[1.0, 1.0, 1.0, 1.0]]).T + C_true = np.array([[1.0, 0.0, 0.0, 0.0]]) D_true = 42.0 # Perform a coordinate transform with a random invertible matrix @@ -192,31 +92,35 @@ def test_observable_form(self): np.testing.assert_array_almost_equal(sys_check.D, D_true) np.testing.assert_array_almost_equal(T_check, T_true) - # Observable form only supports SISO - sys = tf([[ [1], [1] ]], [[ [1, 2, 1], [1, 2, 1] ]]) - np.testing.assert_raises(ControlNotImplemented, observable_form, sys) - + def test_observable_form_MIMO(self): + """Test error as Observable form only supports SISO""" + sys = tf([[[1], [1] ]], [[[1, 2, 1], [1, 2, 1]]]) + with pytest.raises(ControlNotImplemented): + observable_form(sys) def test_unobservable_system(self): """Test observable canonical form with an unobservable system""" - # Create an unobservable system - A = np.matrix("1.0 2.0 2.0; 4.0 5.0 5.0; 7.0 8.0 8.0") - B = np.matrix("1.0 1.0 1.0").T - C = np.matrix("1.0 1.0 1.0") + A = np.array([[1., 2., 2.], + [4., 5., 5.], + [7., 8., 8.]]) + + B = np.array([[1.], [1.], [1.]]) + C = np.array([[1., 1., 1.]]) D = 42.0 sys = ss(A, B, C, D) # Check if an exception is raised - np.testing.assert_raises(ValueError, canonical_form, sys, "observable") + with pytest.raises(ValueError): + canonical_form(sys, "observable") def test_arguments(self): # Additional unit tests added on 25 May 2019 to increase coverage # Unknown canonical forms should generate exception sys = tf([1], [1, 2, 1]) - np.testing.assert_raises( - ControlNotImplemented, canonical_form, sys, 'unknown') + with pytest.raises(ControlNotImplemented): + canonical_form(sys, 'unknown') def test_similarity(self): """Test similarty transform""" @@ -261,7 +165,7 @@ def test_similarity(self): np.testing.assert_array_almost_equal(mimo_new.B, mimo_ini.B) np.testing.assert_array_almost_equal(mimo_new.C, mimo_ini.C) np.testing.assert_array_almost_equal(mimo_new.D, mimo_ini.D) - + # Time rescaling mimo_tim = similarity_transform(mimo_ini, np.eye(4), timescale=0.3) mimo_new = similarity_transform(mimo_tim, np.eye(4), timescale=1/0.3) @@ -287,7 +191,254 @@ def test_similarity(self): np.testing.assert_array_almost_equal(mimo_new.B, mimo_ini.B) np.testing.assert_array_almost_equal(mimo_new.C, mimo_ini.C) np.testing.assert_array_almost_equal(mimo_new.D, mimo_ini.D) - -if __name__ == "__main__": - unittest.main() + +def extract_bdiag(a, blksizes): + """ + Extract block diagonals + + Parameters + ---------- + a - matrix to get blocks from + blksizes - sequence of block diagonal sizes + + Returns + ------- + Block diagonals + + Notes + ----- + Conceptually, inverse of scipy.linalg.block_diag + """ + idx0s = np.hstack([0, np.cumsum(blksizes[:-1], dtype=int)]) + return tuple(a[idx0:idx0+blksize,idx0:idx0+blksize] + for idx0, blksize in zip(idx0s, blksizes)) + + +def companion_from_eig(eigvals): + """ + Find companion matrix for given eigenvalue sequence. + """ + from numpy.polynomial.polynomial import polyfromroots, polycompanion + return polycompanion(polyfromroots(eigvals)).real + + +def block_diag_from_eig(eigvals): + """ + Find block-diagonal matrix for given eigenvalue sequence + + Returns ideal, non-defective, schur block-diagonal form. + """ + blocks = [] + i = 0 + while i < len(eigvals): + e = eigvals[i] + if e.imag == 0: + blocks.append(e.real) + i += 1 + else: + assert e == eigvals[i+1].conjugate() + blocks.append([[e.real, e.imag], + [-e.imag, e.real]]) + i += 2 + return scipy.linalg.block_diag(*blocks) + + +@slycotonly +@pytest.mark.parametrize( + "eigvals, condmax, blksizes", + [ + ([-1,-2,-3,-4,-5], None, [1,1,1,1,1]), + ([-1,-2,-3,-4,-5], 1.01, [5]), + ([-1,-1,-2,-2,-2], None, [2,3]), + ([-1+1j,-1-1j,-2+2j,-2-2j,-2], None, [2,2,1]), + ]) +def test_bdschur_ref(eigvals, condmax, blksizes): + # "reference" check + # uses companion form to introduce numerical complications + from numpy.linalg import solve + + a = companion_from_eig(eigvals) + b, t, test_blksizes = bdschur(a, condmax=condmax) + + np.testing.assert_array_equal(np.sort(test_blksizes), np.sort(blksizes)) + + bdiag_b = scipy.linalg.block_diag(*extract_bdiag(b, test_blksizes)) + np.testing.assert_array_almost_equal(bdiag_b, b) + + np.testing.assert_array_almost_equal(solve(t, a).dot(t), b) + + +@slycotonly +@pytest.mark.parametrize( + "eigvals, sorted_blk_eigvals, sort", + [ + ([-2,-1,0,1,2], [2,1,0,-1,-2], 'continuous'), + ([-2,-2+2j,-2-2j,-2-3j,-2+3j], [-2+3j,-2+2j,-2], 'continuous'), + (np.exp([-0.2,-0.1,0,0.1,0.2]), np.exp([0.2,0.1,0,-0.1,-0.2]), 'discrete'), + (np.exp([-0.2+0.2j,-0.2-0.2j, -0.01, -0.03-0.3j,-0.03+0.3j,]), + np.exp([-0.01, -0.03+0.3j, -0.2+0.2j]), + 'discrete'), + ]) +def test_bdschur_sort(eigvals, sorted_blk_eigvals, sort): + # use block diagonal form to prevent numerical complications + # for discrete case, exp and log introduce round-off, can't test as compeletely + a = block_diag_from_eig(eigvals) + + b, t, blksizes = bdschur(a, sort=sort) + assert len(blksizes) == len(sorted_blk_eigvals) + + blocks = extract_bdiag(b, blksizes) + for block, blk_eigval in zip(blocks, sorted_blk_eigvals): + test_eigvals = np.linalg.eigvals(block) + np.testing.assert_allclose(test_eigvals.real, + blk_eigval.real) + + np.testing.assert_allclose(abs(test_eigvals.imag), + blk_eigval.imag) + + +@slycotonly +def test_bdschur_defective(): + # the eigenvalues of this simple defective matrix cannot be separated + # a previous version of the bdschur would fail on this + a = companion_from_eig([-1, -1]) + amodal, tmodal, blksizes = bdschur(a, condmax=1e200) + + +def test_bdschur_empty(): + # empty matrix in gives empty matrix out + a = np.empty(shape=(0,0)) + b, t, blksizes = bdschur(a) + np.testing.assert_array_equal(b, a) + np.testing.assert_array_equal(t, a) + np.testing.assert_array_equal(blksizes, np.array([])) + + +def test_bdschur_condmax_lt_1(): + # require condmax >= 1.0 + with pytest.raises(ValueError): + bdschur(1, condmax=np.nextafter(1, 0)) + + +@slycotonly +def test_bdschur_invalid_sort(): + # sort must be in ('continuous', 'discrete') + with pytest.raises(ValueError): + bdschur(1, sort='no-such-sort') + + +@slycotonly +@pytest.mark.parametrize( + "A_true, B_true, C_true, D_true", + [(np.diag([4.0, 3.0, 2.0, 1.0]), # order from largest to smallest + np.array([[1.1, 2.2, 3.3, 4.4]]).T, + np.array([[1.3, 1.4, 1.5, 1.6]]), + np.array([[42.0]])), + + (np.array([[-1, 1, 0, 0], + [-1, -1, 0, 0], + [ 0, 0, -2, 1], + [ 0, 0, 0, -3]]), + np.array([[0, 1, 0, 0], + [0, 0, 0, 1]]).T, + np.array([[1, 0, 1, 0], + [0, 1, 0, 0], + [0, 0, 0, 1]]), + np.array([[0, 1], + [1, 0], + [0, 0]])), + ], + ids=["sys1", "sys2"]) +def test_modal_form(A_true, B_true, C_true, D_true): + # Check modal_canonical corresponds to bdschur + # Perform a coordinate transform with a random invertible matrix + T_true = np.array([[-0.27144004, -0.39933167, 0.75634684, 0.44135471], + [-0.74855725, -0.39136285, -0.18142339, -0.50356997], + [-0.40688007, 0.81416369, 0.38002113, -0.16483334], + [-0.44769516, 0.15654653, -0.50060858, 0.72419146]]) + A = np.linalg.solve(T_true, A_true).dot(T_true) + B = np.linalg.solve(T_true, B_true) + C = C_true.dot(T_true) + D = D_true + + # Create a state space system and convert it to modal canonical form + sys_check, T_check = modal_form(ss(A, B, C, D)) + + a_bds, t_bds, _ = bdschur(A) + + np.testing.assert_array_almost_equal(sys_check.A, a_bds) + np.testing.assert_array_almost_equal(T_check, t_bds) + np.testing.assert_array_almost_equal(sys_check.B, np.linalg.solve(t_bds, B)) + np.testing.assert_array_almost_equal(sys_check.C, C.dot(t_bds)) + np.testing.assert_array_almost_equal(sys_check.D, D) + + # canonical_form(...,'modal') is the same as modal_form with default parameters + cf_sys, T_cf = canonical_form(ss(A, B, C, D), 'modal') + np.testing.assert_array_almost_equal(cf_sys.A, sys_check.A) + np.testing.assert_array_almost_equal(cf_sys.B, sys_check.B) + np.testing.assert_array_almost_equal(cf_sys.C, sys_check.C) + np.testing.assert_array_almost_equal(cf_sys.D, sys_check.D) + np.testing.assert_array_almost_equal(T_check, T_cf) + + # Make sure Hankel coefficients are OK + for i in range(A.shape[0]): + np.testing.assert_almost_equal( + np.dot(np.dot(C_true, np.linalg.matrix_power(A_true, i)), + B_true), + np.dot(np.dot(C, np.linalg.matrix_power(A, i)), B)) + + +@slycotonly +@pytest.mark.parametrize( + "condmax, len_blksizes", + [(1.1, 1), + (None, 5)]) +def test_modal_form_condmax(condmax, len_blksizes): + # condmax passed through as expected + a = companion_from_eig([-1, -2, -3, -4, -5]) + amodal, tmodal, blksizes = bdschur(a, condmax=condmax) + assert len(blksizes) == len_blksizes + xsys = ss(a, [[1],[0],[0],[0],[0]], [0,0,0,0,1], 0) + zsys, t = modal_form(xsys, condmax=condmax) + np.testing.assert_array_almost_equal(zsys.A, amodal) + np.testing.assert_array_almost_equal(t, tmodal) + np.testing.assert_array_almost_equal(zsys.B, np.linalg.solve(tmodal, xsys.B)) + np.testing.assert_array_almost_equal(zsys.C, xsys.C.dot(tmodal)) + np.testing.assert_array_almost_equal(zsys.D, xsys.D) + + +@slycotonly +@pytest.mark.parametrize( + "sys_type", + ['continuous', + 'discrete']) +def test_modal_form_sort(sys_type): + a = companion_from_eig([0.1+0.9j,0.1-0.9j, 0.2+0.8j, 0.2-0.8j]) + amodal, tmodal, blksizes = bdschur(a, sort=sys_type) + + dt = 0 if sys_type == 'continuous' else True + + xsys = ss(a, [[1],[0],[0],[0],], [0,0,0,1], 0, dt) + zsys, t = modal_form(xsys, sort=True) + + my_amodal = np.linalg.solve(tmodal, a).dot(tmodal) + np.testing.assert_array_almost_equal(amodal, my_amodal) + + np.testing.assert_array_almost_equal(t, tmodal) + np.testing.assert_array_almost_equal(zsys.A, amodal) + np.testing.assert_array_almost_equal(zsys.B, np.linalg.solve(tmodal, xsys.B)) + np.testing.assert_array_almost_equal(zsys.C, xsys.C.dot(tmodal)) + np.testing.assert_array_almost_equal(zsys.D, xsys.D) + + +def test_modal_form_empty(): + # empty system should be returned as-is + # t empty matrix + insys = ss([], [], [], 123) + outsys, t = modal_form(insys) + np.testing.assert_array_equal(outsys.A, insys.A) + np.testing.assert_array_equal(outsys.B, insys.B) + np.testing.assert_array_equal(outsys.C, insys.C) + np.testing.assert_array_equal(outsys.D, insys.D) + assert t.shape == (0,0) diff --git a/control/tests/config_test.py b/control/tests/config_test.py index 667a7e3c4..c8e4c6cd5 100644 --- a/control/tests/config_test.py +++ b/control/tests/config_test.py @@ -1,52 +1,55 @@ -#!/usr/bin/env python -# -# config_test.py - test config module -# RMM, 25 may 2019 -# -# This test suite checks the functionality of the config module - -import unittest +"""config_test.py - test config module + +RMM, 25 may 2019 + +This test suite checks the functionality of the config module +""" + +from math import pi, log10 + +import matplotlib.pyplot as plt +from matplotlib.testing.decorators import cleanup as mplcleanup import numpy as np +import pytest + import control as ct -import matplotlib.pyplot as plt -from math import pi, log10 -class TestConfig(unittest.TestCase): - def setUp(self): - # Create a simple second order system to use for testing - self.sys = ct.tf([10], [1, 2, 1]) +@pytest.mark.usefixtures("editsdefaults") # makes sure to reset the defaults + # to the test configuration +class TestConfig: + + # Create a simple second order system to use for testing + sys = ct.tf([10], [1, 2, 1]) def test_set_defaults(self): ct.config.set_defaults('config', test1=1, test2=2, test3=None) - self.assertEqual(ct.config.defaults['config.test1'], 1) - self.assertEqual(ct.config.defaults['config.test2'], 2) - self.assertEqual(ct.config.defaults['config.test3'], None) + assert ct.config.defaults['config.test1'] == 1 + assert ct.config.defaults['config.test2'] == 2 + assert ct.config.defaults['config.test3'] is None + @mplcleanup def test_get_param(self): - self.assertEqual( - ct.config._get_param('bode', 'dB'), - ct.config.defaults['bode.dB']) - self.assertEqual(ct.config._get_param('bode', 'dB', 1), 1) + assert ct.config._get_param('bode', 'dB')\ + == ct.config.defaults['bode.dB'] + assert ct.config._get_param('bode', 'dB', 1) == 1 ct.config.defaults['config.test1'] = 1 - self.assertEqual(ct.config._get_param('config', 'test1', None), 1) - self.assertEqual(ct.config._get_param('config', 'test1', None, 1), 1) - + assert ct.config._get_param('config', 'test1', None) == 1 + assert ct.config._get_param('config', 'test1', None, 1) == 1 + ct.config.defaults['config.test3'] = None - self.assertEqual(ct.config._get_param('config', 'test3'), None) - self.assertEqual(ct.config._get_param('config', 'test3', 1), 1) - self.assertEqual( - ct.config._get_param('config', 'test3', None, 1), None) - - self.assertEqual(ct.config._get_param('config', 'test4'), None) - self.assertEqual(ct.config._get_param('config', 'test4', 1), 1) - self.assertEqual(ct.config._get_param('config', 'test4', 2, 1), 2) - self.assertEqual(ct.config._get_param('config', 'test4', None, 3), 3) + assert ct.config._get_param('config', 'test3') is None + assert ct.config._get_param('config', 'test3', 1) == 1 + assert ct.config._get_param('config', 'test3', None, 1) is None - self.assertEqual( - ct.config._get_param('config', 'test4', {'test4':1}, None), 1) + assert ct.config._get_param('config', 'test4') is None + assert ct.config._get_param('config', 'test4', 1) == 1 + assert ct.config._get_param('config', 'test4', 2, 1) == 2 + assert ct.config._get_param('config', 'test4', None, 3) == 3 + assert ct.config._get_param('config', 'test4', {'test4': 1}, None) == 1 + @mplcleanup def test_fbs_bode(self): ct.use_fbs_defaults() @@ -91,8 +94,7 @@ def test_fbs_bode(self): phase_x, phase_y = (((plt.gcf().axes[1]).get_lines())[0]).get_data() np.testing.assert_almost_equal(phase_y[-1], -pi, decimal=2) - ct.reset_defaults() - + @mplcleanup def test_matlab_bode(self): ct.use_matlab_defaults() @@ -120,7 +122,7 @@ def test_matlab_bode(self): # Make sure the x-axis is in rad/sec and y-axis is in degrees np.testing.assert_almost_equal(phase_x[-1], 1000, decimal=1) np.testing.assert_almost_equal(phase_y[-1], -180, decimal=0) - + # Override the defaults and make sure that works as well plt.figure() ct.bode_plot(self.sys, omega, dB=True) @@ -137,8 +139,7 @@ def test_matlab_bode(self): phase_x, phase_y = (((plt.gcf().axes[1]).get_lines())[0]).get_data() np.testing.assert_almost_equal(phase_y[-1], -pi, decimal=2) - ct.reset_defaults() - + @mplcleanup def test_custom_bode_default(self): ct.config.defaults['bode.dB'] = True ct.config.defaults['bode.deg'] = True @@ -160,24 +161,22 @@ def test_custom_bode_default(self): np.testing.assert_almost_equal(mag_y[0], 20*log10(10), decimal=3) np.testing.assert_almost_equal(phase_y[-1], -pi, decimal=2) - ct.reset_defaults() - + @mplcleanup def test_bode_number_of_samples(self): # Set the number of samples (default is 50, from np.logspace) mag_ret, phase_ret, omega_ret = ct.bode_plot(self.sys, omega_num=87) - self.assertEqual(len(mag_ret), 87) + assert len(mag_ret) == 87 # Change the default number of samples ct.config.defaults['freqplot.number_of_samples'] = 76 mag_ret, phase_ret, omega_ret = ct.bode_plot(self.sys) - self.assertEqual(len(mag_ret), 76) - + assert len(mag_ret) == 76 + # Override the default number of samples mag_ret, phase_ret, omega_ret = ct.bode_plot(self.sys, omega_num=87) - self.assertEqual(len(mag_ret), 87) - - ct.reset_defaults() + assert len(mag_ret) == 87 + @mplcleanup def test_bode_feature_periphery_decade(self): # Generate a sample Bode plot to figure out the range it uses ct.reset_defaults() # Make sure starting state is correct @@ -198,49 +197,68 @@ def test_bode_feature_periphery_decade(self): np.testing.assert_almost_equal(omega_ret[0], omega_min*10) np.testing.assert_almost_equal(omega_ret[-1], omega_max/10) - ct.reset_defaults() - def test_reset_defaults(self): ct.use_matlab_defaults() ct.reset_defaults() - self.assertEqual(ct.config.defaults['bode.dB'], False) - self.assertEqual(ct.config.defaults['bode.deg'], True) - self.assertEqual(ct.config.defaults['bode.Hz'], False) - self.assertEqual( - ct.config.defaults['freqplot.number_of_samples'], None) - self.assertEqual( - ct.config.defaults['freqplot.feature_periphery_decades'], 1.0) + assert not ct.config.defaults['bode.dB'] + assert ct.config.defaults['bode.deg'] + assert not ct.config.defaults['bode.Hz'] + assert ct.config.defaults['freqplot.number_of_samples'] == 1000 + assert ct.config.defaults['freqplot.feature_periphery_decades'] == 1.0 def test_legacy_defaults(self): - ct.use_legacy_defaults('0.8.3') - assert(isinstance(ct.ss(0,0,0,1).D, np.matrix)) + with pytest.deprecated_call(): + ct.use_legacy_defaults('0.8.3') + assert(isinstance(ct.ss(0, 0, 0, 1).D, np.matrix)) ct.reset_defaults() - assert(isinstance(ct.ss(0,0,0,1).D, np.ndarray)) + assert isinstance(ct.ss(0, 0, 0, 1).D, np.ndarray) + assert not isinstance(ct.ss(0, 0, 0, 1).D, np.matrix) + + ct.use_legacy_defaults('0.8.4') + assert ct.config.defaults['forced_response.return_x'] is True + + ct.use_legacy_defaults('0.9.0') + assert isinstance(ct.ss(0, 0, 0, 1).D, np.ndarray) + assert not isinstance(ct.ss(0, 0, 0, 1).D, np.matrix) + # test that old versions don't raise a problem + ct.use_legacy_defaults('REL-0.1') + ct.use_legacy_defaults('control-0.3a') ct.use_legacy_defaults('0.6c') ct.use_legacy_defaults('0.8.2') ct.use_legacy_defaults('0.1') - ct.config.reset_defaults() - - - def test_change_default_dt(self): - ct.set_defaults('statesp', default_dt=0) - self.assertEqual(ct.ss(0,0,0,1).dt, 0) - ct.set_defaults('statesp', default_dt=None) - self.assertEqual(ct.ss(0,0,0,1).dt, None) - ct.set_defaults('xferfcn', default_dt=0) - self.assertEqual(ct.tf(1, 1).dt, 0) - ct.set_defaults('xferfcn', default_dt=None) - self.assertEqual(ct.tf(1, 1).dt, None) - - - def tearDown(self): - # Get rid of any figures that we created - plt.close('all') - - # Reset the configuration defaults - ct.config.reset_defaults() - - -if __name__ == '__main__': - unittest.main() + + # Make sure that nonsense versions generate an error + with pytest.raises(ValueError): + ct.use_legacy_defaults("a.b.c") + with pytest.raises(ValueError): + ct.use_legacy_defaults("1.x.3") + + @pytest.mark.parametrize("dt", [0, None]) + def test_change_default_dt(self, dt): + """Test that system with dynamics uses correct default dt""" + ct.set_defaults('control', default_dt=dt) + assert ct.ss(1, 0, 0, 1).dt == dt + assert ct.tf(1, [1, 1]).dt == dt + nlsys = ct.iosys.NonlinearIOSystem( + lambda t, x, u: u * x * x, + lambda t, x, u: x, inputs=1, outputs=1) + assert nlsys.dt == dt + + def test_change_default_dt_static(self): + """Test that static gain systems always have dt=None""" + ct.set_defaults('control', default_dt=0) + assert ct.tf(1, 1).dt is None + assert ct.ss(0, 0, 0, 1).dt is None + # TODO: add in test for static gain iosys + + def test_get_param_last(self): + """Test _get_param last keyword""" + kwargs = {'first': 1, 'second': 2} + + with pytest.raises(TypeError, match="unrecognized keyword.*second"): + assert ct.config._get_param( + 'config', 'first', kwargs, pop=True, last=True) == 1 + + assert ct.config._get_param( + 'config', 'second', kwargs, pop=True, last=True) == 2 diff --git a/control/tests/conftest.py b/control/tests/conftest.py old mode 100755 new mode 100644 index 60c3d0de1..b67ef3674 --- a/control/tests/conftest.py +++ b/control/tests/conftest.py @@ -1,18 +1,103 @@ -# contest.py - pytest local plugins and fixtures +"""conftest.py - pytest local plugins and fixtures""" +from contextlib import contextmanager +from distutils.version import StrictVersion import os +import sys import matplotlib as mpl +import numpy as np +import scipy as sp import pytest import control +TEST_MATRIX_AND_ARRAY = os.getenv("PYTHON_CONTROL_ARRAY_AND_MATRIX") == "1" + +# some common pytest marks. These can be used as test decorators or in +# pytest.param(marks=) +slycotonly = pytest.mark.skipif(not control.exception.slycot_check(), + reason="slycot not installed") +noscipy0 = pytest.mark.skipif(StrictVersion(sp.__version__) < "1.0", + reason="requires SciPy 1.0 or greater") +nopython2 = pytest.mark.skipif(sys.version_info < (3, 0), + reason="requires Python 3+") +matrixfilter = pytest.mark.filterwarnings("ignore:.*matrix subclass:" + "PendingDeprecationWarning") +matrixerrorfilter = pytest.mark.filterwarnings("error:.*matrix subclass:" + "PendingDeprecationWarning") + @pytest.fixture(scope="session", autouse=True) -def use_numpy_ndarray(): - """Switch the config to use ndarray instead of matrix""" - if os.getenv("PYTHON_CONTROL_STATESPACE_ARRAY") == "1": - control.config.defaults['statesp.use_numpy_matrix'] = False +def control_defaults(): + """Make sure the testing session always starts with the defaults. + + This should be the first fixture initialized, + so that all other fixtures see the general defaults (unless they set them + themselves) even before importing control/__init__. Enforce this by adding + it as an argument to all other session scoped fixtures. + """ + control.reset_defaults() + the_defaults = control.config.defaults.copy() + yield + # assert that nothing changed it without reverting + assert control.config.defaults == the_defaults + +@pytest.fixture(scope="function", autouse=TEST_MATRIX_AND_ARRAY, + params=[pytest.param("arrayout", marks=matrixerrorfilter), + pytest.param("matrixout", marks=matrixfilter)]) +def matarrayout(request): + """Switch the config to use np.ndarray and np.matrix as returns""" + restore = control.config.defaults['statesp.use_numpy_matrix'] + control.use_numpy_matrix(request.param == "matrixout", warn=False) + yield + control.use_numpy_matrix(restore, warn=False) + + +def ismatarrayout(obj): + """Test if the returned object has the correct type as configured + + note that isinstance(np.matrix(obj), np.ndarray) is True + """ + use_matrix = control.config.defaults['statesp.use_numpy_matrix'] + return (isinstance(obj, np.ndarray) + and isinstance(obj, np.matrix) == use_matrix) + + +def asmatarrayout(obj): + """Return a object according to the configured default""" + use_matrix = control.config.defaults['statesp.use_numpy_matrix'] + matarray = np.asmatrix if use_matrix else np.asarray + return matarray(obj) + + +@contextmanager +def check_deprecated_matrix(): + """Check that a call produces a deprecation warning because of np.matrix""" + use_matrix = control.config.defaults['statesp.use_numpy_matrix'] + if use_matrix: + with pytest.deprecated_call(): + try: + yield + finally: + pass + else: + yield + + +@pytest.fixture(scope="function", + params=[p for p, usebydefault in + [(pytest.param(np.array, + id="arrayin"), + True), + (pytest.param(np.matrix, + id="matrixin", + marks=matrixfilter), + False)] + if usebydefault or TEST_MATRIX_AND_ARRAY]) +def matarrayin(request): + """Use array and matrix to construct input data in tests""" + return request.param @pytest.fixture(scope="function") @@ -20,7 +105,7 @@ def editsdefaults(): """Make sure any changes to the defaults only last during a test""" restore = control.config.defaults.copy() yield - control.config.defaults.update(restore) + control.config.defaults = restore.copy() @pytest.fixture(scope="function") diff --git a/control/tests/convert_test.py b/control/tests/convert_test.py index e0b0e0364..d5d4cbfab 100644 --- a/control/tests/convert_test.py +++ b/control/tests/convert_test.py @@ -15,252 +15,234 @@ """ from __future__ import print_function -import unittest +from warnings import warn + import numpy as np -from control import matlab +import pytest + +from control import rss, ss, ss2tf, tf, tf2ss from control.statesp import _mimo2siso from control.statefbk import ctrb, obsv from control.freqplot import bode -from control.matlab import tf from control.exception import slycot_check +from control.tests.conftest import slycotonly -class TestConvert(unittest.TestCase): - """Test state space and transfer function conversions.""" - def setUp(self): - """Set up testing parameters.""" - - # Number of times to run each of the randomized tests. - self.numTests = 1 # almost guarantees failure - # Maximum number of states to test + 1 - self.maxStates = 4 - # Maximum number of inputs and outputs to test + 1 - # If slycot is not installed, just check SISO - self.maxIO = 5 if slycot_check() else 2 - # Set to True to print systems to the output. - self.debug = False - # get consistent results - np.random.seed(7) +# Set to True to print systems to the output. +verbose = False +# Maximum number of states to test + 1 +maxStates = 4 +# Maximum number of inputs and outputs to test + 1 +# If slycot is not installed, just check SISO +maxIO = 5 if slycot_check() else 2 + + +@pytest.fixture +def fixedseed(scope='module'): + """Get consistent results""" + np.random.seed(7) + + +class TestConvert: + """Test state space and transfer function conversions.""" def printSys(self, sys, ind): """Print system to the standard output.""" + print("sys%i:\n" % ind) + print(sys) - if self.debug: - print("sys%i:\n" % ind) - print(sys) - - def testConvert(self): - """Test state space to transfer function conversion.""" - verbose = self.debug - - # print __doc__ - - # Machine precision for floats. - # eps = np.finfo(float).eps - - for states in range(1, self.maxStates): - for inputs in range(1, self.maxIO): - for outputs in range(1, self.maxIO): - # start with a random SS system and transform to TF then - # back to SS, check that the matrices are the same. - ssOriginal = matlab.rss(states, outputs, inputs) - if (verbose): - self.printSys(ssOriginal, 1) - - # Make sure the system is not degenerate - Cmat = ctrb(ssOriginal.A, ssOriginal.B) - if (np.linalg.matrix_rank(Cmat) != states): - if (verbose): - print(" skipping (not reachable)") - continue - Omat = obsv(ssOriginal.A, ssOriginal.C) - if (np.linalg.matrix_rank(Omat) != states): - if (verbose): - print(" skipping (not observable)") - continue - - tfOriginal = matlab.tf(ssOriginal) - if (verbose): - self.printSys(tfOriginal, 2) - - ssTransformed = matlab.ss(tfOriginal) - if (verbose): - self.printSys(ssTransformed, 3) - - tfTransformed = matlab.tf(ssTransformed) - if (verbose): - self.printSys(tfTransformed, 4) - - # Check to see if the state space systems have same dim - if (ssOriginal.states != ssTransformed.states): - print("WARNING: state space dimension mismatch: " + \ - "%d versus %d" % \ - (ssOriginal.states, ssTransformed.states)) - - # Now make sure the frequency responses match - # Since bode() only handles SISO, go through each I/O pair - # For phase, take sine and cosine to avoid +/- 360 offset - for inputNum in range(inputs): - for outputNum in range(outputs): - if (verbose): - print("Checking input %d, output %d" \ - % (inputNum, outputNum)) - ssorig_mag, ssorig_phase, ssorig_omega = \ - bode(_mimo2siso(ssOriginal, \ - inputNum, outputNum), \ - deg=False, plot=False) - ssorig_real = ssorig_mag * np.cos(ssorig_phase) - ssorig_imag = ssorig_mag * np.sin(ssorig_phase) - - # - # Make sure TF has same frequency response - # - num = tfOriginal.num[outputNum][inputNum] - den = tfOriginal.den[outputNum][inputNum] - tforig = tf(num, den) - - tforig_mag, tforig_phase, tforig_omega = \ - bode(tforig, ssorig_omega, \ - deg=False, plot=False) - - tforig_real = tforig_mag * np.cos(tforig_phase) - tforig_imag = tforig_mag * np.sin(tforig_phase) - np.testing.assert_array_almost_equal( \ - ssorig_real, tforig_real) - np.testing.assert_array_almost_equal( \ - ssorig_imag, tforig_imag) - - # - # Make sure xform'd SS has same frequency response - # - ssxfrm_mag, ssxfrm_phase, ssxfrm_omega = \ - bode(_mimo2siso(ssTransformed, \ - inputNum, outputNum), \ - ssorig_omega, \ - deg=False, plot=False) - ssxfrm_real = ssxfrm_mag * np.cos(ssxfrm_phase) - ssxfrm_imag = ssxfrm_mag * np.sin(ssxfrm_phase) - np.testing.assert_array_almost_equal( \ - ssorig_real, ssxfrm_real) - np.testing.assert_array_almost_equal( \ - ssorig_imag, ssxfrm_imag) - # - # Make sure xform'd TF has same frequency response - # - num = tfTransformed.num[outputNum][inputNum] - den = tfTransformed.den[outputNum][inputNum] - tfxfrm = tf(num, den) - tfxfrm_mag, tfxfrm_phase, tfxfrm_omega = \ - bode(tfxfrm, ssorig_omega, \ - deg=False, plot=False) - - tfxfrm_real = tfxfrm_mag * np.cos(tfxfrm_phase) - tfxfrm_imag = tfxfrm_mag * np.sin(tfxfrm_phase) - np.testing.assert_array_almost_equal( \ - ssorig_real, tfxfrm_real) - np.testing.assert_array_almost_equal( \ - ssorig_imag, tfxfrm_imag) + @pytest.mark.parametrize("states", range(1, maxStates)) + @pytest.mark.parametrize("inputs", range(1, maxIO)) + @pytest.mark.parametrize("outputs", range(1, maxIO)) + def testConvert(self, fixedseed, states, inputs, outputs): + """Test state space to transfer function conversion. + + start with a random SS system and transform to TF then + back to SS, check that the matrices are the same. + """ + ssOriginal = rss(states, outputs, inputs) + if verbose: + self.printSys(ssOriginal, 1) + + # Make sure the system is not degenerate + Cmat = ctrb(ssOriginal.A, ssOriginal.B) + if (np.linalg.matrix_rank(Cmat) != states): + pytest.skip("not reachable") + Omat = obsv(ssOriginal.A, ssOriginal.C) + if (np.linalg.matrix_rank(Omat) != states): + pytest.skip("not observable") + + tfOriginal = tf(ssOriginal) + if (verbose): + self.printSys(tfOriginal, 2) + + ssTransformed = ss(tfOriginal) + if (verbose): + self.printSys(ssTransformed, 3) + + tfTransformed = tf(ssTransformed) + if (verbose): + self.printSys(tfTransformed, 4) + + # Check to see if the state space systems have same dim + if (ssOriginal.nstates != ssTransformed.nstates) and verbose: + print("WARNING: state space dimension mismatch: %d versus %d" % + (ssOriginal.nstates, ssTransformed.nstates)) + + # Now make sure the frequency responses match + # Since bode() only handles SISO, go through each I/O pair + # For phase, take sine and cosine to avoid +/- 360 offset + for inputNum in range(inputs): + for outputNum in range(outputs): + if (verbose): + print("Checking input %d, output %d" + % (inputNum, outputNum)) + ssorig_mag, ssorig_phase, ssorig_omega = \ + bode(_mimo2siso(ssOriginal, inputNum, outputNum), + deg=False, plot=False) + ssorig_real = ssorig_mag * np.cos(ssorig_phase) + ssorig_imag = ssorig_mag * np.sin(ssorig_phase) + + # + # Make sure TF has same frequency response + # + num = tfOriginal.num[outputNum][inputNum] + den = tfOriginal.den[outputNum][inputNum] + tforig = tf(num, den) + + tforig_mag, tforig_phase, tforig_omega = \ + bode(tforig, ssorig_omega, + deg=False, plot=False) + + tforig_real = tforig_mag * np.cos(tforig_phase) + tforig_imag = tforig_mag * np.sin(tforig_phase) + np.testing.assert_array_almost_equal( + ssorig_real, tforig_real) + np.testing.assert_array_almost_equal( + ssorig_imag, tforig_imag) + + # + # Make sure xform'd SS has same frequency response + # + ssxfrm_mag, ssxfrm_phase, ssxfrm_omega = \ + bode(_mimo2siso(ssTransformed, + inputNum, outputNum), + ssorig_omega, + deg=False, plot=False) + ssxfrm_real = ssxfrm_mag * np.cos(ssxfrm_phase) + ssxfrm_imag = ssxfrm_mag * np.sin(ssxfrm_phase) + np.testing.assert_array_almost_equal( + ssorig_real, ssxfrm_real, decimal=5) + np.testing.assert_array_almost_equal( + ssorig_imag, ssxfrm_imag, decimal=5) + + # Make sure xform'd TF has same frequency response + # + num = tfTransformed.num[outputNum][inputNum] + den = tfTransformed.den[outputNum][inputNum] + tfxfrm = tf(num, den) + tfxfrm_mag, tfxfrm_phase, tfxfrm_omega = \ + bode(tfxfrm, ssorig_omega, + deg=False, plot=False) + + tfxfrm_real = tfxfrm_mag * np.cos(tfxfrm_phase) + tfxfrm_imag = tfxfrm_mag * np.sin(tfxfrm_phase) + np.testing.assert_array_almost_equal( + ssorig_real, tfxfrm_real, decimal=5) + np.testing.assert_array_almost_equal( + ssorig_imag, tfxfrm_imag, decimal=5) def testConvertMIMO(self): - """Test state space to transfer function conversion.""" - verbose = self.debug - - # Do a MIMO conversation and make sure that it is processed - # correctly both with and without slycot - # - # Example from issue #120, jgoppert - import control - - # Set up a transfer function (should always work) - tfcn = control.tf([[[-235, 1.146e4], - [-235, 1.146E4], - [-235, 1.146E4, 0]]], - [[[1, 48.78, 0], - [1, 48.78, 0, 0], - [0.008, 1.39, 48.78]]]) + """Test state space to transfer function conversion. + + Do a MIMO conversion and make sure that it is processed + correctly both with and without slycot + + Example from issue gh-120, jgoppert + """ + + # Set up a 1x3 transfer function (should always work) + tsys = tf([[[-235, 1.146e4], + [-235, 1.146E4], + [-235, 1.146E4, 0]]], + [[[1, 48.78, 0], + [1, 48.78, 0, 0], + [0.008, 1.39, 48.78]]]) # Convert to state space and look for an error if (not slycot_check()): - self.assertRaises(TypeError, control.tf2ss, tfcn) + with pytest.raises(TypeError): + tf2ss(tsys) + else: + ssys = tf2ss(tsys) + assert ssys.B.shape[1] == 3 + assert ssys.C.shape[0] == 1 def testTf2ssStaticSiso(self): """Regression: tf2ss for SISO static gain""" - import control - gsiso = control.tf2ss(control.tf(23, 46)) - self.assertEqual(0, gsiso.states) - self.assertEqual(1, gsiso.inputs) - self.assertEqual(1, gsiso.outputs) - # in all cases ratios are exactly representable, so assert_array_equal is fine + gsiso = tf2ss(tf(23, 46)) + assert 0 == gsiso.nstates + assert 1 == gsiso.ninputs + assert 1 == gsiso.noutputs + # in all cases ratios are exactly representable, so assert_array_equal + # is fine np.testing.assert_array_equal([[0.5]], gsiso.D) def testTf2ssStaticMimo(self): """Regression: tf2ss for MIMO static gain""" - import control # 2x3 TFM - gmimo = control.tf2ss(control.tf( + gmimo = tf2ss(tf( [[ [23], [3], [5] ], [ [-1], [0.125], [101.3] ]], [[ [46], [0.1], [80] ], [ [2], [-0.1], [1] ]])) - self.assertEqual(0, gmimo.states) - self.assertEqual(3, gmimo.inputs) - self.assertEqual(2, gmimo.outputs) - d = np.matrix([[0.5, 30, 0.0625], [-0.5, -1.25, 101.3]]) + assert 0 == gmimo.nstates + assert 3 == gmimo.ninputs + assert 2 == gmimo.noutputs + d = np.array([[0.5, 30, 0.0625], [-0.5, -1.25, 101.3]]) np.testing.assert_array_equal(d, gmimo.D) def testSs2tfStaticSiso(self): """Regression: ss2tf for SISO static gain""" - import control - gsiso = control.ss2tf(control.ss([], [], [], 0.5)) + gsiso = ss2tf(ss([], [], [], 0.5)) np.testing.assert_array_equal([[[0.5]]], gsiso.num) np.testing.assert_array_equal([[[1.]]], gsiso.den) def testSs2tfStaticMimo(self): """Regression: ss2tf for MIMO static gain""" - import control # 2x3 TFM a = [] b = [] c = [] - d = np.matrix([[0.5, 30, 0.0625], [-0.5, -1.25, 101.3]]) - gtf = control.ss2tf(control.ss(a,b,c,d)) + d = np.array([[0.5, 30, 0.0625], [-0.5, -1.25, 101.3]]) + gtf = ss2tf(ss(a, b, c, d)) # we need a 3x2x1 array to compare with gtf.num - # np.testing.assert_array_equal doesn't seem to like a matrices - # with an extra dimension, so convert to ndarray - numref = np.asarray(d)[...,np.newaxis] - np.testing.assert_array_equal(numref, np.array(gtf.num) / np.array(gtf.den)) + numref = d[..., np.newaxis] + np.testing.assert_array_equal(numref, + np.array(gtf.num) / np.array(gtf.den)) + @slycotonly def testTf2SsDuplicatePoles(self): - """Tests for "too few poles for MIMO tf #111" """ - import control - try: - import slycot - num = [ [ [1], [0] ], - [ [0], [1] ] ] - - den = [ [ [1,0], [1] ], - [ [1], [1,0] ] ] - g = control.tf(num, den) - s = control.ss(g) - np.testing.assert_array_equal(g.pole(), s.pole()) - except ImportError: - print("Slycot not present, skipping") - - @unittest.skipIf(not slycot_check(), "slycot not installed") + """Tests for 'too few poles for MIMO tf gh-111'""" + num = [[[1], [0]], + [[0], [1]]] + den = [[[1, 0], [1]], + [[1], [1, 0]]] + g = tf(num, den) + s = ss(g) + np.testing.assert_array_equal(g.pole(), s.pole()) + + @slycotonly def test_tf2ss_robustness(self): - """Unit test to make sure that tf2ss is working correctly. - Source: https://github.com/python-control/python-control/issues/240 - """ - import control - + """Unit test to make sure that tf2ss is working correctly. gh-240""" num = [ [[0], [1]], [[1], [0]] ] den1 = [ [[1], [1,1]], [[1,4], [1]] ] - sys1tf = control.tf(num, den1) - sys1ss = control.tf2ss(sys1tf) + sys1tf = tf(num, den1) + sys1ss = tf2ss(sys1tf) # slight perturbation den2 = [ [[1], [1e-10, 1, 1]], [[1,4], [1]] ] - sys2tf = control.tf(num, den2) - sys2ss = control.tf2ss(sys2tf) + sys2tf = tf(num, den2) + sys2ss = tf2ss(sys2tf) # Make sure that the poles match for StateSpace and TransferFunction np.testing.assert_array_almost_equal(np.sort(sys1tf.pole()), @@ -268,6 +250,16 @@ def test_tf2ss_robustness(self): np.testing.assert_array_almost_equal(np.sort(sys2tf.pole()), np.sort(sys2ss.pole())) - -if __name__ == "__main__": - unittest.main() + def test_tf2ss_nonproper(self): + """Unit tests for non-proper transfer functions""" + # Easy case: input 2 to output 1 is 's' + num = [ [[0], [1, 0]], [[1], [0]] ] + den1 = [ [[1], [1]], [[1,4], [1]] ] + with pytest.raises(ValueError): + tf2ss(tf(num, den1)) + + # Trickier case (make sure that leading zeros in den are handled) + num = [ [[0], [1, 0]], [[1], [0]] ] + den1 = [ [[1], [0, 1]], [[1,4], [1]] ] + with pytest.raises(ValueError): + tf2ss(tf(num, den1)) diff --git a/control/tests/ctrlutil_test.py b/control/tests/ctrlutil_test.py index 03a347154..460ff601c 100644 --- a/control/tests/ctrlutil_test.py +++ b/control/tests/ctrlutil_test.py @@ -1,11 +1,13 @@ -import unittest +"""ctrlutil_test.py""" + import numpy as np -from control.ctrlutil import * -class TestUtils(unittest.TestCase): - def setUp(self): - self.mag = np.array([1, 10, 100, 2, 0.1, 0.01]) - self.db = np.array([0, 20, 40, 6.0205999, -20, -40]) +from control.ctrlutil import db2mag, mag2db, unwrap + +class TestUtils: + + mag = np.array([1, 10, 100, 2, 0.1, 0.01]) + db = np.array([0, 20, 40, 6.0205999, -20, -40]) def check_unwrap_array(self, angle, period=None): if period is None: @@ -56,7 +58,3 @@ def test_mag2db(self): def test_mag2db_array(self): db_array = mag2db(self.mag) np.testing.assert_array_almost_equal(db_array, self.db) - - -if __name__ == "__main__": - unittest.main() diff --git a/control/tests/delay_test.py b/control/tests/delay_test.py index 17c049d24..533eb4a72 100644 --- a/control/tests/delay_test.py +++ b/control/tests/delay_test.py @@ -1,25 +1,25 @@ -#!/usr/bin/env python -*-coding: utf-8-*- -# -# Test Pade approx -# -# Primitive; ideally test to numerical limits +# -*- coding: utf-8 -*- +"""Test Pade approx -from __future__ import division +Primitive; ideally test to numerical limits +""" -import unittest +from __future__ import division import numpy as np +import pytest from control.delay import pade -class TestPade(unittest.TestCase): - - # Reference data from Miklos Vajta's paper "Some remarks on - # Padé-approximations", Table 1, with corrections. The - # corrections are to highest power coeff in numerator for - # (ddeg,ndeg)=(4,3) and (5,4); use Eq (12) in the paper to verify +class TestPade: + """Test Pade approx + Reference data from Miklos Vajta's paper "Some remarks on + Padé-approximations", Table 1, with corrections. The + corrections are to highest power coeff in numerator for + (ddeg,ndeg)=(4,3) and (5,4); use Eq (12) in the paper to verify + """ # all for T = 1 ref = [ # dendeg numdeg den num @@ -33,35 +33,40 @@ class TestPade(unittest.TestCase): ( 4, 3, [1,16,120,480,840], [-4,60,-360,840]), ( 5, 5, [1,30,420,3360,15120,30240], [-1,30,-420,3360,-15120,30240]), ( 5, 4, [1,25,300,2100,8400,15120,], [5,-120,1260,-6720,15120]), - ] + ] - def testRefs(self): + @pytest.mark.parametrize("dendeg, numdeg, refden, refnum", ref) + def testRefs(self, dendeg, numdeg, refden, refnum): "test reference cases for T=1" T = 1 - for dendeg, numdeg, refden, refnum in self.ref: - num, den = pade(T, dendeg, numdeg) - np.testing.assert_array_almost_equal_nulp(np.array(refden), den, nulp=2) - np.testing.assert_array_almost_equal_nulp(np.array(refnum), num, nulp=2) + num, den = pade(T, dendeg, numdeg) + np.testing.assert_array_almost_equal_nulp( + np.array(refden), den, nulp=2) + np.testing.assert_array_almost_equal_nulp( + np.array(refnum), num, nulp=2) - def testTvalues(self): + @pytest.mark.parametrize("dendeg, numdeg, baseden, basenum", ref) + @pytest.mark.parametrize("T", [1/53, 21.95]) + def testTvalues(self, T, dendeg, numdeg, baseden, basenum): "test reference cases for T!=1" - Ts = [1/53, 21.95] - for dendeg, numdeg, baseden, basenum in self.ref: - for T in Ts: - refden = T**np.arange(dendeg, -1, -1)*baseden - refnum = T**np.arange(numdeg, -1, -1)*basenum - refnum /= refden[0] - refden /= refden[0] - num, den = pade(T, dendeg, numdeg) - np.testing.assert_array_almost_equal_nulp(refden, den, nulp=2) - np.testing.assert_array_almost_equal_nulp(refnum, num, nulp=2) + refden = T**np.arange(dendeg, -1, -1)*baseden + refnum = T**np.arange(numdeg, -1, -1)*basenum + refnum /= refden[0] + refden /= refden[0] + num, den = pade(T, dendeg, numdeg) + np.testing.assert_array_almost_equal_nulp(refden, den, nulp=2) + np.testing.assert_array_almost_equal_nulp(refnum, num, nulp=2) def testErrors(self): "ValueError raised for invalid arguments" - self.assertRaises(ValueError,pade,-1,1) # T<0 - self.assertRaises(ValueError,pade,1,-1) # dendeg < 0 - self.assertRaises(ValueError,pade,1,2,-3) # numdeg < 0 - self.assertRaises(ValueError,pade,1,2,3) # numdeg > dendeg + with pytest.raises(ValueError): + pade(-1, 1) # T<0 + with pytest.raises(ValueError): + pade(1, -1) # dendeg < 0 + with pytest.raises(ValueError): + pade(1, 2, -3) # numdeg < 0 + with pytest.raises(ValueError): + pade(1, 2, 3) # numdeg > dendeg def testNumdeg(self): "numdeg argument follows docs" @@ -72,10 +77,10 @@ def testNumdeg(self): for numdeg in range(0,dendeg+1)] testneg = [pade(T,dendeg,numdeg) for numdeg in range(-dendeg,0)] - self.assertEqual(ref[:-1],testneg) - self.assertEqual(ref[-1], pade(T,dendeg,dendeg)) - self.assertEqual(ref[-1], pade(T,dendeg,None)) - self.assertEqual(ref[-1], pade(T,dendeg)) + assert ref[:-1] == testneg + assert ref[-1] == pade(T,dendeg,dendeg) + assert ref[-1] == pade(T,dendeg,None) + assert ref[-1] == pade(T,dendeg) def testT0(self): "T=0 always returns [1],[1]" @@ -85,8 +90,8 @@ def testT0(self): for dendeg in range(1, 6): for numdeg in range(0, dendeg+1): num, den = pade(T, dendeg, numdeg) - np.testing.assert_array_almost_equal_nulp(np.array(refnum), np.array(num)) - np.testing.assert_array_almost_equal_nulp(np.array(refden), np.array(den)) + np.testing.assert_array_almost_equal_nulp( + np.array(refnum), np.array(num)) + np.testing.assert_array_almost_equal_nulp( + np.array(refden), np.array(den)) -if __name__ == '__main__': - unittest.main() diff --git a/control/tests/descfcn_test.py b/control/tests/descfcn_test.py new file mode 100644 index 000000000..d26e2c67a --- /dev/null +++ b/control/tests/descfcn_test.py @@ -0,0 +1,196 @@ +"""descfcn_test.py - test describing functions and related capabilities + +RMM, 23 Jan 2021 + +This set of unit tests covers the various operatons of the descfcn module, as +well as some of the support functions associated with static nonlinearities. + +""" + +import pytest + +import numpy as np +import control as ct +import math +from control.descfcn import saturation_nonlinearity, \ + friction_backlash_nonlinearity, relay_hysteresis_nonlinearity + + +# Static function via a class +class saturation_class: + # Static nonlinear saturation function + def __call__(self, x, lb=-1, ub=1): + return np.clip(x, lb, ub) + + # Describing function for a saturation function + def describing_function(self, a): + if -1 <= a and a <= 1: + return 1. + else: + b = 1/a + return 2/math.pi * (math.asin(b) + b * math.sqrt(1 - b**2)) + + +# Static function without a class +def saturation(x): + return np.clip(x, -1, 1) + + +# Static nonlinear system implementing saturation +@pytest.fixture +def satsys(): + satfcn = saturation_class() + def _satfcn(t, x, u, params): + return satfcn(u) + return ct.NonlinearIOSystem(None, outfcn=_satfcn, input=1, output=1) + + +def test_static_nonlinear_call(satsys): + # Make sure that the saturation system is a static nonlinearity + assert satsys._isstatic() + + # Make sure the saturation function is doing the right computation + input = [-2, -1, -0.5, 0, 0.5, 1, 2] + desired = [-1, -1, -0.5, 0, 0.5, 1, 1] + for x, y in zip(input, desired): + assert satsys(x) == y + + # Test squeeze properties + assert satsys(0.) == 0. + assert satsys([0.], squeeze=True) == 0. + np.testing.assert_array_equal(satsys([0.]), [0.]) + + # Test SIMO nonlinearity + def _simofcn(t, x, u, params): + return np.array([np.cos(u), np.sin(u)]) + simo_sys = ct.NonlinearIOSystem(None, outfcn=_simofcn, input=1, output=2) + np.testing.assert_array_equal(simo_sys([0.]), [1, 0]) + np.testing.assert_array_equal(simo_sys([0.], squeeze=True), [1, 0]) + + # Test MISO nonlinearity + def _misofcn(t, x, u, params={}): + return np.array([np.sin(u[0]) * np.cos(u[1])]) + miso_sys = ct.NonlinearIOSystem(None, outfcn=_misofcn, input=2, output=1) + np.testing.assert_array_equal(miso_sys([0, 0]), [0]) + np.testing.assert_array_equal(miso_sys([0, 0], squeeze=True), [0]) + + +# Test saturation describing function in multiple ways +def test_saturation_describing_function(satsys): + satfcn = saturation_class() + + # Store the analytic describing function for comparison + amprange = np.linspace(0, 10, 100) + df_anal = [satfcn.describing_function(a) for a in amprange] + + # Compute describing function for a static function + df_fcn = ct.describing_function(saturation, amprange) + np.testing.assert_almost_equal(df_fcn, df_anal, decimal=3) + + # Compute describing function for a describing function nonlinearity + df_fcn = ct.describing_function(satfcn, amprange) + np.testing.assert_almost_equal(df_fcn, df_anal, decimal=3) + + # Compute describing function for a static I/O system + df_sys = ct.describing_function(satsys, amprange) + np.testing.assert_almost_equal(df_sys, df_anal, decimal=3) + + # Compute describing function on an array of values + df_arr = ct.describing_function(satsys, amprange) + np.testing.assert_almost_equal(df_arr, df_anal, decimal=3) + + # Evaluate static function at a negative amplitude + with pytest.raises(ValueError, match="cannot evaluate"): + ct.describing_function(saturation, -1) + + # Create describing function nonlinearity w/out describing_function method + # and make sure it drops through to the underlying computation + class my_saturation(ct.DescribingFunctionNonlinearity): + def __call__(self, x): + return saturation(x) + satfcn_nometh = my_saturation() + df_nometh = ct.describing_function(satfcn_nometh, amprange) + np.testing.assert_almost_equal(df_nometh, df_anal, decimal=3) + + +@pytest.mark.parametrize("fcn, amin, amax", [ + [saturation_nonlinearity(1), 0, 10], + [friction_backlash_nonlinearity(2), 1, 10], + [relay_hysteresis_nonlinearity(1, 1), 3, 10], + ]) +def test_describing_function(fcn, amin, amax): + # Store the analytic describing function for comparison + amprange = np.linspace(amin, amax, 100) + df_anal = [fcn.describing_function(a) for a in amprange] + + # Compute describing function on an array of values + df_arr = ct.describing_function( + fcn, amprange, zero_check=False, try_method=False) + np.testing.assert_almost_equal(df_arr, df_anal, decimal=1) + + # Make sure the describing function method also works + df_meth = ct.describing_function(fcn, amprange, zero_check=False) + np.testing.assert_almost_equal(df_meth, df_anal) + + # Make sure that evaluation at negative amplitude generates an exception + with pytest.raises(ValueError, match="cannot evaluate"): + ct.describing_function(fcn, -1) + + +def test_describing_function_plot(): + # Simple linear system with at most 1 intersection + H_simple = ct.tf([1], [1, 2, 2, 1]) + omega = np.logspace(-1, 2, 100) + + # Saturation nonlinearity + F_saturation = ct.descfcn.saturation_nonlinearity(1) + amp = np.linspace(1, 4, 10) + + # No intersection + xsects = ct.describing_function_plot(H_simple, F_saturation, amp, omega) + assert xsects == [] + + # One intersection + H_larger = H_simple * 8 + xsects = ct.describing_function_plot(H_larger, F_saturation, amp, omega) + for a, w in xsects: + np.testing.assert_almost_equal( + H_larger(1j*w), + -1/ct.describing_function(F_saturation, a), decimal=5) + + # Multiple intersections + H_multiple = H_simple * ct.tf(*ct.pade(5, 4)) * 4 + omega = np.logspace(-1, 3, 50) + F_backlash = ct.descfcn.friction_backlash_nonlinearity(1) + amp = np.linspace(0.6, 5, 50) + xsects = ct.describing_function_plot(H_multiple, F_backlash, amp, omega) + for a, w in xsects: + np.testing.assert_almost_equal( + -1/ct.describing_function(F_backlash, a), + H_multiple(1j*w), decimal=5) + +def test_describing_function_exceptions(): + # Describing function with non-zero bias + with pytest.warns(UserWarning, match="asymmetric"): + saturation = ct.descfcn.saturation_nonlinearity(lb=-1, ub=2) + assert saturation(-3) == -1 + assert saturation(3) == 2 + + # Turn off the bias check + bias = ct.describing_function(saturation, 0, zero_check=False) + + # Function should evaluate to zero at zero amplitude + f = lambda x: x + 0.5 + with pytest.raises(ValueError, match="must evaluate to zero"): + bias = ct.describing_function(f, 0, zero_check=True) + + # Evaluate at a negative amplitude + with pytest.raises(ValueError, match="cannot evaluate"): + ct.describing_function(saturation, -1) + + # Describing function with bad label + H_simple = ct.tf([8], [1, 2, 2, 1]) + F_saturation = ct.descfcn.saturation_nonlinearity(1) + amp = np.linspace(1, 4, 10) + with pytest.raises(ValueError, match="formatting string"): + ct.describing_function_plot(H_simple, F_saturation, amp, label=1) diff --git a/control/tests/discrete_test.py b/control/tests/discrete_test.py index 9c1928dab..379098ff2 100644 --- a/control/tests/discrete_test.py +++ b/control/tests/discrete_test.py @@ -1,351 +1,404 @@ -#!/usr/bin/env python -# -# discrete_test.py - test discrete time classes -# RMM, 9 Sep 2012 +"""discrete_test.py - test discrete time classes + +RMM, 9 Sep 2012 +""" -import unittest import numpy as np -from control import StateSpace, TransferFunction, feedback, step_response, \ - isdtime, timebase, isctime, sample_system, bode, impulse_response, \ - timebaseEqual, forced_response -from control import matlab +import pytest + +from control import (StateSpace, TransferFunction, bode, common_timebase, + evalfr, feedback, forced_response, impulse_response, + isctime, isdtime, rss, sample_system, step_response, + timebase) -class TestDiscrete(unittest.TestCase): - """Tests for the DiscreteStateSpace class.""" - def setUp(self): - """Set up a SISO and MIMO system to test operations on.""" +class TestDiscrete: + """Tests for the system classes with discrete timebase.""" + @pytest.fixture + def tsys(self): + """Create some systems for testing""" + class Tsys: + pass + T = Tsys() # Single input, single output continuous and discrete time systems - sys = matlab.rss(3, 1, 1) - self.siso_ss1 = StateSpace(sys.A, sys.B, sys.C, sys.D) - self.siso_ss1c = StateSpace(sys.A, sys.B, sys.C, sys.D, 0.0) - self.siso_ss1d = StateSpace(sys.A, sys.B, sys.C, sys.D, 0.1) - self.siso_ss2d = StateSpace(sys.A, sys.B, sys.C, sys.D, 0.2) - self.siso_ss3d = StateSpace(sys.A, sys.B, sys.C, sys.D, True) + sys = rss(3, 1, 1) + T.siso_ss1 = StateSpace(sys.A, sys.B, sys.C, sys.D, None) + T.siso_ss1c = StateSpace(sys.A, sys.B, sys.C, sys.D, 0.0) + T.siso_ss1d = StateSpace(sys.A, sys.B, sys.C, sys.D, 0.1) + T.siso_ss2d = StateSpace(sys.A, sys.B, sys.C, sys.D, 0.2) + T.siso_ss3d = StateSpace(sys.A, sys.B, sys.C, sys.D, True) # Two input, two output continuous time system A = [[-3., 4., 2.], [-1., -3., 0.], [2., 5., 3.]] B = [[1., 4.], [-3., -3.], [-2., 1.]] C = [[4., 2., -3.], [1., 4., 3.]] D = [[-2., 4.], [0., 1.]] - self.mimo_ss1 = StateSpace(A, B, C, D) - self.mimo_ss1c = StateSpace(A, B, C, D, 0) + T.mimo_ss1 = StateSpace(A, B, C, D, None) + T.mimo_ss1c = StateSpace(A, B, C, D, 0) # Two input, two output discrete time system - self.mimo_ss1d = StateSpace(A, B, C, D, 0.1) + T.mimo_ss1d = StateSpace(A, B, C, D, 0.1) # Same system, but with a different sampling time - self.mimo_ss2d = StateSpace(A, B, C, D, 0.2) + T.mimo_ss2d = StateSpace(A, B, C, D, 0.2) # Single input, single output continuus and discrete transfer function - self.siso_tf1 = TransferFunction([1, 1], [1, 2, 1]) - self.siso_tf1c = TransferFunction([1, 1], [1, 2, 1], 0) - self.siso_tf1d = TransferFunction([1, 1], [1, 2, 1], 0.1) - self.siso_tf2d = TransferFunction([1, 1], [1, 2, 1], 0.2) - self.siso_tf3d = TransferFunction([1, 1], [1, 2, 1], True) - - def testTimebaseEqual(self): - self.assertEqual(timebaseEqual(self.siso_ss1, self.siso_tf1), True) - self.assertEqual(timebaseEqual(self.siso_ss1, self.siso_ss1c), True) - self.assertEqual(timebaseEqual(self.siso_ss1, self.siso_ss1d), True) - self.assertEqual(timebaseEqual(self.siso_ss1d, self.siso_ss1c), False) - self.assertEqual(timebaseEqual(self.siso_ss1d, self.siso_ss2d), False) - self.assertEqual(timebaseEqual(self.siso_ss1d, self.siso_ss3d), False) - - def testSystemInitialization(self): + T.siso_tf1 = TransferFunction([1, 1], [1, 2, 1], None) + T.siso_tf1c = TransferFunction([1, 1], [1, 2, 1], 0) + T.siso_tf1d = TransferFunction([1, 1], [1, 2, 1], 0.1) + T.siso_tf2d = TransferFunction([1, 1], [1, 2, 1], 0.2) + T.siso_tf3d = TransferFunction([1, 1], [1, 2, 1], True) + + return T + + def testCompatibleTimebases(self, tsys): + """test that compatible timebases don't throw errors and vice versa""" + common_timebase(tsys.siso_ss1.dt, tsys.siso_tf1.dt) + common_timebase(tsys.siso_ss1.dt, tsys.siso_ss1c.dt) + common_timebase(tsys.siso_ss1d.dt, tsys.siso_ss1.dt) + common_timebase(tsys.siso_ss1.dt, tsys.siso_ss1d.dt) + common_timebase(tsys.siso_ss1.dt, tsys.siso_ss1d.dt) + common_timebase(tsys.siso_ss1d.dt, tsys.siso_ss3d.dt) + common_timebase(tsys.siso_ss3d.dt, tsys.siso_ss1d.dt) + with pytest.raises(ValueError): + # cont + discrete + common_timebase(tsys.siso_ss1d.dt, tsys.siso_ss1c.dt) + with pytest.raises(ValueError): + # incompatible discrete + common_timebase(tsys.siso_ss1d.dt, tsys.siso_ss2d.dt) + + def testSystemInitialization(self, tsys): # Check to make sure systems are discrete time with proper variables - self.assertEqual(self.siso_ss1.dt, None) - self.assertEqual(self.siso_ss1c.dt, 0) - self.assertEqual(self.siso_ss1d.dt, 0.1) - self.assertEqual(self.siso_ss2d.dt, 0.2) - self.assertEqual(self.siso_ss3d.dt, True) - self.assertEqual(self.mimo_ss1c.dt, 0) - self.assertEqual(self.mimo_ss1d.dt, 0.1) - self.assertEqual(self.mimo_ss2d.dt, 0.2) - self.assertEqual(self.siso_tf1.dt, None) - self.assertEqual(self.siso_tf1c.dt, 0) - self.assertEqual(self.siso_tf1d.dt, 0.1) - self.assertEqual(self.siso_tf2d.dt, 0.2) - self.assertEqual(self.siso_tf3d.dt, True) - - def testCopyConstructor(self): - for sys in (self.siso_ss1, self.siso_ss1c, self.siso_ss1d): - newsys = StateSpace(sys); - self.assertEqual(sys.dt, newsys.dt) - for sys in (self.siso_tf1, self.siso_tf1c, self.siso_tf1d): - newsys = TransferFunction(sys); - self.assertEqual(sys.dt, newsys.dt) - - def test_timebase(self): - self.assertEqual(timebase(1), None); - self.assertRaises(ValueError, timebase, [1, 2]) - self.assertEqual(timebase(self.siso_ss1, strict=False), None); - self.assertEqual(timebase(self.siso_ss1, strict=True), None); - self.assertEqual(timebase(self.siso_ss1c), 0); - self.assertEqual(timebase(self.siso_ss1d), 0.1); - self.assertEqual(timebase(self.siso_ss2d), 0.2); - self.assertEqual(timebase(self.siso_ss3d), True); - self.assertEqual(timebase(self.siso_ss3d, strict=False), 1); - self.assertEqual(timebase(self.siso_tf1, strict=False), None); - self.assertEqual(timebase(self.siso_tf1, strict=True), None); - self.assertEqual(timebase(self.siso_tf1c), 0); - self.assertEqual(timebase(self.siso_tf1d), 0.1); - self.assertEqual(timebase(self.siso_tf2d), 0.2); - self.assertEqual(timebase(self.siso_tf3d), True); - self.assertEqual(timebase(self.siso_tf3d, strict=False), 1); - - def test_timebase_conversions(self): + assert tsys.siso_ss1.dt is None + assert tsys.siso_ss1c.dt == 0 + assert tsys.siso_ss1d.dt == 0.1 + assert tsys.siso_ss2d.dt == 0.2 + assert tsys.siso_ss3d.dt is True + assert tsys.mimo_ss1c.dt == 0 + assert tsys.mimo_ss1d.dt == 0.1 + assert tsys.mimo_ss2d.dt == 0.2 + assert tsys.siso_tf1.dt is None + assert tsys.siso_tf1c.dt == 0 + assert tsys.siso_tf1d.dt == 0.1 + assert tsys.siso_tf2d.dt == 0.2 + assert tsys.siso_tf3d.dt is True + + # keyword argument check + # dynamic systems + assert TransferFunction(1, [1, 1], dt=0.1).dt == 0.1 + assert TransferFunction(1, [1, 1], 0.1).dt == 0.1 + assert StateSpace(1,1,1,1, dt=0.1).dt == 0.1 + assert StateSpace(1,1,1,1, 0.1).dt == 0.1 + # static gain system, dt argument should still override default dt + assert TransferFunction(1, [1,], dt=0.1).dt == 0.1 + assert TransferFunction(1, [1,], 0.1).dt == 0.1 + assert StateSpace(0,0,1,1, dt=0.1).dt == 0.1 + assert StateSpace(0,0,1,1, 0.1).dt == 0.1 + + def testCopyConstructor(self, tsys): + for sys in (tsys.siso_ss1, tsys.siso_ss1c, tsys.siso_ss1d): + newsys = StateSpace(sys) + assert sys.dt == newsys.dt + for sys in (tsys.siso_tf1, tsys.siso_tf1c, tsys.siso_tf1d): + newsys = TransferFunction(sys) + assert sys.dt == newsys.dt + + def test_timebase(self, tsys): + assert timebase(1) is None + with pytest.raises(ValueError): + timebase([1, 2]) + assert timebase(tsys.siso_ss1, strict=False) is None + assert timebase(tsys.siso_ss1, strict=True) is None + assert timebase(tsys.siso_ss1c) == 0 + assert timebase(tsys.siso_ss1d) == 0.1 + assert timebase(tsys.siso_ss2d) == 0.2 + assert timebase(tsys.siso_ss3d) + assert timebase(tsys.siso_ss3d, strict=False) == 1 + assert timebase(tsys.siso_tf1, strict=False) is None + assert timebase(tsys.siso_tf1, strict=True) is None + assert timebase(tsys.siso_tf1c) == 0 + assert timebase(tsys.siso_tf1d) == 0.1 + assert timebase(tsys.siso_tf2d) == 0.2 + assert timebase(tsys.siso_tf3d) + assert timebase(tsys.siso_tf3d, strict=False) == 1 + + def test_timebase_conversions(self, tsys): '''Check to make sure timebases transfer properly''' - tf1 = TransferFunction([1,1],[1,2,3]) # unspecified - tf2 = TransferFunction([1,1],[1,2,3], 0) # cont time - tf3 = TransferFunction([1,1],[1,2,3], True) # dtime, unspec - tf4 = TransferFunction([1,1],[1,2,3], 1) # dtime, dt=1 + tf1 = TransferFunction([1, 1], [1, 2, 3], None) # unspecified + tf2 = TransferFunction([1, 1], [1, 2, 3], 0) # cont time + tf3 = TransferFunction([1, 1], [1, 2, 3], True) # dtime, unspec + tf4 = TransferFunction([1, 1], [1, 2, 3], .1) # dtime, dt=.1 # Make sure unspecified timebase is converted correctly - self.assertEqual(timebase(tf1*tf1), timebase(tf1)) - self.assertEqual(timebase(tf1*tf2), timebase(tf2)) - self.assertEqual(timebase(tf1*tf3), timebase(tf3)) - self.assertEqual(timebase(tf1*tf4), timebase(tf4)) - self.assertEqual(timebase(tf2*tf1), timebase(tf2)) - self.assertEqual(timebase(tf3*tf1), timebase(tf3)) - self.assertEqual(timebase(tf4*tf1), timebase(tf4)) - self.assertEqual(timebase(tf1+tf1), timebase(tf1)) - self.assertEqual(timebase(tf1+tf2), timebase(tf2)) - self.assertEqual(timebase(tf1+tf3), timebase(tf3)) - self.assertEqual(timebase(tf1+tf4), timebase(tf4)) - self.assertEqual(timebase(feedback(tf1, tf1)), timebase(tf1)) - self.assertEqual(timebase(feedback(tf1, tf2)), timebase(tf2)) - self.assertEqual(timebase(feedback(tf1, tf3)), timebase(tf3)) - self.assertEqual(timebase(feedback(tf1, tf4)), timebase(tf4)) + assert timebase(tf1*tf1) == timebase(tf1) + assert timebase(tf1*tf2) == timebase(tf2) + assert timebase(tf1*tf3) == timebase(tf3) + assert timebase(tf1*tf4) == timebase(tf4) + assert timebase(tf3*tf4) == timebase(tf4) + assert timebase(tf2*tf1) == timebase(tf2) + assert timebase(tf3*tf1) == timebase(tf3) + assert timebase(tf4*tf1) == timebase(tf4) + assert timebase(tf1+tf1) == timebase(tf1) + assert timebase(tf1+tf2) == timebase(tf2) + assert timebase(tf1+tf3) == timebase(tf3) + assert timebase(tf1+tf4) == timebase(tf4) + assert timebase(feedback(tf1, tf1)) == timebase(tf1) + assert timebase(feedback(tf1, tf2)) == timebase(tf2) + assert timebase(feedback(tf1, tf3)) == timebase(tf3) + assert timebase(feedback(tf1, tf4)) == timebase(tf4) # Make sure discrete time without sampling is converted correctly - self.assertEqual(timebase(tf3*tf3), timebase(tf3)) - self.assertEqual(timebase(tf3*tf4), timebase(tf4)) - self.assertEqual(timebase(tf3+tf3), timebase(tf3)) - self.assertEqual(timebase(tf3+tf3), timebase(tf4)) - self.assertEqual(timebase(feedback(tf3, tf3)), timebase(tf3)) - self.assertEqual(timebase(feedback(tf3, tf4)), timebase(tf4)) + assert timebase(tf3*tf3) == timebase(tf3) + assert timebase(tf3*tf4) == timebase(tf4) + assert timebase(tf3+tf3) == timebase(tf3) + assert timebase(tf3+tf4) == timebase(tf4) + assert timebase(feedback(tf3, tf3)) == timebase(tf3) + assert timebase(feedback(tf3, tf4)) == timebase(tf4) # Make sure all other combinations are errors - try: - tf2*tf3 # Error; incompatible timebases - raise ValueError("incompatible operation allowed") - except ValueError: - pass - try: - tf2*tf4 # Error; incompatible timebases - raise ValueError("incompatible operation allowed") - except ValueError: - pass - try: - tf2+tf3 # Error; incompatible timebases - raise ValueError("incompatible operation allowed") - except ValueError: - pass - try: - tf2+tf4 # Error; incompatible timebases - raise ValueError("incompatible operation allowed") - except ValueError: - pass - try: - feedback(tf2, tf3) # Error; incompatible timebases - raise ValueError("incompatible operation allowed") - except ValueError: - pass - try: - feedback(tf2, tf4) # Error; incompatible timebases - raise ValueError("incompatible operation allowed") - except ValueError: - pass - - def testisdtime(self): + with pytest.raises(ValueError, match="incompatible timebases"): + tf2 * tf3 + with pytest.raises(ValueError, match="incompatible timebases"): + tf3 * tf2 + with pytest.raises(ValueError, match="incompatible timebases"): + tf2 * tf4 + with pytest.raises(ValueError, match="incompatible timebases"): + tf4 * tf2 + with pytest.raises(ValueError, match="incompatible timebases"): + tf2 + tf3 + with pytest.raises(ValueError, match="incompatible timebases"): + tf3 + tf2 + with pytest.raises(ValueError, match="incompatible timebases"): + tf2 + tf4 + with pytest.raises(ValueError, match="incompatible timebases"): + tf4 + tf2 + with pytest.raises(ValueError, match="incompatible timebases"): + feedback(tf2, tf3) + with pytest.raises(ValueError, match="incompatible timebases"): + feedback(tf3, tf2) + with pytest.raises(ValueError, match="incompatible timebases"): + feedback(tf2, tf4) + with pytest.raises(ValueError, match="incompatible timebases"): + feedback(tf4, tf2) + + def testisdtime(self, tsys): # Constant - self.assertEqual(isdtime(1), True); - self.assertEqual(isdtime(1, strict=True), False); + assert isdtime(1) + assert not isdtime(1, strict=True) # State space - self.assertEqual(isdtime(self.siso_ss1), True); - self.assertEqual(isdtime(self.siso_ss1, strict=True), False); - self.assertEqual(isdtime(self.siso_ss1c), False); - self.assertEqual(isdtime(self.siso_ss1c, strict=True), False); - self.assertEqual(isdtime(self.siso_ss1d), True); - self.assertEqual(isdtime(self.siso_ss1d, strict=True), True); - self.assertEqual(isdtime(self.siso_ss3d, strict=True), True); + assert isdtime(tsys.siso_ss1) + assert not isdtime(tsys.siso_ss1, strict=True) + assert not isdtime(tsys.siso_ss1c) + assert not isdtime(tsys.siso_ss1c, strict=True) + assert isdtime(tsys.siso_ss1d) + assert isdtime(tsys.siso_ss1d, strict=True) + assert isdtime(tsys.siso_ss3d, strict=True) # Transfer function - self.assertEqual(isdtime(self.siso_tf1), True); - self.assertEqual(isdtime(self.siso_tf1, strict=True), False); - self.assertEqual(isdtime(self.siso_tf1c), False); - self.assertEqual(isdtime(self.siso_tf1c, strict=True), False); - self.assertEqual(isdtime(self.siso_tf1d), True); - self.assertEqual(isdtime(self.siso_tf1d, strict=True), True); - self.assertEqual(isdtime(self.siso_tf3d, strict=True), True); - - def testisctime(self): + assert isdtime(tsys.siso_tf1) + assert not isdtime(tsys.siso_tf1, strict=True) + assert not isdtime(tsys.siso_tf1c) + assert not isdtime(tsys.siso_tf1c, strict=True) + assert isdtime(tsys.siso_tf1d) + assert isdtime(tsys.siso_tf1d, strict=True) + assert isdtime(tsys.siso_tf3d, strict=True) + + def testisctime(self, tsys): # Constant - self.assertEqual(isctime(1), True); - self.assertEqual(isctime(1, strict=True), False); + assert isctime(1) + assert not isctime(1, strict=True) # State Space - self.assertEqual(isctime(self.siso_ss1), True); - self.assertEqual(isctime(self.siso_ss1, strict=True), False); - self.assertEqual(isctime(self.siso_ss1c), True); - self.assertEqual(isctime(self.siso_ss1c, strict=True), True); - self.assertEqual(isctime(self.siso_ss1d), False); - self.assertEqual(isctime(self.siso_ss1d, strict=True), False); - self.assertEqual(isctime(self.siso_ss3d, strict=True), False); + assert isctime(tsys.siso_ss1) + assert not isctime(tsys.siso_ss1, strict=True) + assert isctime(tsys.siso_ss1c) + assert isctime(tsys.siso_ss1c, strict=True) + assert not isctime(tsys.siso_ss1d) + assert not isctime(tsys.siso_ss1d, strict=True) + assert not isctime(tsys.siso_ss3d, strict=True) # Transfer Function - self.assertEqual(isctime(self.siso_tf1), True); - self.assertEqual(isctime(self.siso_tf1, strict=True), False); - self.assertEqual(isctime(self.siso_tf1c), True); - self.assertEqual(isctime(self.siso_tf1c, strict=True), True); - self.assertEqual(isctime(self.siso_tf1d), False); - self.assertEqual(isctime(self.siso_tf1d, strict=True), False); - self.assertEqual(isctime(self.siso_tf3d, strict=True), False); - - def testAddition(self): + assert isctime(tsys.siso_tf1) + assert not isctime(tsys.siso_tf1, strict=True) + assert isctime(tsys.siso_tf1c) + assert isctime(tsys.siso_tf1c, strict=True) + assert not isctime(tsys.siso_tf1d) + assert not isctime(tsys.siso_tf1d, strict=True) + assert not isctime(tsys.siso_tf3d, strict=True) + + def testAddition(self, tsys): # State space addition - sys = self.siso_ss1 + self.siso_ss1d - sys = self.siso_ss1 + self.siso_ss1c - sys = self.siso_ss1c + self.siso_ss1 - sys = self.siso_ss1d + self.siso_ss1 - sys = self.siso_ss1c + self.siso_ss1c - sys = self.siso_ss1d + self.siso_ss1d - sys = self.siso_ss3d + self.siso_ss3d - self.assertRaises(ValueError, StateSpace.__add__, self.mimo_ss1c, - self.mimo_ss1d) - self.assertRaises(ValueError, StateSpace.__add__, self.mimo_ss1d, - self.mimo_ss2d) - self.assertRaises(ValueError, StateSpace.__add__, self.siso_ss1d, - self.siso_ss3d) + sys = tsys.siso_ss1 + tsys.siso_ss1d + sys = tsys.siso_ss1 + tsys.siso_ss1c + sys = tsys.siso_ss1c + tsys.siso_ss1 + sys = tsys.siso_ss1d + tsys.siso_ss1 + sys = tsys.siso_ss1c + tsys.siso_ss1c + sys = tsys.siso_ss1d + tsys.siso_ss1d + sys = tsys.siso_ss3d + tsys.siso_ss3d + sys = tsys.siso_ss1d + tsys.siso_ss3d + + with pytest.raises(ValueError): + StateSpace.__add__(tsys.mimo_ss1c, tsys.mimo_ss1d) + with pytest.raises(ValueError): + StateSpace.__add__(tsys.mimo_ss1d, tsys.mimo_ss2d) # Transfer function addition - sys = self.siso_tf1 + self.siso_tf1d - sys = self.siso_tf1 + self.siso_tf1c - sys = self.siso_tf1c + self.siso_tf1 - sys = self.siso_tf1d + self.siso_tf1 - sys = self.siso_tf1c + self.siso_tf1c - sys = self.siso_tf1d + self.siso_tf1d - sys = self.siso_tf2d + self.siso_tf2d - self.assertRaises(ValueError, TransferFunction.__add__, self.siso_tf1c, - self.siso_tf1d) - self.assertRaises(ValueError, TransferFunction.__add__, self.siso_tf1d, - self.siso_tf2d) - self.assertRaises(ValueError, TransferFunction.__add__, self.siso_tf1d, - self.siso_tf3d) + sys = tsys.siso_tf1 + tsys.siso_tf1d + sys = tsys.siso_tf1 + tsys.siso_tf1c + sys = tsys.siso_tf1c + tsys.siso_tf1 + sys = tsys.siso_tf1d + tsys.siso_tf1 + sys = tsys.siso_tf1c + tsys.siso_tf1c + sys = tsys.siso_tf1d + tsys.siso_tf1d + sys = tsys.siso_tf2d + tsys.siso_tf2d + sys = tsys.siso_tf1d + tsys.siso_tf3d + + with pytest.raises(ValueError): + TransferFunction.__add__(tsys.siso_tf1c, tsys.siso_tf1d) + with pytest.raises(ValueError): + TransferFunction.__add__(tsys.siso_tf1d, tsys.siso_tf2d) # State space + transfer function - sys = self.siso_ss1c + self.siso_tf1c - sys = self.siso_tf1c + self.siso_ss1c - sys = self.siso_ss1d + self.siso_tf1d - sys = self.siso_tf1d + self.siso_ss1d - self.assertRaises(ValueError, TransferFunction.__add__, self.siso_tf1c, - self.siso_ss1d) - - def testMultiplication(self): - # State space addition - sys = self.siso_ss1 * self.siso_ss1d - sys = self.siso_ss1 * self.siso_ss1c - sys = self.siso_ss1c * self.siso_ss1 - sys = self.siso_ss1d * self.siso_ss1 - sys = self.siso_ss1c * self.siso_ss1c - sys = self.siso_ss1d * self.siso_ss1d - self.assertRaises(ValueError, StateSpace.__mul__, self.mimo_ss1c, - self.mimo_ss1d) - self.assertRaises(ValueError, StateSpace.__mul__, self.mimo_ss1d, - self.mimo_ss2d) - self.assertRaises(ValueError, StateSpace.__mul__, self.siso_ss1d, - self.siso_ss3d) - - # Transfer function addition - sys = self.siso_tf1 * self.siso_tf1d - sys = self.siso_tf1 * self.siso_tf1c - sys = self.siso_tf1c * self.siso_tf1 - sys = self.siso_tf1d * self.siso_tf1 - sys = self.siso_tf1c * self.siso_tf1c - sys = self.siso_tf1d * self.siso_tf1d - self.assertRaises(ValueError, TransferFunction.__mul__, self.siso_tf1c, - self.siso_tf1d) - self.assertRaises(ValueError, TransferFunction.__mul__, self.siso_tf1d, - self.siso_tf2d) - self.assertRaises(ValueError, TransferFunction.__mul__, self.siso_tf1d, - self.siso_tf3d) + sys = tsys.siso_ss1c + tsys.siso_tf1c + sys = tsys.siso_tf1c + tsys.siso_ss1c + sys = tsys.siso_ss1d + tsys.siso_tf1d + sys = tsys.siso_tf1d + tsys.siso_ss1d + with pytest.raises(ValueError): + TransferFunction.__add__(tsys.siso_tf1c, tsys.siso_ss1d) + + def testMultiplication(self, tsys): + # State space multiplication + sys = tsys.siso_ss1 * tsys.siso_ss1d + sys = tsys.siso_ss1 * tsys.siso_ss1c + sys = tsys.siso_ss1c * tsys.siso_ss1 + sys = tsys.siso_ss1d * tsys.siso_ss1 + sys = tsys.siso_ss1c * tsys.siso_ss1c + sys = tsys.siso_ss1d * tsys.siso_ss1d + sys = tsys.siso_ss1d * tsys.siso_ss3d + + with pytest.raises(ValueError): + StateSpace.__mul__(tsys.mimo_ss1c, tsys.mimo_ss1d) + with pytest.raises(ValueError): + StateSpace.__mul__(tsys.mimo_ss1d, tsys.mimo_ss2d) + + # Transfer function multiplication + sys = tsys.siso_tf1 * tsys.siso_tf1d + sys = tsys.siso_tf1 * tsys.siso_tf1c + sys = tsys.siso_tf1c * tsys.siso_tf1 + sys = tsys.siso_tf1d * tsys.siso_tf1 + sys = tsys.siso_tf1c * tsys.siso_tf1c + sys = tsys.siso_tf1d * tsys.siso_tf1d + sys = tsys.siso_tf1d * tsys.siso_tf3d + + with pytest.raises(ValueError): + TransferFunction.__mul__(tsys.siso_tf1c, tsys.siso_tf1d) + with pytest.raises(ValueError): + TransferFunction.__mul__(tsys.siso_tf1d, tsys.siso_tf2d) # State space * transfer function - sys = self.siso_ss1c * self.siso_tf1c - sys = self.siso_tf1c * self.siso_ss1c - sys = self.siso_ss1d * self.siso_tf1d - sys = self.siso_tf1d * self.siso_ss1d - self.assertRaises(ValueError, TransferFunction.__mul__, self.siso_tf1c, - self.siso_ss1d) - - - def testFeedback(self): - # State space addition - sys = feedback(self.siso_ss1, self.siso_ss1d) - sys = feedback(self.siso_ss1, self.siso_ss1c) - sys = feedback(self.siso_ss1c, self.siso_ss1) - sys = feedback(self.siso_ss1d, self.siso_ss1) - sys = feedback(self.siso_ss1c, self.siso_ss1c) - sys = feedback(self.siso_ss1d, self.siso_ss1d) - self.assertRaises(ValueError, feedback, self.mimo_ss1c, self.mimo_ss1d) - self.assertRaises(ValueError, feedback, self.mimo_ss1d, self.mimo_ss2d) - self.assertRaises(ValueError, feedback, self.siso_ss1d, self.siso_ss3d) - - # Transfer function addition - sys = feedback(self.siso_tf1, self.siso_tf1d) - sys = feedback(self.siso_tf1, self.siso_tf1c) - sys = feedback(self.siso_tf1c, self.siso_tf1) - sys = feedback(self.siso_tf1d, self.siso_tf1) - sys = feedback(self.siso_tf1c, self.siso_tf1c) - sys = feedback(self.siso_tf1d, self.siso_tf1d) - self.assertRaises(ValueError, feedback, self.siso_tf1c, self.siso_tf1d) - self.assertRaises(ValueError, feedback, self.siso_tf1d, self.siso_tf2d) - self.assertRaises(ValueError, feedback, self.siso_tf1d, self.siso_tf3d) + sys = tsys.siso_ss1c * tsys.siso_tf1c + sys = tsys.siso_tf1c * tsys.siso_ss1c + sys = tsys.siso_ss1d * tsys.siso_tf1d + sys = tsys.siso_tf1d * tsys.siso_ss1d + with pytest.raises(ValueError): + TransferFunction.__mul__(tsys.siso_tf1c, + tsys.siso_ss1d) + + + def testFeedback(self, tsys): + # State space feedback + sys = feedback(tsys.siso_ss1, tsys.siso_ss1d) + sys = feedback(tsys.siso_ss1, tsys.siso_ss1c) + sys = feedback(tsys.siso_ss1c, tsys.siso_ss1) + sys = feedback(tsys.siso_ss1d, tsys.siso_ss1) + sys = feedback(tsys.siso_ss1c, tsys.siso_ss1c) + sys = feedback(tsys.siso_ss1d, tsys.siso_ss1d) + sys = feedback(tsys.siso_ss1d, tsys.siso_ss3d) + + with pytest.raises(ValueError): + feedback(tsys.mimo_ss1c, tsys.mimo_ss1d) + with pytest.raises(ValueError): + feedback(tsys.mimo_ss1d, tsys.mimo_ss2d) + + # Transfer function feedback + sys = feedback(tsys.siso_tf1, tsys.siso_tf1d) + sys = feedback(tsys.siso_tf1, tsys.siso_tf1c) + sys = feedback(tsys.siso_tf1c, tsys.siso_tf1) + sys = feedback(tsys.siso_tf1d, tsys.siso_tf1) + sys = feedback(tsys.siso_tf1c, tsys.siso_tf1c) + sys = feedback(tsys.siso_tf1d, tsys.siso_tf1d) + sys = feedback(tsys.siso_tf1d, tsys.siso_tf3d) + + with pytest.raises(ValueError): + feedback(tsys.siso_tf1c, tsys.siso_tf1d) + with pytest.raises(ValueError): + feedback(tsys.siso_tf1d, tsys.siso_tf2d) # State space, transfer function - sys = feedback(self.siso_ss1c, self.siso_tf1c) - sys = feedback(self.siso_tf1c, self.siso_ss1c) - sys = feedback(self.siso_ss1d, self.siso_tf1d) - sys = feedback(self.siso_tf1d, self.siso_ss1d) - self.assertRaises(ValueError, feedback, self.siso_tf1c, self.siso_ss1d) - - def testSimulation(self): + sys = feedback(tsys.siso_ss1c, tsys.siso_tf1c) + sys = feedback(tsys.siso_tf1c, tsys.siso_ss1c) + sys = feedback(tsys.siso_ss1d, tsys.siso_tf1d) + sys = feedback(tsys.siso_tf1d, tsys.siso_ss1d) + with pytest.raises(ValueError): + feedback(tsys.siso_tf1c, tsys.siso_ss1d) + + def testSimulation(self, tsys): T = range(100) U = np.sin(T) # For now, just check calling syntax # TODO: add checks on output of simulations - tout, yout = step_response(self.siso_ss1d) - tout, yout = step_response(self.siso_ss1d, T) - tout, yout = impulse_response(self.siso_ss1d, T) - tout, yout = impulse_response(self.siso_ss1d) - tout, yout, xout = forced_response(self.siso_ss1d, T, U, 0) - tout, yout, xout = forced_response(self.siso_ss2d, T, U, 0) - tout, yout, xout = forced_response(self.siso_ss3d, T, U, 0) - - def test_sample_system(self): + tout, yout = step_response(tsys.siso_ss1d) + tout, yout = step_response(tsys.siso_ss1d, T) + tout, yout = impulse_response(tsys.siso_ss1d) + tout, yout = impulse_response(tsys.siso_ss1d, T) + tout, yout = forced_response(tsys.siso_ss1d, T, U, 0) + tout, yout = forced_response(tsys.siso_ss2d, T, U, 0) + tout, yout = forced_response(tsys.siso_ss3d, T, U, 0) + tout, yout, xout = forced_response(tsys.siso_ss1d, T, U, 0, + return_x=True) + + def test_sample_system(self, tsys): # Make sure we can convert various types of systems - for sysc in (self.siso_tf1, self.siso_tf1c, - self.siso_ss1, self.siso_ss1c, - self.mimo_ss1, self.mimo_ss1c): + for sysc in (tsys.siso_tf1, tsys.siso_tf1c, + tsys.siso_ss1, tsys.siso_ss1c, + tsys.mimo_ss1, tsys.mimo_ss1c): for method in ("zoh", "bilinear", "euler", "backward_diff"): sysd = sample_system(sysc, 1, method=method) - self.assertEqual(sysd.dt, 1) + assert sysd.dt == 1 # Check "matched", defined only for SISO transfer functions - for sysc in (self.siso_tf1, self.siso_tf1c): + for sysc in (tsys.siso_tf1, tsys.siso_tf1c): sysd = sample_system(sysc, 1, method="matched") - self.assertEqual(sysd.dt, 1) - + assert sysd.dt == 1 + + @pytest.mark.parametrize("plantname", + ["siso_ss1c", + "siso_tf1c"]) + def test_sample_system_prewarp(self, tsys, plantname): + """bilinear approximation with prewarping test""" + wwarp = 50 + Ts = 0.025 + # test state space version + plant = getattr(tsys, plantname) + plant_d_warped = plant.sample(Ts, 'bilinear', prewarp_frequency=wwarp) + plant_fr = evalfr(plant, wwarp * 1j) + dt = plant_d_warped.dt + plant_d_fr = evalfr(plant_d_warped, np.exp(wwarp * 1.j * dt)) + np.testing.assert_array_almost_equal(plant_fr, plant_d_fr) + + def test_sample_system_errors(self, tsys): # Check errors - self.assertRaises(ValueError, sample_system, self.siso_ss1d, 1) - self.assertRaises(ValueError, sample_system, self.siso_tf1d, 1) - self.assertRaises(ValueError, sample_system, self.siso_ss1, 1, 'unknown') + with pytest.raises(ValueError): + sample_system(tsys.siso_ss1d, 1) + with pytest.raises(ValueError): + sample_system(tsys.siso_tf1d, 1) + with pytest.raises(ValueError): + sample_system(tsys.siso_ss1, 1, 'unknown') + - def test_sample_ss(self): + def test_sample_ss(self, tsys): # double integrators, two different ways sys1 = StateSpace([[0.,1.],[0.,0.]], [[0.],[1.]], [[1.,0.]], 0.) sys2 = StateSpace([[0.,0.],[1.,0.]], [[1.],[0.]], [[0.,1.]], 0.) @@ -359,22 +412,22 @@ def test_sample_ss(self): np.testing.assert_array_almost_equal(sysd.B, Bd) np.testing.assert_array_almost_equal(sysd.C, sys.C) np.testing.assert_array_almost_equal(sysd.D, sys.D) - self.assertEqual(sysd.dt, h) + assert sysd.dt == h - def test_sample_tf(self): + def test_sample_tf(self, tsys): # double integrator sys = TransferFunction(1, [1,0,0]) for h in (0.1, 0.5, 1, 2): numd_expected = 0.5 * h**2 * np.array([1.,1.]) dend_expected = np.array([1.,-2.,1.]) sysd = sample_system(sys, h, method='zoh') - self.assertEqual(sysd.dt, h) + assert sysd.dt == h numd = sysd.num[0][0] dend = sysd.den[0][0] np.testing.assert_array_almost_equal(numd, numd_expected) np.testing.assert_array_almost_equal(dend, dend_expected) - def test_discrete_bode(self): + def test_discrete_bode(self, tsys): # Create a simple discrete time system and check the calculation sys = TransferFunction([1], [1, 0.5], 1) omega = [1, 2, 3] @@ -383,7 +436,3 @@ def test_discrete_bode(self): np.testing.assert_array_almost_equal(omega, omega_out) np.testing.assert_array_almost_equal(mag_out, np.absolute(H_z)) np.testing.assert_array_almost_equal(phase_out, np.angle(H_z)) - - -if __name__ == "__main__": - unittest.main() diff --git a/control/tests/flatsys_test.py b/control/tests/flatsys_test.py index 0c1d0c92c..373af8dae 100644 --- a/control/tests/flatsys_test.py +++ b/control/tests/flatsys_test.py @@ -1,59 +1,58 @@ -#!/usr/bin/env python -# -# flatsys_test.py - test flat system module -# RMM, 29 Jun 2019 -# -# This test suite checks to make sure that the basic functions supporting -# differential flat systetms are functioning. It doesn't do exhaustive -# testing of operations on flat systems. Separate unit tests should be -# created for that purpose. - -import unittest +"""flatsys_test.py - test flat system module + +RMM, 29 Jun 2019 + +This test suite checks to make sure that the basic functions supporting +differential flat systetms are functioning. It doesn't do exhaustive +testing of operations on flat systems. Separate unit tests should be +created for that purpose. +""" + +from distutils.version import StrictVersion + import numpy as np +import pytest import scipy as sp + import control as ct import control.flatsys as fs -from distutils.version import StrictVersion +import control.optimal as opt +class TestFlatSys: + """Test differential flat systems""" -class TestFlatSys(unittest.TestCase): - def setUp(self): - ct.use_numpy_matrix(False) - - def test_double_integrator(self): + @pytest.mark.parametrize( + "xf, uf, Tf", + [([1, 0], [0], 2), + ([0, 1], [0], 3), + ([1, 1], [1], 4)]) + def test_double_integrator(self, xf, uf, Tf): # Define a second order integrator sys = ct.StateSpace([[-1, 1], [0, -2]], [[0], [1]], [[1, 0]], 0) flatsys = fs.LinearFlatSystem(sys) - # Define the endpoints of a trajectory - x1 = [0, 0]; u1 = [0]; T1 = 1 - x2 = [1, 0]; u2 = [0]; T2 = 2 - x3 = [0, 1]; u3 = [0]; T3 = 3 - x4 = [1, 1]; u4 = [1]; T4 = 4 - # Define the basis set poly = fs.PolyFamily(6) - # Plan trajectories for various combinations - for x0, u0, xf, uf, Tf in [ - (x1, u1, x2, u2, T2), (x1, u1, x3, u3, T3), (x1, u1, x4, u4, T4)]: - traj = fs.point_to_point(flatsys, x0, u0, xf, uf, Tf, basis=poly) + x1, u1, = [0, 0], [0] + traj = fs.point_to_point(flatsys, Tf, x1, u1, xf, uf, basis=poly) - # Verify that the trajectory computation is correct - x, u = traj.eval([0, Tf]) - np.testing.assert_array_almost_equal(x0, x[:, 0]) - np.testing.assert_array_almost_equal(u0, u[:, 0]) - np.testing.assert_array_almost_equal(xf, x[:, 1]) - np.testing.assert_array_almost_equal(uf, u[:, 1]) + # Verify that the trajectory computation is correct + x, u = traj.eval([0, Tf]) + np.testing.assert_array_almost_equal(x1, x[:, 0]) + np.testing.assert_array_almost_equal(u1, u[:, 0]) + np.testing.assert_array_almost_equal(xf, x[:, 1]) + np.testing.assert_array_almost_equal(uf, u[:, 1]) - # Simulate the system and make sure we stay close to desired traj - T = np.linspace(0, Tf, 100) - xd, ud = traj.eval(T) + # Simulate the system and make sure we stay close to desired traj + T = np.linspace(0, Tf, 100) + xd, ud = traj.eval(T) - t, y, x = ct.forced_response(sys, T, ud, x0) - np.testing.assert_array_almost_equal(x, xd, decimal=3) + t, y, x = ct.forced_response(sys, T, ud, x1, return_x=True) + np.testing.assert_array_almost_equal(x, xd, decimal=3) - def test_kinematic_car(self): + @pytest.fixture + def vehicle_flat(self): """Differential flatness for a kinematic car""" def vehicle_flat_forward(x, u, params={}): b = params.get('wheelbase', 3.) # get parameter values @@ -90,21 +89,21 @@ def vehicle_update(t, x, u, params): def vehicle_output(t, x, u, params): return x # Create differentially flat input/output system - vehicle_flat = fs.FlatSystem( + return fs.FlatSystem( vehicle_flat_forward, vehicle_flat_reverse, vehicle_update, vehicle_output, inputs=('v', 'delta'), outputs=('x', 'y', 'theta'), states=('x', 'y', 'theta')) + @pytest.mark.parametrize("poly", [ + fs.PolyFamily(6), fs.PolyFamily(8), fs.BezierFamily(6)]) + def test_kinematic_car(self, vehicle_flat, poly): # Define the endpoints of the trajectory x0 = [0., -2., 0.]; u0 = [10., 0.] xf = [100., 2., 0.]; uf = [10., 0.] Tf = 10 - # Define a set of basis functions to use for the trajectories - poly = fs.PolyFamily(6) - # Find trajectory between initial and final conditions - traj = fs.point_to_point(vehicle_flat, x0, u0, xf, uf, Tf, basis=poly) + traj = fs.point_to_point(vehicle_flat, Tf, x0, u0, xf, uf, basis=poly) # Verify that the trajectory computation is correct x, u = traj.eval([0, Tf]) @@ -123,9 +122,227 @@ def vehicle_output(t, x, u, params): return x vehicle_flat, T, ud, x0, return_x=True) np.testing.assert_allclose(x, xd, atol=0.01, rtol=0.01) - def tearDown(self): - ct.reset_defaults() + def test_flat_cost_constr(self): + # Double integrator system + sys = ct.ss([[0, 1], [0, 0]], [[0], [1]], [[1, 0]], 0) + flat_sys = fs.LinearFlatSystem(sys) + + # Define the endpoints of the trajectory + x0 = [1, 0]; u0 = [0] + xf = [0, 0]; uf = [0] + Tf = 10 + T = np.linspace(0, Tf, 500) + + # Find trajectory between initial and final conditions + traj = fs.point_to_point( + flat_sys, Tf, x0, u0, xf, uf, basis=fs.PolyFamily(8)) + x, u = traj.eval(T) + + np.testing.assert_array_almost_equal(x0, x[:, 0]) + np.testing.assert_array_almost_equal(u0, u[:, 0]) + np.testing.assert_array_almost_equal(xf, x[:, -1]) + np.testing.assert_array_almost_equal(uf, u[:, -1]) + + # Solve with a cost function + timepts = np.linspace(0, Tf, 10) + cost_fcn = opt.quadratic_cost( + flat_sys, np.diag([0, 0]), 1, x0=xf, u0=uf) + + traj_cost = fs.point_to_point( + flat_sys, timepts, x0, u0, xf, uf, cost=cost_fcn, + basis=fs.PolyFamily(8), + # initial_guess='lstsq', + # minimize_kwargs={'method': 'trust-constr'} + ) + + # Verify that the trajectory computation is correct + x_cost, u_cost = traj_cost.eval(T) + np.testing.assert_array_almost_equal(x0, x_cost[:, 0]) + np.testing.assert_array_almost_equal(u0, u_cost[:, 0]) + np.testing.assert_array_almost_equal(xf, x_cost[:, -1]) + np.testing.assert_array_almost_equal(uf, u_cost[:, -1]) + + # Make sure that we got a different answer than before + assert np.any(np.abs(x - x_cost) > 0.1) + + # Re-solve with constraint on the y deviation + lb, ub = [-2, -0.1], [2, 0] + lb, ub = [-2, np.min(x_cost[1])*0.95], [2, 1] + constraints = [opt.state_range_constraint(flat_sys, lb, ub)] + + # Make sure that the previous solution violated at least one constraint + assert np.any(x_cost[0, :] < lb[0]) or np.any(x_cost[0, :] > ub[0]) \ + or np.any(x_cost[1, :] < lb[1]) or np.any(x_cost[1, :] > ub[1]) + + traj_const = fs.point_to_point( + flat_sys, timepts, x0, u0, xf, uf, cost=cost_fcn, + constraints=constraints, basis=fs.PolyFamily(8), + ) + + # Verify that the trajectory computation is correct + x_const, u_const = traj_const.eval(T) + np.testing.assert_array_almost_equal(x0, x_const[:, 0]) + np.testing.assert_array_almost_equal(u0, u_const[:, 0]) + np.testing.assert_array_almost_equal(xf, x_const[:, -1]) + np.testing.assert_array_almost_equal(uf, u_const[:, -1]) + + # Make sure that the solution respects the bounds (with some slop) + for i in range(x_const.shape[0]): + assert np.all(x_const[i] >= lb[i] * 1.02) + assert np.all(x_const[i] <= ub[i] * 1.02) + + # Solve the same problem with a nonlinear constraint type + nl_constraints = [ + (sp.optimize.NonlinearConstraint, lambda x, u: x, lb, ub)] + traj_nlconst = fs.point_to_point( + flat_sys, timepts, x0, u0, xf, uf, cost=cost_fcn, + constraints=nl_constraints, basis=fs.PolyFamily(8), + ) + x_nlconst, u_nlconst = traj_nlconst.eval(T) + np.testing.assert_almost_equal(x_const, x_nlconst) + np.testing.assert_almost_equal(u_const, u_nlconst) + + def test_bezier_basis(self): + bezier = fs.BezierFamily(4) + time = np.linspace(0, 1, 100) + + # Sum of the Bezier curves should be one + np.testing.assert_almost_equal( + 1, sum([bezier(i, time) for i in range(4)])) + + # Sum of derivatives should be zero + for k in range(1, 5): + np.testing.assert_almost_equal( + 0, sum([bezier.eval_deriv(i, k, time) for i in range(4)])) + + # Compare derivatives to formulas + np.testing.assert_almost_equal( + bezier.eval_deriv(1, 0, time), 3 * time - 6 * time**2 + 3 * time**3) + np.testing.assert_almost_equal( + bezier.eval_deriv(1, 1, time), 3 - 12 * time + 9 * time**2) + np.testing.assert_almost_equal( + bezier.eval_deriv(1, 2, time), -12 + 18 * time) + + # Make sure that the second derivative integrates to the first + time = np.linspace(0, 1, 1000) + dt = np.diff(time) + for N in range(5): + bezier = fs.BezierFamily(N) + for i in range(N): + for j in range(1, N+1): + np.testing.assert_allclose( + np.diff(bezier.eval_deriv(i, j-1, time)) / dt, + bezier.eval_deriv(i, j, time)[0:-1], + atol=0.01, rtol=0.01) + + # Exception check + with pytest.raises(ValueError, match="index too high"): + bezier.eval_deriv(4, 0, time) + + def test_point_to_point_errors(self): + """Test error and warning conditions in point_to_point()""" + # Double integrator system + sys = ct.ss([[0, 1], [0, 0]], [[0], [1]], [[1, 0]], 0) + flat_sys = fs.LinearFlatSystem(sys) + + # Define the endpoints of the trajectory + x0 = [1, 0]; u0 = [0] + xf = [0, 0]; uf = [0] + Tf = 10 + T = np.linspace(0, Tf, 500) + + # Cost function + timepts = np.linspace(0, Tf, 10) + cost_fcn = opt.quadratic_cost( + flat_sys, np.diag([1, 1]), 1, x0=xf, u0=uf) + + # Solving without basis specified should be OK + traj = fs.point_to_point(flat_sys, timepts, x0, u0, xf, uf) + x, u = traj.eval(timepts) + np.testing.assert_array_almost_equal(x0, x[:, 0]) + np.testing.assert_array_almost_equal(u0, u[:, 0]) + np.testing.assert_array_almost_equal(xf, x[:, -1]) + np.testing.assert_array_almost_equal(uf, u[:, -1]) + + # Adding a cost function generates a warning + with pytest.warns(UserWarning, match="optimization not possible"): + traj = fs.point_to_point( + flat_sys, timepts, x0, u0, xf, uf, cost=cost_fcn) + + # Make sure we still solved the problem + x, u = traj.eval(timepts) + np.testing.assert_array_almost_equal(x0, x[:, 0]) + np.testing.assert_array_almost_equal(u0, u[:, 0]) + np.testing.assert_array_almost_equal(xf, x[:, -1]) + np.testing.assert_array_almost_equal(uf, u[:, -1]) + + # Try to optimize with insufficient degrees of freedom + with pytest.warns(UserWarning, match="optimization not possible"): + traj = fs.point_to_point( + flat_sys, timepts, x0, u0, xf, uf, cost=cost_fcn, + basis=fs.PolyFamily(6)) + + # Make sure we still solved the problem + x, u = traj.eval(timepts) + np.testing.assert_array_almost_equal(x0, x[:, 0]) + np.testing.assert_array_almost_equal(u0, u[:, 0]) + np.testing.assert_array_almost_equal(xf, x[:, -1]) + np.testing.assert_array_almost_equal(uf, u[:, -1]) + + # Solve with the errors in the various input arguments + with pytest.raises(ValueError, match="Initial state: Wrong shape"): + traj = fs.point_to_point(flat_sys, timepts, np.zeros(3), u0, xf, uf) + with pytest.raises(ValueError, match="Initial input: Wrong shape"): + traj = fs.point_to_point(flat_sys, timepts, x0, np.zeros(3), xf, uf) + with pytest.raises(ValueError, match="Final state: Wrong shape"): + traj = fs.point_to_point(flat_sys, timepts, x0, u0, np.zeros(3), uf) + with pytest.raises(ValueError, match="Final input: Wrong shape"): + traj = fs.point_to_point(flat_sys, timepts, x0, u0, xf, np.zeros(3)) + + # Different ways of describing constraints + constraint = opt.input_range_constraint(flat_sys, -100, 100) + + with pytest.warns(UserWarning, match="optimization not possible"): + traj = fs.point_to_point( + flat_sys, timepts, x0, u0, xf, uf, constraints=constraint, + basis=fs.PolyFamily(6)) + + x, u = traj.eval(timepts) + np.testing.assert_array_almost_equal(x0, x[:, 0]) + np.testing.assert_array_almost_equal(u0, u[:, 0]) + np.testing.assert_array_almost_equal(xf, x[:, -1]) + np.testing.assert_array_almost_equal(uf, u[:, -1]) + + # Constraint that isn't a constraint + with pytest.raises(TypeError, match="must be a list"): + traj = fs.point_to_point( + flat_sys, timepts, x0, u0, xf, uf, constraints=np.eye(2), + basis=fs.PolyFamily(8)) + + # Unknown constraint type + with pytest.raises(TypeError, match="unknown constraint type"): + traj = fs.point_to_point( + flat_sys, timepts, x0, u0, xf, uf, + constraints=[(None, 0, 0, 0)], basis=fs.PolyFamily(8)) + + # Unsolvable optimization + constraint = [opt.input_range_constraint(flat_sys, -0.01, 0.01)] + with pytest.raises(RuntimeError, match="Unable to solve optimal"): + traj = fs.point_to_point( + flat_sys, timepts, x0, u0, xf, uf, constraints=constraint, + basis=fs.PolyFamily(8)) + # Method arguments, parameters + traj_method = fs.point_to_point( + flat_sys, timepts, x0, u0, xf, uf, cost=cost_fcn, + basis=fs.PolyFamily(8), minimize_method='slsqp') + traj_kwarg = fs.point_to_point( + flat_sys, timepts, x0, u0, xf, uf, cost=cost_fcn, + basis=fs.PolyFamily(8), minimize_kwargs={'method': 'slsqp'}) + np.testing.assert_almost_equal( + traj_method.eval(timepts)[0], traj_kwarg.eval(timepts)[0]) -if __name__ == '__main__': - unittest.main() + # Unrecognized keywords + with pytest.raises(TypeError, match="unrecognized keyword"): + traj_method = fs.point_to_point( + flat_sys, timepts, x0, u0, xf, uf, solve_ivp_method=None) diff --git a/control/tests/frd_test.py b/control/tests/frd_test.py index fcbc10263..c63a4c02b 100644 --- a/control/tests/frd_test.py +++ b/control/tests/frd_test.py @@ -1,33 +1,36 @@ -#!/usr/bin/env python -# -# frd_test.py - test FRD class -# RvP, 4 Oct 2012 +"""frd_test.py - test FRD class +RvP, 4 Oct 2012 +""" -import unittest import sys as pysys + import numpy as np +import matplotlib.pyplot as plt +import pytest + import control as ct from control.statesp import StateSpace from control.xferfcn import TransferFunction -from control.frdata import FRD, _convertToFRD, FrequencyResponseData -from control import bdalg -from control import freqplot -from control.exception import slycot_check -import matplotlib.pyplot as plt +from control.frdata import FRD, _convert_to_FRD, FrequencyResponseData +from control import bdalg, evalfr, freqplot +from control.tests.conftest import slycotonly -class TestFRD(unittest.TestCase): +class TestFRD: """These are tests for functionality and correct reporting of the frequency response data class.""" def testBadInputType(self): """Give the constructor invalid input types.""" - self.assertRaises(ValueError, FRD) - self.assertRaises(TypeError, FRD, [1]) + with pytest.raises(ValueError): + FRD() + with pytest.raises(TypeError): + FRD([1]) def testInconsistentDimension(self): - self.assertRaises(TypeError, FRD, [1, 1], [1, 2, 3]) + with pytest.raises(TypeError): + FRD([1, 1], [1, 2, 3]) def testSISOtf(self): # get a SISO transfer function @@ -36,8 +39,11 @@ def testSISOtf(self): frd = FRD(h, omega) assert isinstance(frd, FRD) - np.testing.assert_array_almost_equal( - frd.freqresp([1.0]), h.freqresp([1.0])) + mag1, phase1, omega1 = frd.frequency_response([1.0]) + mag2, phase2, omega2 = h.frequency_response([1.0]) + np.testing.assert_array_almost_equal(mag1, mag2) + np.testing.assert_array_almost_equal(phase1, phase2) + np.testing.assert_array_almost_equal(omega1, omega2) def testOperators(self): # get two SISO transfer functions @@ -48,39 +54,39 @@ def testOperators(self): f2 = FRD(h2, omega) np.testing.assert_array_almost_equal( - (f1 + f2).freqresp([0.1, 1.0, 10])[0], - (h1 + h2).freqresp([0.1, 1.0, 10])[0]) + (f1 + f2).frequency_response([0.1, 1.0, 10])[0], + (h1 + h2).frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - (f1 + f2).freqresp([0.1, 1.0, 10])[1], - (h1 + h2).freqresp([0.1, 1.0, 10])[1]) + (f1 + f2).frequency_response([0.1, 1.0, 10])[1], + (h1 + h2).frequency_response([0.1, 1.0, 10])[1]) np.testing.assert_array_almost_equal( - (f1 - f2).freqresp([0.1, 1.0, 10])[0], - (h1 - h2).freqresp([0.1, 1.0, 10])[0]) + (f1 - f2).frequency_response([0.1, 1.0, 10])[0], + (h1 - h2).frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - (f1 - f2).freqresp([0.1, 1.0, 10])[1], - (h1 - h2).freqresp([0.1, 1.0, 10])[1]) + (f1 - f2).frequency_response([0.1, 1.0, 10])[1], + (h1 - h2).frequency_response([0.1, 1.0, 10])[1]) # multiplication and division np.testing.assert_array_almost_equal( - (f1 * f2).freqresp([0.1, 1.0, 10])[1], - (h1 * h2).freqresp([0.1, 1.0, 10])[1]) + (f1 * f2).frequency_response([0.1, 1.0, 10])[1], + (h1 * h2).frequency_response([0.1, 1.0, 10])[1]) np.testing.assert_array_almost_equal( - (f1 / f2).freqresp([0.1, 1.0, 10])[1], - (h1 / h2).freqresp([0.1, 1.0, 10])[1]) + (f1 / f2).frequency_response([0.1, 1.0, 10])[1], + (h1 / h2).frequency_response([0.1, 1.0, 10])[1]) # with default conversion from scalar np.testing.assert_array_almost_equal( - (f1 * 1.5).freqresp([0.1, 1.0, 10])[1], - (h1 * 1.5).freqresp([0.1, 1.0, 10])[1]) + (f1 * 1.5).frequency_response([0.1, 1.0, 10])[1], + (h1 * 1.5).frequency_response([0.1, 1.0, 10])[1]) np.testing.assert_array_almost_equal( - (f1 / 1.7).freqresp([0.1, 1.0, 10])[1], - (h1 / 1.7).freqresp([0.1, 1.0, 10])[1]) + (f1 / 1.7).frequency_response([0.1, 1.0, 10])[1], + (h1 / 1.7).frequency_response([0.1, 1.0, 10])[1]) np.testing.assert_array_almost_equal( - (2.2 * f2).freqresp([0.1, 1.0, 10])[1], - (2.2 * h2).freqresp([0.1, 1.0, 10])[1]) + (2.2 * f2).frequency_response([0.1, 1.0, 10])[1], + (2.2 * h2).frequency_response([0.1, 1.0, 10])[1]) np.testing.assert_array_almost_equal( - (1.3 / f2).freqresp([0.1, 1.0, 10])[1], - (1.3 / h2).freqresp([0.1, 1.0, 10])[1]) + (1.3 / f2).frequency_response([0.1, 1.0, 10])[1], + (1.3 / h2).frequency_response([0.1, 1.0, 10])[1]) def testOperatorsTf(self): # get two SISO transfer functions @@ -92,24 +98,24 @@ def testOperatorsTf(self): f2 # reference to avoid pyflakes error np.testing.assert_array_almost_equal( - (f1 + h2).freqresp([0.1, 1.0, 10])[0], - (h1 + h2).freqresp([0.1, 1.0, 10])[0]) + (f1 + h2).frequency_response([0.1, 1.0, 10])[0], + (h1 + h2).frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - (f1 + h2).freqresp([0.1, 1.0, 10])[1], - (h1 + h2).freqresp([0.1, 1.0, 10])[1]) + (f1 + h2).frequency_response([0.1, 1.0, 10])[1], + (h1 + h2).frequency_response([0.1, 1.0, 10])[1]) np.testing.assert_array_almost_equal( - (f1 - h2).freqresp([0.1, 1.0, 10])[0], - (h1 - h2).freqresp([0.1, 1.0, 10])[0]) + (f1 - h2).frequency_response([0.1, 1.0, 10])[0], + (h1 - h2).frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - (f1 - h2).freqresp([0.1, 1.0, 10])[1], - (h1 - h2).freqresp([0.1, 1.0, 10])[1]) + (f1 - h2).frequency_response([0.1, 1.0, 10])[1], + (h1 - h2).frequency_response([0.1, 1.0, 10])[1]) # multiplication and division np.testing.assert_array_almost_equal( - (f1 * h2).freqresp([0.1, 1.0, 10])[1], - (h1 * h2).freqresp([0.1, 1.0, 10])[1]) + (f1 * h2).frequency_response([0.1, 1.0, 10])[1], + (h1 * h2).frequency_response([0.1, 1.0, 10])[1]) np.testing.assert_array_almost_equal( - (f1 / h2).freqresp([0.1, 1.0, 10])[1], - (h1 / h2).freqresp([0.1, 1.0, 10])[1]) + (f1 / h2).frequency_response([0.1, 1.0, 10])[1], + (h1 / h2).frequency_response([0.1, 1.0, 10])[1]) # the reverse does not work def testbdalg(self): @@ -121,45 +127,45 @@ def testbdalg(self): f2 = FRD(h2, omega) np.testing.assert_array_almost_equal( - (bdalg.series(f1, f2)).freqresp([0.1, 1.0, 10])[0], - (bdalg.series(h1, h2)).freqresp([0.1, 1.0, 10])[0]) + (bdalg.series(f1, f2)).frequency_response([0.1, 1.0, 10])[0], + (bdalg.series(h1, h2)).frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - (bdalg.parallel(f1, f2)).freqresp([0.1, 1.0, 10])[0], - (bdalg.parallel(h1, h2)).freqresp([0.1, 1.0, 10])[0]) + (bdalg.parallel(f1, f2)).frequency_response([0.1, 1.0, 10])[0], + (bdalg.parallel(h1, h2)).frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - (bdalg.feedback(f1, f2)).freqresp([0.1, 1.0, 10])[0], - (bdalg.feedback(h1, h2)).freqresp([0.1, 1.0, 10])[0]) + (bdalg.feedback(f1, f2)).frequency_response([0.1, 1.0, 10])[0], + (bdalg.feedback(h1, h2)).frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - (bdalg.negate(f1)).freqresp([0.1, 1.0, 10])[0], - (bdalg.negate(h1)).freqresp([0.1, 1.0, 10])[0]) + (bdalg.negate(f1)).frequency_response([0.1, 1.0, 10])[0], + (bdalg.negate(h1)).frequency_response([0.1, 1.0, 10])[0]) # append() and connect() not implemented for FRD objects # np.testing.assert_array_almost_equal( -# (bdalg.append(f1, f2)).freqresp([0.1, 1.0, 10])[0], -# (bdalg.append(h1, h2)).freqresp([0.1, 1.0, 10])[0]) +# (bdalg.append(f1, f2)).frequency_response([0.1, 1.0, 10])[0], +# (bdalg.append(h1, h2)).frequency_response([0.1, 1.0, 10])[0]) # # f3 = bdalg.append(f1, f2, f2) # h3 = bdalg.append(h1, h2, h2) # Q = np.mat([ [1, 2], [2, -1] ]) # np.testing.assert_array_almost_equal( -# (bdalg.connect(f3, Q, [2], [1])).freqresp([0.1, 1.0, 10])[0], -# (bdalg.connect(h3, Q, [2], [1])).freqresp([0.1, 1.0, 10])[0]) +# (bdalg.connect(f3, Q, [2], [1])).frequency_response([0.1, 1.0, 10])[0], +# (bdalg.connect(h3, Q, [2], [1])).frequency_response([0.1, 1.0, 10])[0]) def testFeedback(self): h1 = TransferFunction([1], [1, 2, 2]) omega = np.logspace(-1, 2, 10) f1 = FRD(h1, omega) np.testing.assert_array_almost_equal( - f1.feedback(1).freqresp([0.1, 1.0, 10])[0], - h1.feedback(1).freqresp([0.1, 1.0, 10])[0]) + f1.feedback(1).frequency_response([0.1, 1.0, 10])[0], + h1.feedback(1).frequency_response([0.1, 1.0, 10])[0]) # Make sure default argument also works np.testing.assert_array_almost_equal( - f1.feedback().freqresp([0.1, 1.0, 10])[0], - h1.feedback().freqresp([0.1, 1.0, 10])[0]) + f1.feedback().frequency_response([0.1, 1.0, 10])[0], + h1.feedback().frequency_response([0.1, 1.0, 10])[0]) def testFeedback2(self): h2 = StateSpace([[-1.0, 0], [0, -2.0]], [[0.4], [0.1]], @@ -168,9 +174,9 @@ def testFeedback2(self): def testAuto(self): omega = np.logspace(-1, 2, 10) - f1 = _convertToFRD(1, omega) - f2 = _convertToFRD(np.matrix([[1, 0], [0.1, -1]]), omega) - f2 = _convertToFRD([[1, 0], [0.1, -1]], omega) + f1 = _convert_to_FRD(1, omega) + f2 = _convert_to_FRD(np.array([[1, 0], [0.1, -1]]), omega) + f2 = _convert_to_FRD([[1, 0], [0.1, -1]], omega) f1, f2 # reference to avoid pyflakes error def testNyquist(self): @@ -183,7 +189,7 @@ def testNyquist(self): freqplot.nyquist(f1, f1.omega) # plt.savefig('/dev/null', format='svg') - @unittest.skipIf(not slycot_check(), "slycot not installed") + @slycotonly def testMIMO(self): sys = StateSpace([[-0.5, 0.0], [0.0, -1.0]], [[1.0, 0.0], [0.0, 1.0]], @@ -192,13 +198,13 @@ def testMIMO(self): omega = np.logspace(-1, 2, 10) f1 = FRD(sys, omega) np.testing.assert_array_almost_equal( - sys.freqresp([0.1, 1.0, 10])[0], - f1.freqresp([0.1, 1.0, 10])[0]) + sys.frequency_response([0.1, 1.0, 10])[0], + f1.frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - sys.freqresp([0.1, 1.0, 10])[1], - f1.freqresp([0.1, 1.0, 10])[1]) + sys.frequency_response([0.1, 1.0, 10])[1], + f1.frequency_response([0.1, 1.0, 10])[1]) - @unittest.skipIf(not slycot_check(), "slycot not installed") + @slycotonly def testMIMOfb(self): sys = StateSpace([[-0.5, 0.0], [0.0, -1.0]], [[1.0, 0.0], [0.0, 1.0]], @@ -208,29 +214,31 @@ def testMIMOfb(self): f1 = FRD(sys, omega).feedback([[0.1, 0.3], [0.0, 1.0]]) f2 = FRD(sys.feedback([[0.1, 0.3], [0.0, 1.0]]), omega) np.testing.assert_array_almost_equal( - f1.freqresp([0.1, 1.0, 10])[0], - f2.freqresp([0.1, 1.0, 10])[0]) + f1.frequency_response([0.1, 1.0, 10])[0], + f2.frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - f1.freqresp([0.1, 1.0, 10])[1], - f2.freqresp([0.1, 1.0, 10])[1]) + f1.frequency_response([0.1, 1.0, 10])[1], + f2.frequency_response([0.1, 1.0, 10])[1]) - @unittest.skipIf(not slycot_check(), "slycot not installed") + @slycotonly def testMIMOfb2(self): - sys = StateSpace(np.matrix('-2.0 0 0; 0 -1 1; 0 0 -3'), - np.matrix('1.0 0; 0 0; 0 1'), + sys = StateSpace(np.array([[-2.0, 0, 0], + [0, -1, 1], + [0, 0, -3]]), + np.array([[1.0, 0], [0, 0], [0, 1]]), np.eye(3), np.zeros((3, 2))) omega = np.logspace(-1, 2, 10) - K = np.matrix('1 0.3 0; 0.1 0 0') + K = np.array([[1, 0.3, 0], [0.1, 0, 0]]) f1 = FRD(sys, omega).feedback(K) f2 = FRD(sys.feedback(K), omega) np.testing.assert_array_almost_equal( - f1.freqresp([0.1, 1.0, 10])[0], - f2.freqresp([0.1, 1.0, 10])[0]) + f1.frequency_response([0.1, 1.0, 10])[0], + f2.frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - f1.freqresp([0.1, 1.0, 10])[1], - f2.freqresp([0.1, 1.0, 10])[1]) + f1.frequency_response([0.1, 1.0, 10])[1], + f2.frequency_response([0.1, 1.0, 10])[1]) - @unittest.skipIf(not slycot_check(), "slycot not installed") + @slycotonly def testMIMOMult(self): sys = StateSpace([[-0.5, 0.0], [0.0, -1.0]], [[1.0, 0.0], [0.0, 1.0]], @@ -240,78 +248,85 @@ def testMIMOMult(self): f1 = FRD(sys, omega) f2 = FRD(sys, omega) np.testing.assert_array_almost_equal( - (f1*f2).freqresp([0.1, 1.0, 10])[0], - (sys*sys).freqresp([0.1, 1.0, 10])[0]) + (f1*f2).frequency_response([0.1, 1.0, 10])[0], + (sys*sys).frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - (f1*f2).freqresp([0.1, 1.0, 10])[1], - (sys*sys).freqresp([0.1, 1.0, 10])[1]) + (f1*f2).frequency_response([0.1, 1.0, 10])[1], + (sys*sys).frequency_response([0.1, 1.0, 10])[1]) - @unittest.skipIf(not slycot_check(), "slycot not installed") + @slycotonly def testMIMOSmooth(self): sys = StateSpace([[-0.5, 0.0], [0.0, -1.0]], [[1.0, 0.0], [0.0, 1.0]], [[1.0, 0.0], [0.0, 1.0], [1.0, 1.0]], [[0.0, 0.0], [0.0, 0.0], [0.0, 0.0]]) - sys2 = np.matrix([[1, 0, 0], [0, 1, 0]]) * sys + sys2 = np.array([[1, 0, 0], [0, 1, 0]]) * sys omega = np.logspace(-1, 2, 10) f1 = FRD(sys, omega, smooth=True) f2 = FRD(sys2, omega, smooth=True) np.testing.assert_array_almost_equal( - (f1*f2).freqresp([0.1, 1.0, 10])[0], - (sys*sys2).freqresp([0.1, 1.0, 10])[0]) + (f1*f2).frequency_response([0.1, 1.0, 10])[0], + (sys*sys2).frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - (f1*f2).freqresp([0.1, 1.0, 10])[1], - (sys*sys2).freqresp([0.1, 1.0, 10])[1]) + (f1*f2).frequency_response([0.1, 1.0, 10])[1], + (sys*sys2).frequency_response([0.1, 1.0, 10])[1]) np.testing.assert_array_almost_equal( - (f1*f2).freqresp([0.1, 1.0, 10])[2], - (sys*sys2).freqresp([0.1, 1.0, 10])[2]) + (f1*f2).frequency_response([0.1, 1.0, 10])[2], + (sys*sys2).frequency_response([0.1, 1.0, 10])[2]) def testAgainstOctave(self): # with data from octave: # sys = ss([-2 0 0; 0 -1 1; 0 0 -3], # [1 0; 0 0; 0 1], eye(3), zeros(3,2)) # bfr = frd(bsys, [1]) - sys = StateSpace(np.matrix('-2.0 0 0; 0 -1 1; 0 0 -3'), - np.matrix('1.0 0; 0 0; 0 1'), + sys = StateSpace(np.array([[-2.0, 0, 0], [0, -1, 1], [0, 0, -3]]), + np.array([[1.0, 0], [0, 0], [0, 1]]), np.eye(3), np.zeros((3, 2))) omega = np.logspace(-1, 2, 10) f1 = FRD(sys, omega) np.testing.assert_array_almost_equal( - (f1.freqresp([1.0])[0] * - np.exp(1j*f1.freqresp([1.0])[1])).reshape(3, 2), - np.matrix('0.4-0.2j 0; 0 0.1-0.2j; 0 0.3-0.1j')) + (f1.frequency_response([1.0])[0] * + np.exp(1j * f1.frequency_response([1.0])[1])).reshape(3, 2), + np.array([[0.4 - 0.2j, 0], [0, 0.1 - 0.2j], [0, 0.3 - 0.1j]])) - def test_string_representation(self): + def test_string_representation(self, capsys): sys = FRD([1, 2, 3], [4, 5, 6]) print(sys) # Just print without checking - def test_frequency_mismatch(self): + def test_frequency_mismatch(self, recwarn): + # recwarn: there may be a warning before the error! # Overlapping but non-equal frequency ranges sys1 = FRD([1, 2, 3], [4, 5, 6]) sys2 = FRD([2, 3, 4], [5, 6, 7]) - self.assertRaises(NotImplementedError, FRD.__add__, sys1, sys2) + with pytest.raises(NotImplementedError): + FRD.__add__(sys1, sys2) # One frequency range is a subset of another sys1 = FRD([1, 2, 3], [4, 5, 6]) sys2 = FRD([2, 3], [4, 5]) - self.assertRaises(NotImplementedError, FRD.__add__, sys1, sys2) + with pytest.raises(NotImplementedError): + FRD.__add__(sys1, sys2) def test_size_mismatch(self): sys1 = FRD(ct.rss(2, 2, 2), np.logspace(-1, 1, 10)) # Different number of inputs sys2 = FRD(ct.rss(3, 1, 2), np.logspace(-1, 1, 10)) - self.assertRaises(ValueError, FRD.__add__, sys1, sys2) + with pytest.raises(ValueError): + FRD.__add__(sys1, sys2) # Different number of outputs sys2 = FRD(ct.rss(3, 2, 1), np.logspace(-1, 1, 10)) - self.assertRaises(ValueError, FRD.__add__, sys1, sys2) + with pytest.raises(ValueError): + FRD.__add__(sys1, sys2) # Inputs and outputs don't match - self.assertRaises(ValueError, FRD.__mul__, sys2, sys1) + with pytest.raises(ValueError): + FRD.__mul__(sys2, sys1) # Feedback mismatch - self.assertRaises(ValueError, FRD.feedback, sys2, sys1) + with pytest.raises(ValueError): + FRD.feedback(sys2, sys1) def test_operator_conversion(self): sys_tf = ct.tf([1], [1, 2, 1]) @@ -365,7 +380,8 @@ def test_operator_conversion(self): np.testing.assert_array_almost_equal(sys_pow.fresp, chk_pow.fresp) # Assertion error if we try to raise to a non-integer power - self.assertRaises(ValueError, FRD.__pow__, frd_tf, 0.5) + with pytest.raises(ValueError): + FRD.__pow__(frd_tf, 0.5) # Selected testing on transfer function conversion sys_add = frd_2 + sys_tf @@ -375,44 +391,36 @@ def test_operator_conversion(self): # Input/output mismatch size mismatch in rmul sys1 = FRD(ct.rss(2, 2, 2), np.logspace(-1, 1, 10)) - self.assertRaises(ValueError, FRD.__rmul__, frd_2, sys1) + with pytest.raises(ValueError): + FRD.__rmul__(frd_2, sys1) # Make sure conversion of something random generates exception - self.assertRaises(TypeError, FRD.__add__, frd_tf, 'string') + with pytest.raises(TypeError): + FRD.__add__(frd_tf, 'string') def test_eval(self): sys_tf = ct.tf([1], [1, 2, 1]) frd_tf = FRD(sys_tf, np.logspace(-1, 1, 3)) - np.testing.assert_almost_equal(sys_tf.evalfr(1), frd_tf.eval(1)) + np.testing.assert_almost_equal(sys_tf(1j), frd_tf.eval(1)) + np.testing.assert_almost_equal(sys_tf(1j), frd_tf(1j)) # Should get an error if we evaluate at an unknown frequency - self.assertRaises(ValueError, frd_tf.eval, 2) + with pytest.raises(ValueError, match="not .* in frequency list"): + frd_tf.eval(2) - # This test only works in Python 3 due to a conflict with the same - # warning type in other test modules (frd_test.py). See - # https://bugs.python.org/issue4180 for more details - @unittest.skipIf(pysys.version_info < (3, 0), "test requires Python 3+") - def test_evalfr_deprecated(self): - sys_tf = ct.tf([1], [1, 2, 1]) - frd_tf = FRD(sys_tf, np.logspace(-1, 1, 3)) - - # Deprecated version of the call (should generate warning) - import warnings - with warnings.catch_warnings(): - # Make warnings generate an exception - warnings.simplefilter('error') - - # Make sure that we get a pending deprecation warning - self.assertRaises(PendingDeprecationWarning, frd_tf.evalfr, 1.) + # Should get an error if we evaluate at an complex number + with pytest.raises(ValueError, match="can only accept real-valued"): + frd_tf.eval(2 + 1j) - # FRD.evalfr() is being deprecated - import warnings - with warnings.catch_warnings(): - # Make warnings generate an exception - warnings.simplefilter('error') + # Should get an error if we use __call__ at real-valued frequency + with pytest.raises(ValueError, match="only accept purely imaginary"): + frd_tf(2) - # Make sure that we get a pending deprecation warning - self.assertRaises(PendingDeprecationWarning, frd_tf.evalfr, 1.) + 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): + frd_tf.freqresp(1.) def test_repr_str(self): # repr printing @@ -427,8 +435,8 @@ def test_repr_str(self): sysm = FrequencyResponseData( np.matmul(array([[1],[2]]), sys0.fresp), sys0.omega) - self.assertEqual(repr(sys0), ref0) - self.assertEqual(repr(sys1), ref1) + assert repr(sys0) == ref0 + assert repr(sys1) == ref1 sys0r = eval(repr(sys0)) np.testing.assert_array_almost_equal(sys0r.fresp, sys0.fresp) np.testing.assert_array_almost_equal(sys0r.omega, sys0.omega) @@ -444,8 +452,8 @@ def test_repr_str(self): 1.000 0.9 +0.1j 10.000 0.1 +2j 100.000 0.05 +3j""" - self.assertEqual(str(sys0), refs) - self.assertEqual(str(sys1), refs) + assert str(sys0) == refs + assert str(sys1) == refs # print multi-input system refm = """Frequency response data @@ -463,7 +471,4 @@ def test_repr_str(self): 1.000 1.8 +0.2j 10.000 0.2 +4j 100.000 0.1 +6j""" - self.assertEqual(str(sysm), refm) - -if __name__ == "__main__": - unittest.main() + assert str(sysm) == refm diff --git a/control/tests/freqresp_test.py b/control/tests/freqresp_test.py index 9d59a1972..321580ba7 100644 --- a/control/tests/freqresp_test.py +++ b/control/tests/freqresp_test.py @@ -1,241 +1,510 @@ -#!/usr/bin/env python -# -# freqresp_test.py - test frequency response functions -# RMM, 30 May 2016 (based on timeresp_test.py) -# -# This is a rudimentary set of tests for frequency response functions, -# including bode plots. - -import unittest +"""freqresp_test.py - test frequency response functions + +RMM, 30 May 2016 (based on timeresp_test.py) + +This is a rudimentary set of tests for frequency response functions, +including bode plots. +""" + import matplotlib.pyplot as plt import numpy as np -from numpy.testing import assert_array_almost_equal +from numpy.testing import assert_allclose +import math +import pytest import control as ctrl from control.statesp import StateSpace from control.xferfcn import TransferFunction from control.matlab import ss, tf, bode, rss -from control.exception import slycot_check - - -class TestFreqresp(unittest.TestCase): - def setUp(self): - self.A = np.matrix('1,1;0,1') - self.C = np.matrix('1,0') - self.omega = np.linspace(10e-2,10e2,1000) - - def test_siso(self): - B = np.matrix('0;1') - D = 0 - sys = StateSpace(self.A,B,self.C,D) - - # test frequency response - frq=sys.freqresp(self.omega) - - # test bode plot - bode(sys) - - # Convert to transfer function and test bode - systf = tf(sys) - bode(systf) - - def test_superimpose(self): - # Test to make sure that multiple calls to plots superimpose their - # data on the same axes unless told to do otherwise - - # Generate two plots in a row; should be on the same axes - plt.figure(1); plt.clf() - ctrl.bode_plot(ctrl.tf([1], [1,2,1])) - ctrl.bode_plot(ctrl.tf([5], [1, 1])) - - # Check to make sure there are two axes and that each axes has two lines - self.assertEqual(len(plt.gcf().axes), 2) - for ax in plt.gcf().axes: - # Make sure there are 2 lines in each subplot - assert len(ax.get_lines()) == 2 - - # Generate two plots as a list; should be on the same axes - plt.figure(2); plt.clf(); - ctrl.bode_plot([ctrl.tf([1], [1,2,1]), ctrl.tf([5], [1, 1])]) - - # Check to make sure there are two axes and that each axes has two lines - self.assertEqual(len(plt.gcf().axes), 2) - for ax in plt.gcf().axes: - # Make sure there are 2 lines in each subplot - assert len(ax.get_lines()) == 2 - - # Generate two separate plots; only the second should appear - plt.figure(3); plt.clf(); - ctrl.bode_plot(ctrl.tf([1], [1,2,1])) - plt.clf() - ctrl.bode_plot(ctrl.tf([5], [1, 1])) - - # Check to make sure there are two axes and that each axes has one line - self.assertEqual(len(plt.gcf().axes), 2) - for ax in plt.gcf().axes: - # Make sure there is only 1 line in the subplot - assert len(ax.get_lines()) == 1 - - # Now add a line to the magnitude plot and make sure if is there - for ax in plt.gcf().axes: - if ax.get_label() == 'control-bode-magnitude': +from control.freqplot import bode_plot, nyquist_plot +from control.tests.conftest import slycotonly + +pytestmark = pytest.mark.usefixtures("mplcleanup") + + +@pytest.fixture +def ss_siso(): + A = np.array([[1, 1], [0, 1]]) + B = np.array([[0], [1]]) + C = np.array([[1, 0]]) + D = 0 + return StateSpace(A, B, C, D) + + +@pytest.fixture +def ss_mimo(): + A = np.array([[1, 1], [0, 1]]) + B = np.array([[1, 0], [0, 1]]) + C = np.array([[1, 0]]) + D = np.array([[0, 0]]) + return StateSpace(A, B, C, D) + +def test_freqresp_siso(ss_siso): + """Test SISO frequency response""" + omega = np.linspace(10e-2, 10e2, 1000) + + # test frequency response + ctrl.freqresp(ss_siso, omega) + + +@slycotonly +def test_freqresp_mimo(ss_mimo): + """Test MIMO frequency response calls""" + omega = np.linspace(10e-2, 10e2, 1000) + ctrl.freqresp(ss_mimo, omega) + tf_mimo = tf(ss_mimo) + ctrl.freqresp(tf_mimo, omega) + + +def test_bode_basic(ss_siso): + """Test bode plot call (Very basic)""" + # TODO: proper test + tf_siso = tf(ss_siso) + bode(ss_siso) + bode(tf_siso) + assert len(bode_plot(tf_siso, plot=False, omega_num=20)[0] == 20) + omega = bode_plot(tf_siso, plot=False, omega_limits=(1, 100))[2] + assert_allclose(omega[0], 1) + assert_allclose(omega[-1], 100) + assert len(bode_plot(tf_siso, plot=False, omega=np.logspace(-1,1,10))[0])\ + == 10 + +def test_nyquist_basic(ss_siso): + """Test nyquist plot call (Very basic)""" + # TODO: proper test + tf_siso = tf(ss_siso) + nyquist_plot(ss_siso) + nyquist_plot(tf_siso) + count, contour = nyquist_plot( + tf_siso, plot=False, return_contour=True, omega_num=20) + assert len(contour) == 20 + + count, contour = nyquist_plot( + tf_siso, plot=False, omega_limits=(1, 100), return_contour=True) + assert_allclose(contour[0], 1j) + assert_allclose(contour[-1], 100j) + + count, contour = nyquist_plot( + tf_siso, plot=False, omega=np.logspace(-1, 1, 10), return_contour=True) + assert len(contour) == 10 + + +@pytest.mark.filterwarnings("ignore:.*non-positive left xlim:UserWarning") +def test_superimpose(): + """Test superimpose multiple calls. + + Test to make sure that multiple calls to plots superimpose their + data on the same axes unless told to do otherwise + """ + # Generate two plots in a row; should be on the same axes + plt.figure(1) + plt.clf() + ctrl.bode_plot(ctrl.tf([1], [1, 2, 1])) + ctrl.bode_plot(ctrl.tf([5], [1, 1])) + + # Check that there are two axes and that each axes has two lines + len(plt.gcf().axes) == 2 + for ax in plt.gcf().axes: + # Make sure there are 2 lines in each subplot + assert len(ax.get_lines()) == 2 + + # Generate two plots as a list; should be on the same axes + plt.figure(2) + plt.clf() + ctrl.bode_plot([ctrl.tf([1], [1, 2, 1]), ctrl.tf([5], [1, 1])]) + + # Check that there are two axes and that each axes has two lines + assert len(plt.gcf().axes) == 2 + for ax in plt.gcf().axes: + # Make sure there are 2 lines in each subplot + assert len(ax.get_lines()) == 2 + + # Generate two separate plots; only the second should appear + plt.figure(3) + plt.clf() + ctrl.bode_plot(ctrl.tf([1], [1, 2, 1])) + plt.clf() + ctrl.bode_plot(ctrl.tf([5], [1, 1])) + + # Check to make sure there are two axes and that each axes has one line + assert len(plt.gcf().axes) == 2 + for ax in plt.gcf().axes: + # Make sure there is only 1 line in the subplot + assert len(ax.get_lines()) == 1 + + # Now add a line to the magnitude plot and make sure if is there + for ax in plt.gcf().axes: + if ax.get_label() == 'control-bode-magnitude': break - ax.semilogx([1e-2, 1e1], 20 * np.log10([1, 1]), 'k-') - self.assertEqual(len(ax.get_lines()), 2) - - def test_doubleint(self): - # 30 May 2016, RMM: added to replicate typecast bug in freqresp.py - A = np.matrix('0, 1; 0, 0'); - B = np.matrix('0; 1'); - C = np.matrix('1, 0'); - D = 0; - sys = ss(A, B, C, D); - bode(sys); - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def test_mimo(self): - # MIMO - B = np.matrix('1,0;0,1') - D = np.matrix('0,0') - sysMIMO = ss(self.A,B,self.C,D) - - frqMIMO = sysMIMO.freqresp(self.omega) - tfMIMO = tf(sysMIMO) - - #bode(sysMIMO) # - should throw not implemented exception - #bode(tfMIMO) # - should throw not implemented exception - - #plt.figure(3) - #plt.semilogx(self.omega,20*np.log10(np.squeeze(frq[0]))) - - #plt.figure(4) - #bode(sysMIMO,self.omega) - - def test_bode_margin(self): - num = [1000] - den = [1, 25, 100, 0] - sys = ctrl.tf(num, den) - plt.figure() - ctrl.bode_plot(sys, margins=True,dB=False,deg = True, Hz=False) - fig = plt.gcf() - allaxes = fig.get_axes() - - mag_to_infinity = (np.array([6.07828691, 6.07828691]), - np.array([1.00000000e+00, 1.00000000e-08])) - assert_array_almost_equal(mag_to_infinity, allaxes[0].lines[2].get_data()) - - gm_to_infinty = (np.array([10., 10.]), np.array([4.00000000e-01, 1.00000000e-08])) - assert_array_almost_equal(gm_to_infinty, allaxes[0].lines[3].get_data()) - - one_to_gm = (np.array([10., 10.]), np.array([1., 0.4])) - assert_array_almost_equal(one_to_gm, allaxes[0].lines[4].get_data()) - - pm_to_infinity = (np.array([6.07828691, 6.07828691]), - np.array([100000., -157.46405841])) - assert_array_almost_equal(pm_to_infinity, allaxes[1].lines[2].get_data()) - - pm_to_phase = (np.array([6.07828691, 6.07828691]), np.array([-157.46405841, -180.])) - assert_array_almost_equal(pm_to_phase, allaxes[1].lines[3].get_data()) - - phase_to_infinity = (np.array([10., 10.]), np.array([1.00000000e-08, -1.80000000e+02])) - assert_array_almost_equal(phase_to_infinity, allaxes[1].lines[4].get_data()) - - def test_discrete(self): - # Test discrete time frequency response - - # SISO state space systems with either fixed or unspecified sampling times - sys = rss(3, 1, 1) - siso_ss1d = StateSpace(sys.A, sys.B, sys.C, sys.D, 0.1) - siso_ss2d = StateSpace(sys.A, sys.B, sys.C, sys.D, True) - - # MIMO state space systems with either fixed or unspecified sampling times - A = [[-3., 4., 2.], [-1., -3., 0.], [2., 5., 3.]] - B = [[1., 4.], [-3., -3.], [-2., 1.]] - C = [[4., 2., -3.], [1., 4., 3.]] - D = [[-2., 4.], [0., 1.]] - mimo_ss1d = StateSpace(A, B, C, D, 0.1) - mimo_ss2d = StateSpace(A, B, C, D, True) - - # SISO transfer functions - siso_tf1d = TransferFunction([1, 1], [1, 2, 1], 0.1) - siso_tf2d = TransferFunction([1, 1], [1, 2, 1], True) - - # Go through each system and call the code, checking return types - for sys in (siso_ss1d, siso_ss2d, mimo_ss1d, mimo_ss2d, - siso_tf1d, siso_tf2d): - # Set frequency range to just below Nyquist freq (for Bode) - omega_ok = np.linspace(10e-4,0.99,100) * np.pi/sys.dt - - # Test frequency response - ret = sys.freqresp(omega_ok) - - # Check for warning if frequency is out of range - import warnings - warnings.simplefilter('always', UserWarning) # don't supress - with warnings.catch_warnings(record=True) as w: - # Set up warnings filter to only show warnings in control module - warnings.filterwarnings("ignore") - warnings.filterwarnings("always", module="control") - - # Look for a warning about sampling above Nyquist frequency - omega_bad = np.linspace(10e-4,1.1,10) * np.pi/sys.dt - ret = sys.freqresp(omega_bad) - print("len(w) =", len(w)) - self.assertEqual(len(w), 1) - self.assertIn("above", str(w[-1].message)) - self.assertIn("Nyquist", str(w[-1].message)) - - # Test bode plots (currently only implemented for SISO) - if (sys.inputs == 1 and sys.outputs == 1): - # Generic call (frequency range calculated automatically) - ret_ss = bode(sys) - - # Convert to transfer function and test bode again - systf = tf(sys); - ret_tf = bode(systf) - - # Make sure we can pass a frequency range - bode(sys, omega_ok) - - else: - # Calling bode should generate a not implemented error - self.assertRaises(NotImplementedError, bode, (sys,)) - - def test_options(self): - """Test ability to set parameter values""" - # Generate a Bode plot of a transfer function - sys = ctrl.tf([1000], [1, 25, 100, 0]) - fig1 = plt.figure() - ctrl.bode_plot(sys, dB=False, deg = True, Hz=False) - - # Save the parameter values - left1, right1 = fig1.axes[0].xaxis.get_data_interval() - numpoints1 = len(fig1.axes[0].lines[0].get_data()[0]) - - # Same transfer function, but add a decade on each end - ctrl.config.set_defaults('freqplot', feature_periphery_decades=2) - fig2 = plt.figure() - ctrl.bode_plot(sys, dB=False, deg = True, Hz=False) - left2, right2 = fig2.axes[0].xaxis.get_data_interval() - - # Make sure we got an extra decade on each end - self.assertAlmostEqual(left2, 0.1 * left1) - self.assertAlmostEqual(right2, 10 * right1) - - # Same transfer function, but add more points to the plot - ctrl.config.set_defaults( - 'freqplot', feature_periphery_decades=2, number_of_samples=13) - fig3 = plt.figure() - ctrl.bode_plot(sys, dB=False, deg = True, Hz=False) - numpoints3 = len(fig3.axes[0].lines[0].get_data()[0]) - - # Make sure we got the right number of points - self.assertNotEqual(numpoints1, numpoints3) - self.assertEqual(numpoints3, 13) - - # Reset default parameters to avoid contamination - ctrl.config.reset_defaults() - - -if __name__ == '__main__': - unittest.main() + + ax.semilogx([1e-2, 1e1], 20 * np.log10([1, 1]), 'k-') + assert len(ax.get_lines()) == 2 + + +def test_doubleint(): + """Test typcast bug with double int + + 30 May 2016, RMM: added to replicate typecast bug in frequency_response.py + """ + A = np.array([[0, 1], [0, 0]]) + B = np.array([[0], [1]]) + C = np.array([[1, 0]]) + D = 0 + sys = ss(A, B, C, D) + bode(sys) + + +@pytest.mark.parametrize( + "Hz, Wcp, Wcg", + [pytest.param(False, 6.0782869, 10., id="omega"), + pytest.param(True, 0.9673894, 1.591549, id="Hz")]) +@pytest.mark.parametrize( + "deg, p0, pm", + [pytest.param(False, -np.pi, -2.748266, id="rad"), + pytest.param(True, -180, -157.46405841, id="deg")]) +@pytest.mark.parametrize( + "dB, maginfty1, maginfty2, gminv", + [pytest.param(False, 1, 1e-8, 0.4, id="mag"), + pytest.param(True, 0, -1e+5, -7.9588, id="dB")]) +def test_bode_margin(dB, maginfty1, maginfty2, gminv, + deg, p0, pm, + Hz, Wcp, Wcg): + """Test bode margins""" + num = [1000] + den = [1, 25, 100, 0] + sys = ctrl.tf(num, den) + plt.figure() + ctrl.bode_plot(sys, margins=True, dB=dB, deg=deg, Hz=Hz) + fig = plt.gcf() + allaxes = fig.get_axes() + + mag_to_infinity = (np.array([Wcp, Wcp]), + np.array([maginfty1, maginfty2])) + assert_allclose(mag_to_infinity, + allaxes[0].lines[2].get_data(), + rtol=1e-5) + + gm_to_infinty = (np.array([Wcg, Wcg]), + np.array([gminv, maginfty2])) + assert_allclose(gm_to_infinty, + allaxes[0].lines[3].get_data(), + rtol=1e-5) + + one_to_gm = (np.array([Wcg, Wcg]), + np.array([maginfty1, gminv])) + assert_allclose(one_to_gm, allaxes[0].lines[4].get_data(), + rtol=1e-5) + + pm_to_infinity = (np.array([Wcp, Wcp]), + np.array([1e5, pm])) + assert_allclose(pm_to_infinity, + allaxes[1].lines[2].get_data(), + rtol=1e-5) + + pm_to_phase = (np.array([Wcp, Wcp]), + np.array([pm, p0])) + assert_allclose(pm_to_phase, allaxes[1].lines[3].get_data(), + rtol=1e-5) + + phase_to_infinity = (np.array([Wcg, Wcg]), + np.array([0, p0])) + assert_allclose(phase_to_infinity, allaxes[1].lines[4].get_data(), + rtol=1e-5) + + +@pytest.fixture +def dsystem_dt(request): + """Test systems for test_discrete""" + # SISO state space systems with either fixed or unspecified sampling times + sys = rss(3, 1, 1) + + # MIMO state space systems with either fixed or unspecified sampling times + A = [[-3., 4., 2.], [-1., -3., 0.], [2., 5., 3.]] + B = [[1., 4.], [-3., -3.], [-2., 1.]] + C = [[4., 2., -3.], [1., 4., 3.]] + D = [[-2., 4.], [0., 1.]] + + dt = request.param + systems = {'sssiso': StateSpace(sys.A, sys.B, sys.C, sys.D, dt), + 'ssmimo': StateSpace(A, B, C, D, dt), + 'tf': TransferFunction([2, 1], [2, 1, 1], dt)} + return systems + + +@pytest.fixture +def dsystem_type(request, dsystem_dt): + """Return system by typekey""" + systype = request.param + return dsystem_dt[systype] + + +@pytest.mark.parametrize("dsystem_dt", [0.1, True], indirect=True) +@pytest.mark.parametrize("dsystem_type", ['sssiso', 'ssmimo', 'tf'], + indirect=True) +def test_discrete(dsystem_type): + """Test discrete time frequency response""" + dsys = dsystem_type + # Set frequency range to just below Nyquist freq (for Bode) + omega_ok = np.linspace(10e-4, 0.99, 100) * np.pi / dsys.dt + + # Test frequency response + dsys.frequency_response(omega_ok) + + # Check for warning if frequency is out of range + with pytest.warns(UserWarning, match="above.*Nyquist"): + # Look for a warning about sampling above Nyquist frequency + omega_bad = np.linspace(10e-4, 1.1, 10) * np.pi / dsys.dt + dsys.frequency_response(omega_bad) + + # Test bode plots (currently only implemented for SISO) + if (dsys.ninputs == 1 and dsys.noutputs == 1): + # Generic call (frequency range calculated automatically) + bode(dsys) + + # Convert to transfer function and test bode again + systf = tf(dsys) + bode(systf) + + # Make sure we can pass a frequency range + bode(dsys, omega_ok) + + else: + # Calling bode should generate a not implemented error + with pytest.raises(NotImplementedError): + bode((dsys,)) + + +def test_options(editsdefaults): + """Test ability to set parameter values""" + # Generate a Bode plot of a transfer function + sys = ctrl.tf([1000], [1, 25, 100, 0]) + fig1 = plt.figure() + ctrl.bode_plot(sys, dB=False, deg=True, Hz=False) + + # Save the parameter values + left1, right1 = fig1.axes[0].xaxis.get_data_interval() + numpoints1 = len(fig1.axes[0].lines[0].get_data()[0]) + + # Same transfer function, but add a decade on each end + ctrl.config.set_defaults('freqplot', feature_periphery_decades=2) + fig2 = plt.figure() + ctrl.bode_plot(sys, dB=False, deg=True, Hz=False) + left2, right2 = fig2.axes[0].xaxis.get_data_interval() + + # Make sure we got an extra decade on each end + assert_allclose(left2, 0.1 * left1) + assert_allclose(right2, 10 * right1) + + # Same transfer function, but add more points to the plot + ctrl.config.set_defaults( + 'freqplot', feature_periphery_decades=2, number_of_samples=13) + fig3 = plt.figure() + ctrl.bode_plot(sys, dB=False, deg=True, Hz=False) + numpoints3 = len(fig3.axes[0].lines[0].get_data()[0]) + + # Make sure we got the right number of points + assert numpoints1 != numpoints3 + assert numpoints3 == 13 + +@pytest.mark.parametrize( + "TF, initial_phase, default_phase, expected_phase", + [pytest.param(ctrl.tf([1], [1, 0]), + None, -math.pi/2, -math.pi/2, id="order1, default"), + pytest.param(ctrl.tf([1], [1, 0]), + 180, -math.pi/2, 3*math.pi/2, id="order1, 180"), + pytest.param(ctrl.tf([1], [1, 0, 0]), + None, -math.pi, -math.pi, id="order2, default"), + pytest.param(ctrl.tf([1], [1, 0, 0]), + 180, -math.pi, math.pi, id="order2, 180"), + pytest.param(ctrl.tf([1], [1, 0, 0, 0]), + None, -3*math.pi/2, -3*math.pi/2, id="order2, default"), + pytest.param(ctrl.tf([1], [1, 0, 0, 0]), + 180, -3*math.pi/2, math.pi/2, id="order2, 180"), + pytest.param(ctrl.tf([1], [1, 0, 0, 0, 0]), + None, 0, 0, id="order4, default"), + pytest.param(ctrl.tf([1], [1, 0, 0, 0, 0]), + 180, 0, 0, id="order4, 180"), + pytest.param(ctrl.tf([1], [1, 0, 0, 0, 0]), + -360, 0, -2*math.pi, id="order4, -360"), + ]) +def test_initial_phase(TF, initial_phase, default_phase, expected_phase): + # Check initial phase of standard transfer functions + mag, phase, omega = ctrl.bode(TF) + assert(abs(phase[0] - default_phase) < 0.1) + + # Now reset the initial phase to +180 and see if things work + mag, phase, omega = ctrl.bode(TF, initial_phase=initial_phase) + assert(abs(phase[0] - expected_phase) < 0.1) + + # Make sure everything works in rad/sec as well + if initial_phase: + plt.xscale('linear') # avoids xlim warning on next line + plt.clf() # clear previous figure (speeds things up) + mag, phase, omega = ctrl.bode( + TF, initial_phase=initial_phase/180. * math.pi, deg=False) + assert(abs(phase[0] - expected_phase) < 0.1) + + +@pytest.mark.parametrize( + "TF, wrap_phase, min_phase, max_phase", + [pytest.param(ctrl.tf([1], [1, 0]), + None, -math.pi/2, 0, id="order1, default"), + pytest.param(ctrl.tf([1], [1, 0]), + True, -math.pi, math.pi, id="order1, True"), + pytest.param(ctrl.tf([1], [1, 0]), + -270, -3*math.pi/2, math.pi/2, id="order1, -270"), + pytest.param(ctrl.tf([1], [1, 0, 0, 0]), + None, -3*math.pi/2, 0, id="order3, default"), + pytest.param(ctrl.tf([1], [1, 0, 0, 0]), + True, -math.pi, math.pi, id="order3, True"), + pytest.param(ctrl.tf([1], [1, 0, 0, 0]), + -270, -3*math.pi/2, math.pi/2, id="order3, -270"), + pytest.param(ctrl.tf([1], [1, 0, 0, 0, 0, 0]), + True, -3*math.pi/2, 0, id="order5, default"), + pytest.param(ctrl.tf([1], [1, 0, 0, 0, 0, 0]), + True, -math.pi, math.pi, id="order5, True"), + pytest.param(ctrl.tf([1], [1, 0, 0, 0, 0, 0]), + -270, -3*math.pi/2, math.pi/2, id="order5, -270"), + ]) + +def test_phase_wrap(TF, wrap_phase, min_phase, max_phase): + mag, phase, omega = ctrl.bode(TF, wrap_phase=wrap_phase) + assert(min(phase) >= min_phase) + assert(max(phase) <= max_phase) + + +def test_freqresp_warn_infinite(): + """Test evaluation warnings for transfer functions w/ pole at the origin""" + sys_finite = ctrl.tf([1], [1, 0.01]) + sys_infinite = ctrl.tf([1], [1, 0.01, 0]) + + # Transfer function with finite zero frequency gain + np.testing.assert_almost_equal(sys_finite(0), 100) + np.testing.assert_almost_equal(sys_finite(0, warn_infinite=False), 100) + np.testing.assert_almost_equal(sys_finite(0, warn_infinite=True), 100) + + # Transfer function with infinite zero frequency gain + with pytest.warns(RuntimeWarning, match="divide by zero"): + np.testing.assert_almost_equal( + sys_infinite(0), complex(np.inf, np.nan)) + with pytest.warns(RuntimeWarning, match="divide by zero"): + np.testing.assert_almost_equal( + sys_infinite(0, warn_infinite=True), complex(np.inf, np.nan)) + np.testing.assert_almost_equal( + sys_infinite(0, warn_infinite=False), complex(np.inf, np.nan)) + + # Switch to state space + sys_finite = ctrl.tf2ss(sys_finite) + sys_infinite = ctrl.tf2ss(sys_infinite) + + # State space system with finite zero frequency gain + np.testing.assert_almost_equal(sys_finite(0), 100) + np.testing.assert_almost_equal(sys_finite(0, warn_infinite=False), 100) + np.testing.assert_almost_equal(sys_finite(0, warn_infinite=True), 100) + + # State space system with infinite zero frequency gain + with pytest.warns(RuntimeWarning, match="singular matrix"): + np.testing.assert_almost_equal( + sys_infinite(0), complex(np.inf, np.nan)) + with pytest.warns(RuntimeWarning, match="singular matrix"): + np.testing.assert_almost_equal( + sys_infinite(0, warn_infinite=True), complex(np.inf, np.nan)) + np.testing.assert_almost_equal(sys_infinite( + 0, warn_infinite=False), complex(np.inf, np.nan)) + + +def test_dcgain_consistency(): + """Test to make sure that DC gain is consistently evaluated""" + # Set up transfer function with pole at the origin + sys_tf = ctrl.tf([1], [1, 0]) + assert 0 in sys_tf.pole() + + # Set up state space system with pole at the origin + sys_ss = ctrl.tf2ss(sys_tf) + assert 0 in sys_ss.pole() + + # Finite (real) numerator over 0 denominator => inf + nanj + np.testing.assert_equal( + sys_tf(0, warn_infinite=False), complex(np.inf, np.nan)) + np.testing.assert_equal( + sys_ss(0, warn_infinite=False), complex(np.inf, np.nan)) + np.testing.assert_equal( + sys_tf(0j, warn_infinite=False), complex(np.inf, np.nan)) + np.testing.assert_equal( + sys_ss(0j, warn_infinite=False), complex(np.inf, np.nan)) + np.testing.assert_equal( + sys_tf.dcgain(), np.inf) + np.testing.assert_equal( + sys_ss.dcgain(), np.inf) + + # Set up transfer function with pole, zero at the origin + sys_tf = ctrl.tf([1, 0], [1, 0]) + assert 0 in sys_tf.pole() + assert 0 in sys_tf.zero() + + # Pole and zero at the origin should give nan + nanj for the response + np.testing.assert_equal( + sys_tf(0, warn_infinite=False), complex(np.nan, np.nan)) + np.testing.assert_equal( + sys_tf(0j, warn_infinite=False), complex(np.nan, np.nan)) + np.testing.assert_equal( + sys_tf.dcgain(), np.nan) + + # Set up state space version + sys_ss = ctrl.tf2ss(ctrl.tf([1, 0], [1, 1])) * \ + ctrl.tf2ss(ctrl.tf([1], [1, 0])) + + # Different systems give different representations => test accordingly + if 0 in sys_ss.pole() and 0 in sys_ss.zero(): + # Pole and zero at the origin => should get (nan + nanj) + np.testing.assert_equal( + sys_ss(0, warn_infinite=False), complex(np.nan, np.nan)) + np.testing.assert_equal( + sys_ss(0j, warn_infinite=False), complex(np.nan, np.nan)) + np.testing.assert_equal( + sys_ss.dcgain(), np.nan) + elif 0 in sys_ss.pole(): + # Pole at the origin, but zero elsewhere => should get (inf + nanj) + np.testing.assert_equal( + sys_ss(0, warn_infinite=False), complex(np.inf, np.nan)) + np.testing.assert_equal( + sys_ss(0j, warn_infinite=False), complex(np.inf, np.nan)) + np.testing.assert_equal( + sys_ss.dcgain(), np.inf) + else: + # Near pole/zero cancellation => nothing sensible to check + pass + + # Pole with non-zero, complex numerator => inf + infj + s = ctrl.tf('s') + sys_tf = (s + 1) / (s**2 + 1) + assert 1j in sys_tf.pole() + + # Set up state space system with pole on imaginary axis + sys_ss = ctrl.tf2ss(sys_tf) + assert 1j in sys_tf.pole() + + # Make sure we get correct response if evaluated at the pole + np.testing.assert_equal( + sys_tf(1j, warn_infinite=False), complex(np.inf, np.inf)) + + # For state space, numerical errors come into play + resp_ss = sys_ss(1j, warn_infinite=False) + if np.isfinite(resp_ss): + assert abs(resp_ss) > 1e15 + else: + if resp_ss != complex(np.inf, np.inf): + pytest.xfail("statesp evaluation at poles not fully implemented") + else: + np.testing.assert_equal(resp_ss, complex(np.inf, np.inf)) + + # DC gain is finite + np.testing.assert_almost_equal(sys_tf.dcgain(), 1.) + np.testing.assert_almost_equal(sys_ss.dcgain(), 1.) + + # Make sure that we get the *signed* DC gain + sys_tf = -1 / (s + 1) + np.testing.assert_almost_equal(sys_tf.dcgain(), -1) + + sys_ss = ctrl.tf2ss(sys_tf) + np.testing.assert_almost_equal(sys_ss.dcgain(), -1) diff --git a/control/tests/input_element_int_test.py b/control/tests/input_element_int_test.py index c6a6f64a3..5b3b801c6 100644 --- a/control/tests/input_element_int_test.py +++ b/control/tests/input_element_int_test.py @@ -1,54 +1,66 @@ -# input_element_int_test.py -# -# Author: Kangwon Lee (kangwonlee) -# Date: 22 Oct 2017 -# -# Unit tests contributed as part of PR #158, "SISO tf() may not work -# with numpy arrays with numpy.int elements" -# -# Modified: -# * 29 Dec 2017, RMM - updated file name and added header - -import unittest +"""input_element_int_test.py + +Author: Kangwon Lee (kangwonlee) +Date: 22 Oct 2017 + +Modified: +* 29 Dec 2017, RMM - updated file name and added header +""" + import numpy as np -import control as ctl +from control import dcgain, ss, tf + +class TestTfInputIntElement: + """input_element_int_test + + Unit tests contributed as part of PR gh-158, "SISO tf() may not work + with numpy arrays with numpy.int elements + """ -class TestTfInputIntElement(unittest.TestCase): - # currently these do not pass def test_tf_den_with_numpy_int_element(self): num = 1 den = np.convolve([1, 2, 1], [1, 1, 1]) - sys = ctl.tf(num, den) + sys = tf(num, den) - self.assertAlmostEqual(1.0, ctl.dcgain(sys)) + np.testing.assert_almost_equal(1., dcgain(sys)) def test_tf_num_with_numpy_int_element(self): num = np.convolve([1], [1, 1]) den = np.convolve([1, 2, 1], [1, 1, 1]) - sys = ctl.tf(num, den) + sys = tf(num, den) - self.assertAlmostEqual(1.0, ctl.dcgain(sys)) + np.testing.assert_almost_equal(1., dcgain(sys)) # currently these pass - def test_tf_input_with_int_element_works(self): + def test_tf_input_with_int_element(self): num = 1 den = np.convolve([1.0, 2, 1], [1, 1, 1]) - sys = ctl.tf(num, den) + sys = tf(num, den) - self.assertAlmostEqual(1.0, ctl.dcgain(sys)) + np.testing.assert_almost_equal(1., dcgain(sys)) def test_ss_input_with_int_element(self): - ident = np.matrix(np.identity(2), dtype=int) - a = np.matrix([[0, 1], - [-1, -2]], dtype=int) * ident - b = np.matrix([[0], + a = np.array([[0, 1], + [-1, -2]], dtype=int) + b = np.array([[0], [1]], dtype=int) - c = np.matrix([[0, 1]], dtype=int) - d = 0 + c = np.array([[0, 1]], dtype=int) + d = np.array([[1]], dtype=int) + + sys = ss(a, b, c, d) + sys2 = tf(sys) + np.testing.assert_almost_equal(dcgain(sys), dcgain(sys2)) - sys = ctl.ss(a, b, c, d) - sys2 = ctl.ss2tf(sys) - self.assertAlmostEqual(ctl.dcgain(sys), ctl.dcgain(sys2)) + def test_ss_input_with_0int_dcgain(self): + a = np.array([[0, 1], + [-1, -2]], dtype=int) + b = np.array([[0], + [1]], dtype=int) + c = np.array([[0, 1]], dtype=int) + d = 0 + sys = ss(a, b, c, d) + np.testing.assert_allclose(dcgain(sys), 0, + atol=np.finfo(float).epsneg) diff --git a/control/tests/interconnect_test.py b/control/tests/interconnect_test.py new file mode 100644 index 000000000..302c45278 --- /dev/null +++ b/control/tests/interconnect_test.py @@ -0,0 +1,212 @@ +"""interconnect_test.py - test input/output interconnect function + +RMM, 22 Jan 2021 + +This set of unit tests covers the various operatons of the interconnect() +function, as well as some of the support functions associated with +interconnect(). + +Note: additional tests are available in iosys_test.py, which focuses on the +raw InterconnectedSystem constructor. This set of unit tests focuses on +functionality implemented in the interconnect() function itself. + +""" + +import pytest + +import numpy as np +import scipy as sp + +import control as ct + +@pytest.mark.parametrize("inputs, output, dimension, D", [ + [1, 1, None, [[1]] ], + ['u', 'y', None, [[1]] ], + [['u'], ['y'], None, [[1]] ], + [2, 1, None, [[1, 1]] ], + [['r', '-y'], ['e'], None, [[1, -1]] ], + [5, 1, None, np.ones((1, 5)) ], + ['u', 'y', 1, [[1]] ], + ['u', 'y', 2, [[1, 0], [0, 1]] ], + [['r', '-y'], ['e'], 2, [[1, 0, -1, 0], [0, 1, 0, -1]] ], +]) +def test_summing_junction(inputs, output, dimension, D): + ninputs = 1 if isinstance(inputs, str) else \ + inputs if isinstance(inputs, int) else len(inputs) + sum = ct.summing_junction( + inputs=inputs, output=output, dimension=dimension) + dim = 1 if dimension is None else dimension + np.testing.assert_array_equal(sum.A, np.ndarray((0, 0))) + np.testing.assert_array_equal(sum.B, np.ndarray((0, ninputs*dim))) + np.testing.assert_array_equal(sum.C, np.ndarray((dim, 0))) + np.testing.assert_array_equal(sum.D, D) + + +def test_summation_exceptions(): + # Bad input description + with pytest.raises(ValueError, match="could not parse input"): + sumblk = ct.summing_junction(np.pi, 'y') + + # Bad output description + with pytest.raises(ValueError, match="could not parse output"): + sumblk = ct.summing_junction('u', np.pi) + + # Bad input dimension + with pytest.raises(ValueError, match="unrecognized dimension"): + sumblk = ct.summing_junction('u', 'y', dimension=False) + + +def test_interconnect_implicit(): + """Test the use of implicit connections in interconnect()""" + import random + + # System definition + P = ct.ss2io( + ct.rss(2, 1, 1, strictly_proper=True), + inputs='u', outputs='y', name='P') + kp = ct.tf(random.uniform(1, 10), [1]) + ki = ct.tf(random.uniform(1, 10), [1, 0]) + C = ct.tf2io(kp + ki, inputs='e', outputs='u', name='C') + + # Block diagram computation + Tss = ct.feedback(P * C, 1) + + # Construct the interconnection explicitly + Tio_exp = ct.interconnect( + (C, P), + connections = [['P.u', 'C.u'], ['C.e', '-P.y']], + inplist='C.e', outlist='P.y') + + # Compare to bdalg computation + np.testing.assert_almost_equal(Tio_exp.A, Tss.A) + np.testing.assert_almost_equal(Tio_exp.B, Tss.B) + np.testing.assert_almost_equal(Tio_exp.C, Tss.C) + np.testing.assert_almost_equal(Tio_exp.D, Tss.D) + + # Construct the interconnection via a summing junction + sumblk = ct.summing_junction(inputs=['r', '-y'], output='e', name="sum") + Tio_sum = ct.interconnect( + (C, P, sumblk), inplist=['r'], outlist=['y']) + + np.testing.assert_almost_equal(Tio_sum.A, Tss.A) + np.testing.assert_almost_equal(Tio_sum.B, Tss.B) + np.testing.assert_almost_equal(Tio_sum.C, Tss.C) + np.testing.assert_almost_equal(Tio_sum.D, Tss.D) + + # Setting connections to False should lead to an empty connection map + empty = ct.interconnect( + (C, P, sumblk), connections=False, inplist=['r'], outlist=['y']) + np.testing.assert_array_equal(empty.connect_map, np.zeros((4, 3))) + + # Implicit summation across repeated signals + kp_io = ct.tf2io(kp, inputs='e', outputs='u', name='kp') + ki_io = ct.tf2io(ki, inputs='e', outputs='u', name='ki') + Tio_sum = ct.interconnect( + (kp_io, ki_io, P, sumblk), inplist=['r'], outlist=['y']) + np.testing.assert_almost_equal(Tio_sum.A, Tss.A) + np.testing.assert_almost_equal(Tio_sum.B, Tss.B) + np.testing.assert_almost_equal(Tio_sum.C, Tss.C) + np.testing.assert_almost_equal(Tio_sum.D, Tss.D) + + # TODO: interconnect a MIMO system using implicit connections + # P = control.ss2io( + # control.rss(2, 2, 2, strictly_proper=True), + # input_prefix='u', output_prefix='y', name='P') + # C = control.ss2io( + # control.rss(2, 2, 2), + # input_prefix='e', output_prefix='u', name='C') + # sumblk = control.summing_junction( + # inputs=['r', '-y'], output='e', dimension=2) + # S = control.interconnect([P, C, sumblk], inplist='r', outlist='y') + + # Make sure that repeated inplist/outlist names work + pi_io = ct.interconnect( + (kp_io, ki_io), inplist=['e'], outlist=['u']) + pi_ss = ct.tf2ss(kp + ki) + np.testing.assert_almost_equal(pi_io.A, pi_ss.A) + np.testing.assert_almost_equal(pi_io.B, pi_ss.B) + np.testing.assert_almost_equal(pi_io.C, pi_ss.C) + np.testing.assert_almost_equal(pi_io.D, pi_ss.D) + + # Default input and output lists, along with singular versions + Tio_sum = ct.interconnect( + (kp_io, ki_io, P, sumblk), input='r', output='y') + np.testing.assert_almost_equal(Tio_sum.A, Tss.A) + np.testing.assert_almost_equal(Tio_sum.B, Tss.B) + np.testing.assert_almost_equal(Tio_sum.C, Tss.C) + np.testing.assert_almost_equal(Tio_sum.D, Tss.D) + + # Signal not found + with pytest.raises(ValueError, match="could not find"): + Tio_sum = ct.interconnect( + (C, P, sumblk), inplist=['x'], outlist=['y']) + + with pytest.raises(ValueError, match="could not find"): + Tio_sum = ct.interconnect( + (C, P, sumblk), inplist=['r'], outlist=['x']) + +def test_interconnect_docstring(): + """Test the examples from the interconnect() docstring""" + + # MIMO interconnection (note: use [C, P] instead of [P, C] for state order) + P = ct.LinearIOSystem( + ct.rss(2, 2, 2, strictly_proper=True), name='P') + C = ct.LinearIOSystem(ct.rss(2, 2, 2), name='C') + T = ct.interconnect( + [C, P], + connections = [ + ['P.u[0]', 'C.y[0]'], ['P.u[1]', 'C.y[1]'], + ['C.u[0]', '-P.y[0]'], ['C.u[1]', '-P.y[1]']], + inplist = ['C.u[0]', 'C.u[1]'], + outlist = ['P.y[0]', 'P.y[1]'], + ) + T_ss = ct.feedback(P * C, ct.ss([], [], [], np.eye(2))) + np.testing.assert_almost_equal(T.A, T_ss.A) + np.testing.assert_almost_equal(T.B, T_ss.B) + np.testing.assert_almost_equal(T.C, T_ss.C) + np.testing.assert_almost_equal(T.D, T_ss.D) + + # Implicit interconnection (note: use [C, P, sumblk] for proper state order) + P = ct.tf2io(ct.tf(1, [1, 0]), inputs='u', outputs='y') + C = ct.tf2io(ct.tf(10, [1, 1]), inputs='e', outputs='u') + sumblk = ct.summing_junction(inputs=['r', '-y'], output='e') + T = ct.interconnect([C, P, sumblk], inplist='r', outlist='y') + T_ss = ct.feedback(P * C, 1) + np.testing.assert_almost_equal(T.A, T_ss.A) + np.testing.assert_almost_equal(T.B, T_ss.B) + np.testing.assert_almost_equal(T.C, T_ss.C) + np.testing.assert_almost_equal(T.D, T_ss.D) + + +def test_interconnect_exceptions(): + # First make sure the docstring example works + P = ct.tf2io(ct.tf(1, [1, 0]), input='u', output='y') + C = ct.tf2io(ct.tf(10, [1, 1]), input='e', output='u') + sumblk = ct.summing_junction(inputs=['r', '-y'], output='e') + T = ct.interconnect((P, C, sumblk), input='r', output='y') + assert (T.ninputs, T.noutputs, T.nstates) == (1, 1, 2) + + # Unrecognized arguments + # LinearIOSystem + with pytest.raises(TypeError, match="unknown parameter"): + P = ct.LinearIOSystem(ct.rss(2, 1, 1), output_name='y') + + # Interconnect + with pytest.raises(TypeError, match="unknown parameter"): + T = ct.interconnect((P, C, sumblk), input_name='r', output='y') + + # Interconnected system + with pytest.raises(TypeError, match="unknown parameter"): + T = ct.InterconnectedSystem((P, C, sumblk), input_name='r', output='y') + + # NonlinearIOSytem + with pytest.raises(TypeError, match="unknown parameter"): + nlios = ct.NonlinearIOSystem( + None, lambda t, x, u, params: u*u, input_count=1, output_count=1) + + # Summing junction + with pytest.raises(TypeError, match="input specification is required"): + sumblk = ct.summing_junction() + + with pytest.raises(TypeError, match="unknown parameter"): + sumblk = ct.summing_junction(input_count=2, output_count=2) diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index 20f289d8c..9a15e83f4 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -1,85 +1,93 @@ -#!/usr/bin/env python -# -# iosys_test.py - test input/output system oeprations -# RMM, 17 Apr 2019 -# -# This test suite checks to make sure that basic input/output class -# operations are working. It doesn't do exhaustive testing of -# operations on input/output systems. Separate unit tests should be -# created for that purpose. +"""iosys_test.py - test input/output system oeprations + +RMM, 17 Apr 2019 + +This test suite checks to make sure that basic input/output class +operations are working. It doesn't do exhaustive testing of +operations on input/output systems. Separate unit tests should be +created for that purpose. +""" from __future__ import print_function -import unittest -import warnings + import numpy as np +import pytest import scipy as sp + import control as ct -import control.iosys as ios -from distutils.version import StrictVersion +from control import iosys as ios +from control.tests.conftest import noscipy0, matrixfilter -class TestIOSys(unittest.TestCase): - def setUp(self): - # Turn off numpy matrix warnings - import warnings - warnings.simplefilter('ignore', category=PendingDeprecationWarning) +class TestIOSys: + @pytest.fixture + def tsys(self): + class TSys: + pass + T = TSys() + """Return some test systems""" # Create a single input/single output linear system - self.siso_linsys = ct.StateSpace( + T.siso_linsys = ct.StateSpace( [[-1, 1], [0, -2]], [[0], [1]], [[1, 0]], [[0]]) # Create a multi input/multi output linear system - self.mimo_linsys1 = ct.StateSpace( + T.mimo_linsys1 = ct.StateSpace( [[-1, 1], [0, -2]], [[1, 0], [0, 1]], - [[1, 0], [0, 1]], np.zeros((2,2))) + [[1, 0], [0, 1]], np.zeros((2, 2))) # Create a multi input/multi output linear system - self.mimo_linsys2 = ct.StateSpace( + T.mimo_linsys2 = ct.StateSpace( [[-1, 1], [0, -2]], [[0, 1], [1, 0]], - [[1, 0], [0, 1]], np.zeros((2,2))) + [[1, 0], [0, 1]], np.zeros((2, 2))) # Create simulation parameters - self.T = np.linspace(0, 10, 100) - self.U = np.sin(self.T) - self.X0 = [0, 0] + T.T = np.linspace(0, 10, 100) + T.U = np.sin(T.T) + T.X0 = [0, 0] + + return T - @unittest.skipIf(StrictVersion(sp.__version__) < "1.0", - "requires SciPy 1.0 or greater") - def test_linear_iosys(self): + @noscipy0 + def test_linear_iosys(self, tsys): # Create an input/output system from the linear system - linsys = self.siso_linsys + linsys = tsys.siso_linsys iosys = ios.LinearIOSystem(linsys) # Make sure that the right hand side matches linear system for x, u in (([0, 0], 0), ([1, 0], 0), ([0, 1], 0), ([0, 0], 1)): np.testing.assert_array_almost_equal( - np.reshape(iosys._rhs(0, x, u), (-1,1)), + np.reshape(iosys._rhs(0, x, u), (-1, 1)), np.dot(linsys.A, np.reshape(x, (-1, 1))) + np.dot(linsys.B, u)) # Make sure that simulations also line up - T, U, X0 = self.T, self.U, self.X0 - lti_t, lti_y, lti_x = ct.forced_response(linsys, T, U, X0) + T, U, X0 = tsys.T, tsys.U, tsys.X0 + lti_t, lti_y = ct.forced_response(linsys, T, U, X0) ios_t, ios_y = ios.input_output_response(iosys, T, U, X0) np.testing.assert_array_almost_equal(lti_t, ios_t) - np.testing.assert_allclose(lti_y, ios_y,atol=0.002,rtol=0.) + np.testing.assert_allclose(lti_y, ios_y, atol=0.002, rtol=0.) - @unittest.skipIf(StrictVersion(sp.__version__) < "1.0", - "requires SciPy 1.0 or greater") - def test_tf2io(self): + @noscipy0 + def test_tf2io(self, tsys): # Create a transfer function from the state space system - linsys = self.siso_linsys + linsys = tsys.siso_linsys tfsys = ct.ss2tf(linsys) iosys = ct.tf2io(tfsys) # Verify correctness via simulation - T, U, X0 = self.T, self.U, self.X0 - lti_t, lti_y, lti_x = ct.forced_response(linsys, T, U, X0) + T, U, X0 = tsys.T, tsys.U, tsys.X0 + lti_t, lti_y = ct.forced_response(linsys, T, U, X0) ios_t, ios_y = ios.input_output_response(iosys, T, U, X0) np.testing.assert_array_almost_equal(lti_t, ios_t) - np.testing.assert_allclose(lti_y, ios_y,atol=0.002,rtol=0.) + np.testing.assert_allclose(lti_y, ios_y, atol=0.002, rtol=0.) + + # Make sure that non-proper transfer functions generate an error + tfsys = ct.tf('s') + with pytest.raises(ValueError): + iosys=ct.tf2io(tfsys) - def test_ss2io(self): + def test_ss2io(self, tsys): # Create an input/output system from the linear system - linsys = self.siso_linsys + linsys = tsys.siso_linsys iosys = ct.ss2io(linsys) np.testing.assert_array_equal(linsys.A, iosys.A) np.testing.assert_array_equal(linsys.B, iosys.B) @@ -89,50 +97,44 @@ def test_ss2io(self): # Try adding names to things iosys_named = ct.ss2io(linsys, inputs='u', outputs='y', states=['x1', 'x2'], name='iosys_named') - self.assertEqual(iosys_named.find_input('u'), 0) - self.assertEqual(iosys_named.find_input('x'), None) - self.assertEqual(iosys_named.find_output('y'), 0) - self.assertEqual(iosys_named.find_output('u'), None) - self.assertEqual(iosys_named.find_state('x0'), None) - self.assertEqual(iosys_named.find_state('x1'), 0) - self.assertEqual(iosys_named.find_state('x2'), 1) + assert iosys_named.find_input('u') == 0 + assert iosys_named.find_input('x') is None + assert iosys_named.find_output('y') == 0 + assert iosys_named.find_output('u') is None + assert iosys_named.find_state('x0') is None + assert iosys_named.find_state('x1') == 0 + assert iosys_named.find_state('x2') == 1 np.testing.assert_array_equal(linsys.A, iosys_named.A) np.testing.assert_array_equal(linsys.B, iosys_named.B) np.testing.assert_array_equal(linsys.C, iosys_named.C) np.testing.assert_array_equal(linsys.D, iosys_named.D) - # Make sure unspecified inputs/outputs/states are handled properly - def test_iosys_unspecified(self): - # System with unspecified inputs and outputs + def test_iosys_unspecified(self, tsys): + """System with unspecified inputs and outputs""" sys = ios.NonlinearIOSystem(secord_update, secord_output) np.testing.assert_raises(TypeError, sys.__mul__, sys) - # Make sure we can print various types of I/O systems - def test_iosys_print(self): + def test_iosys_print(self, tsys, capsys): + """Make sure we can print various types of I/O systems""" # Send the output to /dev/null - import os - f = open(os.devnull,"w") # Simple I/O system - iosys = ct.ss2io(self.siso_linsys) - print(iosys, file=f) + iosys = ct.ss2io(tsys.siso_linsys) + print(iosys) # I/O system without ninputs, noutputs ios_unspecified = ios.NonlinearIOSystem(secord_update, secord_output) - print(ios_unspecified, file=f) + print(ios_unspecified) # I/O system with derived inputs and outputs ios_linearized = ios.linearize(ios_unspecified, [0, 0], [0]) - print(ios_linearized, file=f) + print(ios_linearized) - f.close() - - @unittest.skipIf(StrictVersion(sp.__version__) < "1.0", - "requires SciPy 1.0 or greater") - def test_nonlinear_iosys(self): + @noscipy0 + def test_nonlinear_iosys(self, tsys): # Create a simple nonlinear I/O system nlsys = ios.NonlinearIOSystem(predprey) - T = self.T + T = tsys.T # Start by simulating from an equilibrium point X0 = [0, 0] @@ -147,25 +149,42 @@ def test_nonlinear_iosys(self): # Simulate a linear function as a nonlinear function and compare # # Create a single input/single output linear system - linsys = self.siso_linsys + linsys = tsys.siso_linsys # Create a nonlinear system with the same dynamics nlupd = lambda t, x, u, params: \ - np.reshape(np.dot(linsys.A, np.reshape(x, (-1, 1))) + np.dot(linsys.B, u), (-1,)) + np.reshape(np.dot(linsys.A, np.reshape(x, (-1, 1))) + + np.dot(linsys.B, u), (-1,)) nlout = lambda t, x, u, params: \ - np.reshape(np.dot(linsys.C, np.reshape(x, (-1, 1))) + np.dot(linsys.D, u), (-1,)) - nlsys = ios.NonlinearIOSystem(nlupd, nlout) + np.reshape(np.dot(linsys.C, np.reshape(x, (-1, 1))) + + np.dot(linsys.D, u), (-1,)) + nlsys = ios.NonlinearIOSystem(nlupd, nlout, inputs=1, outputs=1) # Make sure that simulations also line up - T, U, X0 = self.T, self.U, self.X0 - lti_t, lti_y, lti_x = ct.forced_response(linsys, T, U, X0) + T, U, X0 = tsys.T, tsys.U, tsys.X0 + lti_t, lti_y = ct.forced_response(linsys, T, U, X0) ios_t, ios_y = ios.input_output_response(nlsys, T, U, X0) np.testing.assert_array_almost_equal(lti_t, ios_t) np.testing.assert_allclose(lti_y, ios_y,atol=0.002,rtol=0.) - def test_linearize(self): + @pytest.fixture + def kincar(self): + # Create a simple nonlinear system to check (kinematic car) + def kincar_update(t, x, u, params): + return np.array([np.cos(x[2]) * u[0], np.sin(x[2]) * u[0], u[1]]) + + def kincar_output(t, x, u, params): + return np.array([x[0], x[1]]) + + return ios.NonlinearIOSystem( + kincar_update, kincar_output, + inputs = ['v', 'phi'], + outputs = ['x', 'y'], + states = ['x', 'y', 'theta']) + + def test_linearize(self, tsys, kincar): # Create a single input/single output linear system - linsys = self.siso_linsys + linsys = tsys.siso_linsys iosys = ios.LinearIOSystem(linsys) # Linearize it and make sure we get back what we started with @@ -176,11 +195,7 @@ def test_linearize(self): np.testing.assert_array_almost_equal(linsys.D, linearized.D) # Create a simple nonlinear system to check (kinematic car) - def kincar_update(t, x, u, params): - return np.array([np.cos(x[2]) * u[0], np.sin(x[2]) * u[0], u[1]]) - def kincar_output(t, x, u, params): - return np.array([x[0], x[1]]) - iosys = ios.NonlinearIOSystem(kincar_update, kincar_output) + iosys = kincar linearized = iosys.linearize([0, 0, 0], [0, 0]) np.testing.assert_array_almost_equal(linearized.A, np.zeros((3,3))) np.testing.assert_array_almost_equal( @@ -189,70 +204,179 @@ def kincar_output(t, x, u, params): linearized.C, [[1, 0, 0], [0, 1, 0]]) np.testing.assert_array_almost_equal(linearized.D, np.zeros((2,2))) - @unittest.skipIf(StrictVersion(sp.__version__) < "1.0", - "requires SciPy 1.0 or greater") - def test_connect(self): + @pytest.mark.usefixtures("editsdefaults") + def test_linearize_named_signals(self, kincar): + # Full form of the call + linearized = kincar.linearize([0, 0, 0], [0, 0], copy=True, + name='linearized') + assert linearized.name == 'linearized' + assert linearized.find_input('v') == 0 + assert linearized.find_input('phi') == 1 + assert linearized.find_output('x') == 0 + assert linearized.find_output('y') == 1 + assert linearized.find_state('x') == 0 + assert linearized.find_state('y') == 1 + assert linearized.find_state('theta') == 2 + + # If we copy signal names w/out a system name, append '$linearized' + linearized = kincar.linearize([0, 0, 0], [0, 0], copy=True) + assert linearized.name == kincar.name + '$linearized' + + # Test legacy version as well + ct.use_legacy_defaults('0.8.4') + ct.config.use_numpy_matrix(False) # np.matrix deprecated + linearized = kincar.linearize([0, 0, 0], [0, 0], copy=True) + assert linearized.name == kincar.name + '_linearized' + + # If copy is False, signal names should not be copied + lin_nocopy = kincar.linearize(0, 0, copy=False) + assert lin_nocopy.find_input('v') is None + assert lin_nocopy.find_output('x') is None + assert lin_nocopy.find_state('x') is None + + @noscipy0 + def test_connect(self, tsys): # Define a couple of (linear) systems to interconnection - linsys1 = self.siso_linsys + linsys1 = tsys.siso_linsys iosys1 = ios.LinearIOSystem(linsys1) - linsys2 = self.siso_linsys + linsys2 = tsys.siso_linsys iosys2 = ios.LinearIOSystem(linsys2) # Connect systems in different ways and compare to StateSpace linsys_series = linsys2 * linsys1 iosys_series = ios.InterconnectedSystem( - (iosys1, iosys2), # systems - ((1, 0),), # interconnection (series) + [iosys1, iosys2], # systems + [[1, 0]], # interconnection (series) 0, # input = first system 1 # output = second system ) # Run a simulation and compare to linear response - T, U = self.T, self.U - X0 = np.concatenate((self.X0, self.X0)) + T, U = tsys.T, tsys.U + X0 = np.concatenate((tsys.X0, tsys.X0)) ios_t, ios_y, ios_x = ios.input_output_response( iosys_series, T, U, X0, return_x=True) - lti_t, lti_y, lti_x = ct.forced_response(linsys_series, T, U, X0) + lti_t, lti_y = ct.forced_response(linsys_series, T, U, X0) np.testing.assert_array_almost_equal(lti_t, ios_t) np.testing.assert_allclose(lti_y, ios_y,atol=0.002,rtol=0.) # Connect systems with different timebases - linsys2c = self.siso_linsys + linsys2c = tsys.siso_linsys linsys2c.dt = 0 # Reset the timebase iosys2c = ios.LinearIOSystem(linsys2c) iosys_series = ios.InterconnectedSystem( - (iosys1, iosys2c), # systems - ((1, 0),), # interconnection (series) + [iosys1, iosys2c], # systems + [[1, 0]], # interconnection (series) 0, # input = first system 1 # output = second system ) - self.assertTrue(ct.isctime(iosys_series, strict=True)) + assert ct.isctime(iosys_series, strict=True) ios_t, ios_y, ios_x = ios.input_output_response( iosys_series, T, U, X0, return_x=True) - lti_t, lti_y, lti_x = ct.forced_response(linsys_series, T, U, X0) + lti_t, lti_y = ct.forced_response(linsys_series, T, U, X0) np.testing.assert_array_almost_equal(lti_t, ios_t) np.testing.assert_allclose(lti_y, ios_y,atol=0.002,rtol=0.) # Feedback interconnection linsys_feedback = ct.feedback(linsys1, linsys2) iosys_feedback = ios.InterconnectedSystem( - (iosys1, iosys2), # systems - ((1, 0), # input of sys2 = output of sys1 - (0, (1, 0, -1))), # input of sys1 = -output of sys2 + [iosys1, iosys2], # systems + [[1, 0], # input of sys2 = output of sys1 + [0, (1, 0, -1)]], # input of sys1 = -output of sys2 0, # input = first system 0 # output = first system ) ios_t, ios_y, ios_x = ios.input_output_response( iosys_feedback, T, U, X0, return_x=True) - lti_t, lti_y, lti_x = ct.forced_response(linsys_feedback, T, U, X0) + lti_t, lti_y = ct.forced_response(linsys_feedback, T, U, X0) np.testing.assert_array_almost_equal(lti_t, ios_t) np.testing.assert_allclose(lti_y, ios_y,atol=0.002,rtol=0.) - @unittest.skipIf(StrictVersion(sp.__version__) < "1.0", - "requires SciPy 1.0 or greater") - def test_static_nonlinearity(self): + @noscipy0 + @pytest.mark.parametrize( + "connections, inplist, outlist", + [pytest.param([[(1, 0), (0, 0, 1)]], [[(0, 0, 1)]], [[(1, 0, 1)]], + id="full, raw tuple"), + pytest.param([[(1, 0), (0, 0, -1)]], [[(0, 0)]], [[(1, 0, -1)]], + id="full, raw tuple, canceling gains"), + pytest.param([[(1, 0), (0, 0)]], [[(0, 0)]], [[(1, 0)]], + id="full, raw tuple, no gain"), + pytest.param([[(1, 0), (0, 0)]], [(0, 0)], [(1, 0)], + id="full, raw tuple, no gain, no outer list"), + pytest.param([['sys2.u[0]', 'sys1.y[0]']], ['sys1.u[0]'], + ['sys2.y[0]'], id="named, full"), + pytest.param([['sys2.u[0]', '-sys1.y[0]']], ['sys1.u[0]'], + ['-sys2.y[0]'], id="named, full, caneling gains"), + pytest.param([['sys2.u[0]', 'sys1.y[0]']], 'sys1.u[0]', 'sys2.y[0]', + id="named, full, no list"), + pytest.param([['sys2.u[0]', ('sys1', 'y[0]')]], [(0, 0)], [(1,)], + id="mixed"), + pytest.param([[1, 0]], 0, 1, id="minimal")]) + def test_connect_spec_variants(self, tsys, connections, inplist, outlist): + # Define a couple of (linear) systems to interconnection + linsys1 = tsys.siso_linsys + iosys1 = ios.LinearIOSystem(linsys1, name="sys1") + linsys2 = tsys.siso_linsys + iosys2 = ios.LinearIOSystem(linsys2, name="sys2") + + # Simple series connection + linsys_series = linsys2 * linsys1 + + # Create a simulation run to compare against + T, U = tsys.T, tsys.U + X0 = np.concatenate((tsys.X0, tsys.X0)) + lti_t, lti_y, lti_x = ct.forced_response( + linsys_series, T, U, X0, return_x=True) + + # Create the input/output system with different parameter variations + iosys_series = ios.InterconnectedSystem( + [iosys1, iosys2], connections, inplist, outlist) + ios_t, ios_y, ios_x = ios.input_output_response( + iosys_series, T, U, X0, return_x=True) + np.testing.assert_array_almost_equal(lti_t, ios_t) + np.testing.assert_allclose(lti_y, ios_y, atol=0.002, rtol=0.) + + @noscipy0 + @pytest.mark.parametrize( + "connections, inplist, outlist", + [pytest.param([['sys2.u[0]', 'sys1.y[0]']], + [[('sys1', 'u[0]'), ('sys1', 'u[0]')]], + [('sys2', 'y[0]', 0.5)], id="duplicated input"), + pytest.param([['sys2.u[0]', ('sys1', 'y[0]', 0.5)], + ['sys2.u[0]', ('sys1', 'y[0]', 0.5)]], + 'sys1.u[0]', 'sys2.y[0]', id="duplicated connection"), + pytest.param([['sys2.u[0]', 'sys1.y[0]']], 'sys1.u[0]', + [[('sys2', 'y[0]', 0.5), ('sys2', 'y[0]', 0.5)]], + id="duplicated output")]) + def test_connect_spec_warnings(self, tsys, connections, inplist, outlist): + # Define a couple of (linear) systems to interconnection + linsys1 = tsys.siso_linsys + iosys1 = ios.LinearIOSystem(linsys1, name="sys1") + linsys2 = tsys.siso_linsys + iosys2 = ios.LinearIOSystem(linsys2, name="sys2") + + # Simple series connection + linsys_series = linsys2 * linsys1 + + # Create a simulation run to compare against + T, U = tsys.T, tsys.U + X0 = np.concatenate((tsys.X0, tsys.X0)) + lti_t, lti_y, lti_x = ct.forced_response( + linsys_series, T, U, X0, return_x=True) + + # Set up multiple gainst and make sure a warning is generated + with pytest.warns(UserWarning, match="multiple.*Combining"): + iosys_series = ios.InterconnectedSystem( + [iosys1, iosys2], connections, inplist, outlist) + ios_t, ios_y, ios_x = ios.input_output_response( + iosys_series, T, U, X0, return_x=True) + np.testing.assert_array_almost_equal(lti_t, ios_t) + np.testing.assert_allclose(lti_y, ios_y, atol=0.002, rtol=0.) + + @noscipy0 + def test_static_nonlinearity(self, tsys): # Linear dynamical system - linsys = self.siso_linsys + linsys = tsys.siso_linsys ioslin = ios.LinearIOSystem(linsys) # Nonlinear saturation @@ -261,22 +385,24 @@ def test_static_nonlinearity(self): nlsat = ios.NonlinearIOSystem(None, sat_output, inputs=1, outputs=1) # Set up parameters for simulation - T, U, X0 = self.T, 2 * self.U, self.X0 + T, U, X0 = tsys.T, 2 * tsys.U, tsys.X0 Usat = np.vectorize(sat)(U) # Make sure saturation works properly by comparing linear system with # saturated input to nonlinear system with saturation composition - lti_t, lti_y, lti_x = ct.forced_response(linsys, T, Usat, X0) + lti_t, lti_y, lti_x = ct.forced_response( + linsys, T, Usat, X0, return_x=True) ios_t, ios_y, ios_x = ios.input_output_response( ioslin * nlsat, T, U, X0, return_x=True) np.testing.assert_array_almost_equal(lti_t, ios_t) np.testing.assert_array_almost_equal(lti_y, ios_y, decimal=2) - @unittest.skipIf(StrictVersion(sp.__version__) < "1.0", - "requires SciPy 1.0 or greater") - def test_algebraic_loop(self): + + @noscipy0 + @pytest.mark.filterwarnings("ignore:Duplicate name::control.iosys") + def test_algebraic_loop(self, tsys): # Create some linear and nonlinear systems to play with - linsys = self.siso_linsys + linsys = tsys.siso_linsys lnios = ios.LinearIOSystem(linsys) nlios = ios.NonlinearIOSystem(None, \ lambda t, x, u, params: u*u, inputs=1, outputs=1) @@ -284,7 +410,7 @@ def test_algebraic_loop(self): nlios2 = nlios.copy() # Set up parameters for simulation - T, U, X0 = self.T, self.U, self.X0 + T, U, X0 = tsys.T, tsys.U, tsys.X0 # Single nonlinear system - no states ios_t, ios_y = ios.input_output_response(nlios, T, U) @@ -301,14 +427,14 @@ def test_algebraic_loop(self): # Nonlinear system composed with LTI system (series) -- with states ios_t, ios_y = ios.input_output_response( nlios * lnios * nlios, T, U, X0) - lti_t, lti_y, lti_x = ct.forced_response(linsys, T, U*U, X0) + lti_t, lti_y = ct.forced_response(linsys, T, U*U, X0) np.testing.assert_array_almost_equal(ios_y, lti_y*lti_y, decimal=3) # Nonlinear system in feeback loop with LTI system iosys = ios.InterconnectedSystem( - (lnios, nlios), # linear system w/ nonlinear feedback - ((1,), # feedback interconnection (sig to 0) - (0, (1, 0, -1))), + [lnios, nlios], # linear system w/ nonlinear feedback + [[1], # feedback interconnection (sig to 0) + [0, (1, 0, -1)]], 0, # input to linear system 0 # output from linear system ) @@ -318,59 +444,60 @@ def test_algebraic_loop(self): # Algebraic loop from static nonlinear system in feedback # (error will be due to no states) iosys = ios.InterconnectedSystem( - (nlios1, nlios2), # two copies of a static nonlinear system - ((0, 1), # feedback interconnection - (1, (0, 0, -1))), + [nlios1, nlios2], # two copies of a static nonlinear system + [[0, 1], # feedback interconnection + [1, (0, 0, -1)]], 0, 0 ) args = (iosys, T, U) - self.assertRaises(RuntimeError, ios.input_output_response, *args) + with pytest.raises(RuntimeError): + ios.input_output_response(*args) # Algebraic loop due to feedthrough term linsys = ct.StateSpace( [[-1, 1], [0, -2]], [[0], [1]], [[1, 0]], [[1]]) lnios = ios.LinearIOSystem(linsys) iosys = ios.InterconnectedSystem( - (nlios, lnios), # linear system w/ nonlinear feedback - ((0, 1), # feedback interconnection - (1, (0, 0, -1))), + [nlios, lnios], # linear system w/ nonlinear feedback + [[0, 1], # feedback interconnection + [1, (0, 0, -1)]], 0, 0 ) args = (iosys, T, U, X0) # ios_t, ios_y = ios.input_output_response(iosys, T, U, X0) - self.assertRaises(RuntimeError, ios.input_output_response, *args) + with pytest.raises(RuntimeError): + ios.input_output_response(*args) - @unittest.skipIf(StrictVersion(sp.__version__) < "1.0", - "requires SciPy 1.0 or greater") - def test_summer(self): + @noscipy0 + def test_summer(self, tsys): # Construct a MIMO system for testing - linsys = self.mimo_linsys1 - linio = ios.LinearIOSystem(linsys) + linsys = tsys.mimo_linsys1 + linio1 = ios.LinearIOSystem(linsys) + linio2 = ios.LinearIOSystem(linsys) linsys_parallel = linsys + linsys - iosys_parallel = linio + linio + iosys_parallel = linio1 + linio2 # Set up parameters for simulation - T = self.T + T = tsys.T U = [np.sin(T), np.cos(T)] X0 = 0 - lin_t, lin_y, lin_x = ct.forced_response(linsys_parallel, T, U, X0) + lin_t, lin_y = ct.forced_response(linsys_parallel, T, U, X0) ios_t, ios_y = ios.input_output_response(iosys_parallel, T, U, X0) np.testing.assert_allclose(ios_y, lin_y,atol=0.002,rtol=0.) - @unittest.skipIf(StrictVersion(sp.__version__) < "1.0", - "requires SciPy 1.0 or greater") - def test_rmul(self): + @noscipy0 + def test_rmul(self, tsys): # Test right multiplication # TODO: replace with better tests when conversions are implemented # Set up parameters for simulation - T, U, X0 = self.T, self.U, self.X0 + T, U, X0 = tsys.T, tsys.U, tsys.X0 # Linear system with input and output nonlinearities # Also creates a nested interconnected system - ioslin = ios.LinearIOSystem(self.siso_linsys) + ioslin = ios.LinearIOSystem(tsys.siso_linsys) nlios = ios.NonlinearIOSystem(None, \ lambda t, x, u, params: u*u, inputs=1, outputs=1) sys1 = nlios * ioslin @@ -378,16 +505,15 @@ def test_rmul(self): # Make sure we got the right thing (via simulation comparison) ios_t, ios_y = ios.input_output_response(sys2, T, U, X0) - lti_t, lti_y, lti_x = ct.forced_response(ioslin, T, U*U, X0) + lti_t, lti_y = ct.forced_response(ioslin, T, U*U, X0) np.testing.assert_array_almost_equal(ios_y, lti_y*lti_y, decimal=3) - @unittest.skipIf(StrictVersion(sp.__version__) < "1.0", - "requires SciPy 1.0 or greater") - def test_neg(self): + @noscipy0 + def test_neg(self, tsys): """Test negation of a system""" # Set up parameters for simulation - T, U, X0 = self.T, self.U, self.X0 + T, U, X0 = tsys.T, tsys.U, tsys.X0 # Static nonlinear system nlios = ios.NonlinearIOSystem(None, \ @@ -397,84 +523,81 @@ def test_neg(self): # Linear system with input nonlinearity # Also creates a nested interconnected system - ioslin = ios.LinearIOSystem(self.siso_linsys) + ioslin = ios.LinearIOSystem(tsys.siso_linsys) sys = (ioslin) * (-nlios) # Make sure we got the right thing (via simulation comparison) ios_t, ios_y = ios.input_output_response(sys, T, U, X0) - lti_t, lti_y, lti_x = ct.forced_response(ioslin, T, U*U, X0) + lti_t, lti_y = ct.forced_response(ioslin, T, U*U, X0) np.testing.assert_array_almost_equal(ios_y, -lti_y, decimal=3) - @unittest.skipIf(StrictVersion(sp.__version__) < "1.0", - "requires SciPy 1.0 or greater") - def test_feedback(self): + @noscipy0 + def test_feedback(self, tsys): # Set up parameters for simulation - T, U, X0 = self.T, self.U, self.X0 + T, U, X0 = tsys.T, tsys.U, tsys.X0 # Linear system with constant feedback (via "nonlinear" mapping) - ioslin = ios.LinearIOSystem(self.siso_linsys) + ioslin = ios.LinearIOSystem(tsys.siso_linsys) nlios = ios.NonlinearIOSystem(None, \ lambda t, x, u, params: u, inputs=1, outputs=1) iosys = ct.feedback(ioslin, nlios) - linsys = ct.feedback(self.siso_linsys, 1) + linsys = ct.feedback(tsys.siso_linsys, 1) ios_t, ios_y = ios.input_output_response(iosys, T, U, X0) - lti_t, lti_y, lti_x = ct.forced_response(linsys, T, U, X0) + lti_t, lti_y = ct.forced_response(linsys, T, U, X0) np.testing.assert_allclose(ios_y, lti_y,atol=0.002,rtol=0.) - @unittest.skipIf(StrictVersion(sp.__version__) < "1.0", - "requires SciPy 1.0 or greater") - def test_bdalg_functions(self): + @noscipy0 + def test_bdalg_functions(self, tsys): """Test block diagram functions algebra on I/O systems""" # Set up parameters for simulation - T = self.T + T = tsys.T U = [np.sin(T), np.cos(T)] X0 = 0 # Set up systems to be composed - linsys1 = self.mimo_linsys1 + linsys1 = tsys.mimo_linsys1 linio1 = ios.LinearIOSystem(linsys1) - linsys2 = self.mimo_linsys2 + linsys2 = tsys.mimo_linsys2 linio2 = ios.LinearIOSystem(linsys2) # Series interconnection linsys_series = ct.series(linsys1, linsys2) iosys_series = ct.series(linio1, linio2) - lin_t, lin_y, lin_x = ct.forced_response(linsys_series, T, U, X0) + lin_t, lin_y = ct.forced_response(linsys_series, T, U, X0) ios_t, ios_y = ios.input_output_response(iosys_series, T, U, X0) np.testing.assert_allclose(ios_y, lin_y,atol=0.002,rtol=0.) # Make sure that systems don't commute linsys_series = ct.series(linsys2, linsys1) - lin_t, lin_y, lin_x = ct.forced_response(linsys_series, T, U, X0) - self.assertFalse((np.abs(lin_y - ios_y) < 1e-3).all()) + lin_t, lin_y = ct.forced_response(linsys_series, T, U, X0) + assert not (np.abs(lin_y - ios_y) < 1e-3).all() # Parallel interconnection linsys_parallel = ct.parallel(linsys1, linsys2) iosys_parallel = ct.parallel(linio1, linio2) - lin_t, lin_y, lin_x = ct.forced_response(linsys_parallel, T, U, X0) + lin_t, lin_y = ct.forced_response(linsys_parallel, T, U, X0) ios_t, ios_y = ios.input_output_response(iosys_parallel, T, U, X0) np.testing.assert_allclose(ios_y, lin_y,atol=0.002,rtol=0.) # Negation linsys_negate = ct.negate(linsys1) iosys_negate = ct.negate(linio1) - lin_t, lin_y, lin_x = ct.forced_response(linsys_negate, T, U, X0) + lin_t, lin_y = ct.forced_response(linsys_negate, T, U, X0) ios_t, ios_y = ios.input_output_response(iosys_negate, T, U, X0) np.testing.assert_allclose(ios_y, lin_y,atol=0.002,rtol=0.) # Feedback interconnection linsys_feedback = ct.feedback(linsys1, linsys2) iosys_feedback = ct.feedback(linio1, linio2) - lin_t, lin_y, lin_x = ct.forced_response(linsys_feedback, T, U, X0) + lin_t, lin_y = ct.forced_response(linsys_feedback, T, U, X0) ios_t, ios_y = ios.input_output_response(iosys_feedback, T, U, X0) np.testing.assert_allclose(ios_y, lin_y,atol=0.002,rtol=0.) - @unittest.skipIf(StrictVersion(sp.__version__) < "1.0", - "requires SciPy 1.0 or greater") - def test_nonsquare_bdalg(self): + @noscipy0 + def test_nonsquare_bdalg(self, tsys): # Set up parameters for simulation - T = self.T + T = tsys.T U2 = [np.sin(T), np.cos(T)] U3 = [np.sin(T), np.cos(T), T] X0 = 0 @@ -494,13 +617,13 @@ def test_nonsquare_bdalg(self): # Multiplication linsys_multiply = linsys_3i2o * linsys_2i3o iosys_multiply = iosys_3i2o * iosys_2i3o - lin_t, lin_y, lin_x = ct.forced_response(linsys_multiply, T, U2, X0) + lin_t, lin_y = ct.forced_response(linsys_multiply, T, U2, X0) ios_t, ios_y = ios.input_output_response(iosys_multiply, T, U2, X0) np.testing.assert_allclose(ios_y, lin_y,atol=0.002,rtol=0.) linsys_multiply = linsys_2i3o * linsys_3i2o iosys_multiply = iosys_2i3o * iosys_3i2o - lin_t, lin_y, lin_x = ct.forced_response(linsys_multiply, T, U3, X0) + lin_t, lin_y = ct.forced_response(linsys_multiply, T, U3, X0) ios_t, ios_y = ios.input_output_response(iosys_multiply, T, U3, X0) np.testing.assert_allclose(ios_y, lin_y,atol=0.002,rtol=0.) @@ -513,17 +636,17 @@ def test_nonsquare_bdalg(self): # Feedback linsys_multiply = ct.feedback(linsys_3i2o, linsys_2i3o) iosys_multiply = iosys_3i2o.feedback(iosys_2i3o) - lin_t, lin_y, lin_x = ct.forced_response(linsys_multiply, T, U3, X0) + lin_t, lin_y = ct.forced_response(linsys_multiply, T, U3, X0) ios_t, ios_y = ios.input_output_response(iosys_multiply, T, U3, X0) np.testing.assert_allclose(ios_y, lin_y,atol=0.002,rtol=0.) # Mismatch should generate exception args = (iosys_3i2o, iosys_3i2o) - self.assertRaises(ValueError, ct.series, *args) + with pytest.raises(ValueError): + ct.series(*args) - @unittest.skipIf(StrictVersion(sp.__version__) < "1.0", - "requires SciPy 1.0 or greater") - def test_discrete(self): + @noscipy0 + def test_discrete(self, tsys): """Test discrete time functionality""" # Create some linear and nonlinear systems to play with linsys = ct.StateSpace( @@ -531,37 +654,37 @@ def test_discrete(self): lnios = ios.LinearIOSystem(linsys) # Set up parameters for simulation - T, U, X0 = self.T, self.U, self.X0 + T, U, X0 = tsys.T, tsys.U, tsys.X0 # Simulate and compare to LTI output ios_t, ios_y = ios.input_output_response(lnios, T, U, X0) - lin_t, lin_y, lin_x = ct.forced_response(linsys, T, U, X0) + lin_t, lin_y = ct.forced_response(linsys, T, U, X0) np.testing.assert_allclose(ios_t, lin_t,atol=0.002,rtol=0.) np.testing.assert_allclose(ios_y, lin_y,atol=0.002,rtol=0.) # Test MIMO system, converted to discrete time - linsys = ct.StateSpace(self.mimo_linsys1) - linsys.dt = self.T[1] - self.T[0] + linsys = ct.StateSpace(tsys.mimo_linsys1) + linsys.dt = tsys.T[1] - tsys.T[0] lnios = ios.LinearIOSystem(linsys) # Set up parameters for simulation - T = self.T + T = tsys.T U = [np.sin(T), np.cos(T)] X0 = 0 # Simulate and compare to LTI output ios_t, ios_y = ios.input_output_response(lnios, T, U, X0) - lin_t, lin_y, lin_x = ct.forced_response(linsys, T, U, X0) + lin_t, lin_y = ct.forced_response(linsys, T, U, X0) np.testing.assert_allclose(ios_t, lin_t,atol=0.002,rtol=0.) np.testing.assert_allclose(ios_y, lin_y,atol=0.002,rtol=0.) - def test_find_eqpts(self): + def test_find_eqpts(self, tsys): """Test find_eqpt function""" # Simple equilibrium point with no inputs nlsys = ios.NonlinearIOSystem(predprey) xeq, ueq, result = ios.find_eqpt( nlsys, [1.6, 1.2], None, return_result=True) - self.assertTrue(result.success) + assert result.success np.testing.assert_array_almost_equal(xeq, [1.64705879, 1.17923874]) np.testing.assert_array_almost_equal( nlsys._rhs(0, xeq, ueq), np.zeros((2,))) @@ -572,7 +695,7 @@ def test_find_eqpts(self): # Make sure the origin is a fixed point xeq, ueq, result = ios.find_eqpt( nlsys, [0, 0, 0, 0], [0, 4*9.8], return_result=True) - self.assertTrue(result.success) + assert result.success np.testing.assert_array_almost_equal( nlsys._rhs(0, xeq, ueq), np.zeros((4,))) np.testing.assert_array_almost_equal(xeq, [0, 0, 0, 0]) @@ -580,7 +703,7 @@ def test_find_eqpts(self): # Use a small lateral force to cause motion xeq, ueq, result = ios.find_eqpt( nlsys, [0, 0, 0, 0], [0.01, 4*9.8], return_result=True) - self.assertTrue(result.success) + assert result.success np.testing.assert_array_almost_equal( nlsys._rhs(0, xeq, ueq), np.zeros((4,)), decimal=5) @@ -588,7 +711,7 @@ def test_find_eqpts(self): xeq, ueq, result = ios.find_eqpt( nlsys, [0, 0, 0, 0], [0.01, 4*9.8], y0=[0.1, 0.1], return_result=True) - self.assertTrue(result.success) + assert result.success np.testing.assert_array_almost_equal( nlsys._out(0, xeq, ueq), [0.1, 0.1], decimal=5) np.testing.assert_array_almost_equal( @@ -598,7 +721,7 @@ def test_find_eqpts(self): xeq, ueq, result = ios.find_eqpt( nlsys, [0, 0, 0, 0], [0.01, 4*9.8], y0=[0.1, 0.1], iy = [0, 1], return_result=True) - self.assertTrue(result.success) + assert result.success np.testing.assert_array_almost_equal( nlsys._out(0, xeq, ueq), [0.1, 0.1], decimal=5) np.testing.assert_array_almost_equal( @@ -619,7 +742,7 @@ def test_find_eqpts(self): nlsys_full, [0, 0, 0, 0, 0, 0], [0.01, 4*9.8], y0=[0, 0, 0.1, 0.1, 0, 0], iy = [2, 3], idx=[2, 3, 4, 5], ix=[0, 1], return_result=True) - self.assertTrue(result.success) + assert result.success np.testing.assert_array_almost_equal( nlsys_full._out(0, xeq, ueq)[[2, 3]], [0.1, 0.1], decimal=5) np.testing.assert_array_almost_equal( @@ -631,7 +754,7 @@ def test_find_eqpts(self): nlsys_full, [0, 0, 0, 0, 0, 0], [0.01, 4*9.8], y0=[0, 0, 0.1, 0.1, 0, 0], iy=[3], iu=[1], idx=[2, 3, 4, 5], ix=[0, 1], return_result=True) - self.assertTrue(result.success) + assert result.success np.testing.assert_almost_equal(ueq[1], 4*9.8, decimal=5) np.testing.assert_array_almost_equal( nlsys_full._out(0, xeq, ueq)[[3]], [0.1], decimal=5) @@ -644,7 +767,7 @@ def test_find_eqpts(self): y0=[0, 0, 0, 0.1, 0, 0], iy=[3], dx0=[0.1, 0, 0, 0, 0, 0], idx=[1, 2, 3, 4, 5], ix=[0, 1], return_result=True) - self.assertTrue(result.success) + assert result.success np.testing.assert_array_almost_equal( nlsys_full._out(0, xeq, ueq)[-3:], [0.1, 0, 0], decimal=5) np.testing.assert_array_almost_equal( @@ -658,16 +781,15 @@ def test_find_eqpts(self): # If result is returned, user has to check xeq, ueq, result = ios.find_eqpt( lnios, [0, 0], [0], y0=[1], return_result=True) - self.assertFalse(result.success) + assert not result.success # If result is not returned, find_eqpt should return None xeq, ueq = ios.find_eqpt(lnios, [0, 0], [0], y0=[1]) - self.assertEqual(xeq, None) - self.assertEqual(ueq, None) + assert xeq is None + assert ueq is None - @unittest.skipIf(StrictVersion(sp.__version__) < "1.0", - "requires SciPy 1.0 or greater") - def test_params(self): + @noscipy0 + def test_params(self, tsys): # Start with the default set of parameters ios_secord_default = ios.NonlinearIOSystem( secord_update, secord_output, inputs=1, outputs=1, states=2) @@ -717,51 +839,40 @@ def test_params(self): np.testing.assert_array_almost_equal(w, [4j, -4j, 4j, -4j]) # Check for warning if we try to set params for LinearIOSystem - linsys = self.siso_linsys + linsys = tsys.siso_linsys iosys = ios.LinearIOSystem(linsys) - T, U, X0 = self.T, self.U, self.X0 - lti_t, lti_y, lti_x = ct.forced_response(linsys, T, U, X0) - with warnings.catch_warnings(record=True) as warnval: - # Turn off deprecation warnings - warnings.simplefilter("ignore", category=DeprecationWarning) - warnings.simplefilter("ignore", category=PendingDeprecationWarning) - - # Trigger a warning + T, U, X0 = tsys.T, tsys.U, tsys.X0 + lti_t, lti_y = ct.forced_response(linsys, T, U, X0) + with pytest.warns(UserWarning, match="LinearIOSystem.*ignored"): ios_t, ios_y = ios.input_output_response( iosys, T, U, X0, params={'something':0}) - # Verify that we got a warning - self.assertEqual(len(warnval), 1) - self.assertTrue(issubclass(warnval[-1].category, UserWarning)) - self.assertTrue("LinearIOSystem" in str(warnval[-1].message)) - self.assertTrue("ignored" in str(warnval[-1].message)) - # Check to make sure results are OK np.testing.assert_array_almost_equal(lti_t, ios_t) np.testing.assert_allclose(lti_y, ios_y,atol=0.002,rtol=0.) - def test_named_signals(self): + def test_named_signals(self, tsys): sys1 = ios.NonlinearIOSystem( updfcn = lambda t, x, u, params: np.array( - np.dot(self.mimo_linsys1.A, np.reshape(x, (-1, 1))) \ - + np.dot(self.mimo_linsys1.B, np.reshape(u, (-1, 1))) + np.dot(tsys.mimo_linsys1.A, np.reshape(x, (-1, 1))) \ + + np.dot(tsys.mimo_linsys1.B, np.reshape(u, (-1, 1))) ).reshape(-1,), outfcn = lambda t, x, u, params: np.array( - np.dot(self.mimo_linsys1.C, np.reshape(x, (-1, 1))) \ - + np.dot(self.mimo_linsys1.D, np.reshape(u, (-1, 1))) + np.dot(tsys.mimo_linsys1.C, np.reshape(x, (-1, 1))) \ + + np.dot(tsys.mimo_linsys1.D, np.reshape(u, (-1, 1))) ).reshape(-1,), - inputs = ('u[0]', 'u[1]'), - outputs = ('y[0]', 'y[1]'), - states = self.mimo_linsys1.states, + inputs = ['u[0]', 'u[1]'], + outputs = ['y[0]', 'y[1]'], + states = tsys.mimo_linsys1.nstates, name = 'sys1') - sys2 = ios.LinearIOSystem(self.mimo_linsys2, - inputs = ('u[0]', 'u[1]'), - outputs = ('y[0]', 'y[1]'), + sys2 = ios.LinearIOSystem(tsys.mimo_linsys2, + inputs = ['u[0]', 'u[1]'], + outputs = ['y[0]', 'y[1]'], name = 'sys2') # Series interconnection (sys1 * sys2) using __mul__ ios_mul = sys1 * sys2 - ss_series = self.mimo_linsys1 * self.mimo_linsys2 + ss_series = tsys.mimo_linsys1 * tsys.mimo_linsys2 lin_series = ct.linearize(ios_mul, 0, 0) np.testing.assert_array_almost_equal(ss_series.A, lin_series.A) np.testing.assert_array_almost_equal(ss_series.B, lin_series.B) @@ -770,7 +881,7 @@ def test_named_signals(self): # Series interconnection (sys1 * sys2) using series ios_series = ct.series(sys2, sys1) - ss_series = ct.series(self.mimo_linsys2, self.mimo_linsys1) + ss_series = ct.series(tsys.mimo_linsys2, tsys.mimo_linsys1) lin_series = ct.linearize(ios_series, 0, 0) np.testing.assert_array_almost_equal(ss_series.A, lin_series.A) np.testing.assert_array_almost_equal(ss_series.B, lin_series.B) @@ -779,13 +890,30 @@ def test_named_signals(self): # Series interconnection (sys1 * sys2) using named + mixed signals ios_connect = ios.InterconnectedSystem( + [sys2, sys1], + connections=[ + [('sys1', 'u[0]'), 'sys2.y[0]'], + ['sys1.u[1]', 'sys2.y[1]'] + ], + inplist=['sys2.u[0]', ('sys2', 1)], + outlist=[(1, 'y[0]'), 'sys1.y[1]'] + ) + lin_series = ct.linearize(ios_connect, 0, 0) + np.testing.assert_array_almost_equal(ss_series.A, lin_series.A) + np.testing.assert_array_almost_equal(ss_series.B, lin_series.B) + np.testing.assert_array_almost_equal(ss_series.C, lin_series.C) + np.testing.assert_array_almost_equal(ss_series.D, lin_series.D) + + # Try the same thing using the interconnect function + # Since sys1 is nonlinear, we should get back the same result + ios_connect = ios.interconnect( (sys2, sys1), connections=( - (('sys1', 'u[0]'), 'sys2.y[0]'), - ('sys1.u[1]', 'sys2.y[1]') + [('sys1', 'u[0]'), 'sys2.y[0]'], + ['sys1.u[1]', 'sys2.y[1]'] ), - inplist=('sys2.u[0]', ('sys2', 1)), - outlist=((1, 'y[0]'), 'sys1.y[1]') + inplist=['sys2.u[0]', ('sys2', 1)], + outlist=[(1, 'y[0]'), 'sys1.y[1]'] ) lin_series = ct.linearize(ios_connect, 0, 0) np.testing.assert_array_almost_equal(ss_series.A, lin_series.A) @@ -793,46 +921,68 @@ def test_named_signals(self): np.testing.assert_array_almost_equal(ss_series.C, lin_series.C) np.testing.assert_array_almost_equal(ss_series.D, lin_series.D) - # Make sure that we can use input signal names as system outputs - ios_connect = ios.InterconnectedSystem( - (sys1, sys2), + # Try the same thing using the interconnect function + # Since sys1 is nonlinear, we should get back the same result + # Note: use a tuple for connections to make sure it works + ios_connect = ios.interconnect( + (sys2, sys1), connections=( - ('sys2.u[0]', 'sys1.y[0]'), ('sys2.u[1]', 'sys1.y[1]'), - ('sys1.u[0]', '-sys2.y[0]'), ('sys1.u[1]', '-sys2.y[1]') + [('sys1', 'u[0]'), 'sys2.y[0]'], + ['sys1.u[1]', 'sys2.y[1]'] ), - inplist=('sys1.u[0]', 'sys1.u[1]'), - outlist=('sys2.u[0]', 'sys2.u[1]') # = sys1.y[0], sys1.y[1] + inplist=['sys2.u[0]', ('sys2', 1)], + outlist=[(1, 'y[0]'), 'sys1.y[1]'] ) - ss_feedback = ct.feedback(self.mimo_linsys1, self.mimo_linsys2) + lin_series = ct.linearize(ios_connect, 0, 0) + np.testing.assert_array_almost_equal(ss_series.A, lin_series.A) + np.testing.assert_array_almost_equal(ss_series.B, lin_series.B) + np.testing.assert_array_almost_equal(ss_series.C, lin_series.C) + np.testing.assert_array_almost_equal(ss_series.D, lin_series.D) + + # Make sure that we can use input signal names as system outputs + ios_connect = ios.InterconnectedSystem( + [sys1, sys2], + connections=[ + ['sys2.u[0]', 'sys1.y[0]'], ['sys2.u[1]', 'sys1.y[1]'], + ['sys1.u[0]', '-sys2.y[0]'], ['sys1.u[1]', '-sys2.y[1]'] + ], + inplist=['sys1.u[0]', 'sys1.u[1]'], + outlist=['sys2.u[0]', 'sys2.u[1]'] # = sys1.y[0], sys1.y[1] + ) + ss_feedback = ct.feedback(tsys.mimo_linsys1, tsys.mimo_linsys2) lin_feedback = ct.linearize(ios_connect, 0, 0) np.testing.assert_array_almost_equal(ss_feedback.A, lin_feedback.A) np.testing.assert_array_almost_equal(ss_feedback.B, lin_feedback.B) np.testing.assert_array_almost_equal(ss_feedback.C, lin_feedback.C) np.testing.assert_array_almost_equal(ss_feedback.D, lin_feedback.D) - def test_sys_naming_convention(self): - """Enforce generic system names 'sys[i]' to be present when systems are created - without explicit names.""" + @pytest.mark.usefixtures("editsdefaults") + def test_sys_naming_convention(self, tsys): + """Enforce generic system names 'sys[i]' to be present when systems are + created without explicit names.""" + ct.config.use_legacy_defaults('0.8.4') # changed delims in 0.9.0 + ct.config.use_numpy_matrix(False) # np.matrix deprecated ct.InputOutputSystem.idCounter = 0 - sys = ct.LinearIOSystem(self.mimo_linsys1) - self.assertEquals(sys.name, "sys[0]") - self.assertEquals(sys.copy().name, "copy of sys[0]") - + sys = ct.LinearIOSystem(tsys.mimo_linsys1) + + assert sys.name == "sys[0]" + assert sys.copy().name == "copy of sys[0]" + namedsys = ios.NonlinearIOSystem( - updfcn = lambda t, x, u, params: x, - outfcn = lambda t, x, u, params: u, - inputs = ('u[0]', 'u[1]'), - outputs = ('y[0]', 'y[1]'), - states = self.mimo_linsys1.states, - name = 'namedsys') + updfcn=lambda t, x, u, params: x, + outfcn=lambda t, x, u, params: u, + inputs=('u[0]', 'u[1]'), + outputs=('y[0]', 'y[1]'), + states=tsys.mimo_linsys1.nstates, + name='namedsys') unnamedsys1 = ct.NonlinearIOSystem( - lambda t,x,u,params: x, inputs=2, outputs=2, states=2 + lambda t, x, u, params: x, inputs=2, outputs=2, states=2 ) unnamedsys2 = ct.NonlinearIOSystem( - None, lambda t,x,u,params: u, inputs=2, outputs=2 + None, lambda t, x, u, params: u, inputs=2, outputs=2 ) - self.assertEquals(unnamedsys2.name, "sys[2]") + assert unnamedsys2.name == "sys[2]" # Unnamed/unnamed connections uu_series = unnamedsys1 * unnamedsys2 @@ -840,65 +990,68 @@ def test_sys_naming_convention(self): u_neg = - unnamedsys1 uu_feedback = unnamedsys2.feedback(unnamedsys1) uu_dup = unnamedsys1 * unnamedsys1.copy() - uu_hierarchical = uu_series*unnamedsys1 + uu_hierarchical = uu_series * unnamedsys1 - self.assertEquals(uu_series.name, "sys[3]") - self.assertEquals(uu_parallel.name, "sys[4]") - self.assertEquals(u_neg.name, "sys[5]") - self.assertEquals(uu_feedback.name, "sys[6]") - self.assertEquals(uu_dup.name, "sys[7]") - self.assertEquals(uu_hierarchical.name, "sys[8]") + assert uu_series.name == "sys[3]" + assert uu_parallel.name == "sys[4]" + assert u_neg.name == "sys[5]" + assert uu_feedback.name == "sys[6]" + assert uu_dup.name == "sys[7]" + assert uu_hierarchical.name == "sys[8]" # Unnamed/named connections un_series = unnamedsys1 * namedsys un_parallel = unnamedsys1 + namedsys un_feedback = unnamedsys2.feedback(namedsys) un_dup = unnamedsys1 * namedsys.copy() - un_hierarchical = uu_series*unnamedsys1 + un_hierarchical = uu_series * unnamedsys1 - self.assertEquals(un_series.name, "sys[9]") - self.assertEquals(un_parallel.name, "sys[10]") - self.assertEquals(un_feedback.name, "sys[11]") - self.assertEquals(un_dup.name, "sys[12]") - self.assertEquals(un_hierarchical.name, "sys[13]") + assert un_series.name == "sys[9]" + assert un_parallel.name == "sys[10]" + assert un_feedback.name == "sys[11]" + assert un_dup.name == "sys[12]" + assert un_hierarchical.name == "sys[13]" # Same system conflict - with warnings.catch_warnings(record=True) as warnval: + with pytest.warns(UserWarning): unnamedsys1 * unnamedsys1 - self.assertEqual(len(warnval), 1) - def test_signals_naming_convention(self): + @pytest.mark.usefixtures("editsdefaults") + def test_signals_naming_convention_0_8_4(self, tsys): """Enforce generic names to be present when systems are created without explicit signal names: input: 'u[i]' state: 'x[i]' output: 'y[i]' """ + + ct.config.use_legacy_defaults('0.8.4') # changed delims in 0.9.0 + ct.config.use_numpy_matrix(False) # np.matrix deprecated ct.InputOutputSystem.idCounter = 0 - sys = ct.LinearIOSystem(self.mimo_linsys1) + sys = ct.LinearIOSystem(tsys.mimo_linsys1) for statename in ["x[0]", "x[1]"]: - self.assertTrue(statename in sys.state_index) + assert statename in sys.state_index for inputname in ["u[0]", "u[1]"]: - self.assertTrue(inputname in sys.input_index) + assert inputname in sys.input_index for outputname in ["y[0]", "y[1]"]: - self.assertTrue(outputname in sys.output_index) - self.assertEqual(len(sys.state_index), sys.nstates) - self.assertEqual(len(sys.input_index), sys.ninputs) - self.assertEqual(len(sys.output_index), sys.noutputs) + assert outputname in sys.output_index + assert len(sys.state_index) == sys.nstates + assert len(sys.input_index) == sys.ninputs + assert len(sys.output_index) == sys.noutputs namedsys = ios.NonlinearIOSystem( - updfcn = lambda t, x, u, params: x, - outfcn = lambda t, x, u, params: u, - inputs = ('u0'), - outputs = ('y0'), - states = ('x0'), - name = 'namedsys') + updfcn=lambda t, x, u, params: x, + outfcn=lambda t, x, u, params: u, + inputs=('u0'), + outputs=('y0'), + states=('x0'), + name='namedsys') unnamedsys = ct.NonlinearIOSystem( - lambda t,x,u,params: x, inputs=1, outputs=1, states=1 + lambda t, x, u, params: x, inputs=1, outputs=1, states=1 ) - self.assertTrue('u0' in namedsys.input_index) - self.assertTrue('y0' in namedsys.output_index) - self.assertTrue('x0' in namedsys.state_index) + assert 'u0' in namedsys.input_index + assert 'y0' in namedsys.output_index + assert 'x0' in namedsys.state_index # Unnamed/named connections un_series = unnamedsys * namedsys @@ -908,26 +1061,25 @@ def test_signals_naming_convention(self): un_hierarchical = un_series*unnamedsys u_neg = - unnamedsys - self.assertTrue("sys[1].x[0]" in un_series.state_index) - self.assertTrue("namedsys.x0" in un_series.state_index) - self.assertTrue("sys[1].x[0]" in un_parallel.state_index) - self.assertTrue("namedsys.x0" in un_series.state_index) - self.assertTrue("sys[1].x[0]" in un_feedback.state_index) - self.assertTrue("namedsys.x0" in un_feedback.state_index) - self.assertTrue("sys[1].x[0]" in un_dup.state_index) - self.assertTrue("copy of namedsys.x0" in un_dup.state_index) - self.assertTrue("sys[1].x[0]" in un_hierarchical.state_index) - self.assertTrue("sys[2].sys[1].x[0]" in un_hierarchical.state_index) - self.assertTrue("sys[1].x[0]" in u_neg.state_index) + assert "sys[1].x[0]" in un_series.state_index + assert "namedsys.x0" in un_series.state_index + assert "sys[1].x[0]" in un_parallel.state_index + assert "namedsys.x0" in un_series.state_index + assert "sys[1].x[0]" in un_feedback.state_index + assert "namedsys.x0" in un_feedback.state_index + assert "sys[1].x[0]" in un_dup.state_index + assert "copy of namedsys.x0" in un_dup.state_index + assert "sys[1].x[0]" in un_hierarchical.state_index + assert "sys[2].sys[1].x[0]" in un_hierarchical.state_index + assert "sys[1].x[0]" in u_neg.state_index # Same system conflict - with warnings.catch_warnings(record=True) as warnval: + with pytest.warns(UserWarning): same_name_series = unnamedsys * unnamedsys - self.assertEquals(len(warnval), 1) - self.assertTrue("sys[1].x[0]" in same_name_series.state_index) - self.assertTrue("copy of sys[1].x[0]" in same_name_series.state_index) + assert "sys[1].x[0]" in same_name_series.state_index + assert "copy of sys[1].x[0]" in same_name_series.state_index - def test_named_signals_linearize_inconsistent(self): + def test_named_signals_linearize_inconsistent(self, tsys): """Mare sure that providing inputs or outputs not consistent with updfcn or outfcn fail """ @@ -935,15 +1087,15 @@ def test_named_signals_linearize_inconsistent(self): def updfcn(t, x, u, params): """2 inputs, 2 states""" return np.array( - np.dot(self.mimo_linsys1.A, np.reshape(x, (-1, 1))) - + np.dot(self.mimo_linsys1.B, np.reshape(u, (-1, 1))) + np.dot(tsys.mimo_linsys1.A, np.reshape(x, (-1, 1))) + + np.dot(tsys.mimo_linsys1.B, np.reshape(u, (-1, 1))) ).reshape(-1,) def outfcn(t, x, u, params): """2 states, 2 outputs""" return np.array( - self.mimo_linsys1.C * np.reshape(x, (-1, 1)) - + self.mimo_linsys1.D * np.reshape(u, (-1, 1)) + tsys.mimo_linsys1.C * np.reshape(x, (-1, 1)) + + tsys.mimo_linsys1.D * np.reshape(u, (-1, 1)) ).reshape(-1,) for inputs, outputs in [ @@ -955,133 +1107,234 @@ def outfcn(t, x, u, params): outfcn=outfcn, inputs=inputs, outputs=outputs, - states=self.mimo_linsys1.states, + states=tsys.mimo_linsys1.nstates, name='sys1') - self.assertRaises(ValueError, sys1.linearize, [0, 0], [0, 0]) + with pytest.raises(ValueError): + sys1.linearize([0, 0], [0, 0]) sys2 = ios.NonlinearIOSystem(updfcn=updfcn, outfcn=outfcn, inputs=('u[0]', 'u[1]'), outputs=('y[0]', 'y[1]'), - states=self.mimo_linsys1.states, + states=tsys.mimo_linsys1.nstates, name='sys1') for x0, u0 in [([0], [0, 0]), ([0, 0, 0], [0, 0]), ([0, 0], [0]), ([0, 0], [0, 0, 0])]: - self.assertRaises(ValueError, sys2.linearize, x0, u0) + with pytest.raises(ValueError): + sys2.linearize(x0, u0) - def test_lineariosys_statespace(self): + def test_lineariosys_statespace(self, tsys): """Make sure that a LinearIOSystem is also a StateSpace object""" - iosys_siso = ct.LinearIOSystem(self.siso_linsys) - self.assertTrue(isinstance(iosys_siso, ct.StateSpace)) + iosys_siso = ct.LinearIOSystem(tsys.siso_linsys) + iosys_siso2 = ct.LinearIOSystem(tsys.siso_linsys) + assert isinstance(iosys_siso, ct.StateSpace) # Make sure that state space functions work for LinearIOSystems np.testing.assert_array_equal( - iosys_siso.pole(), self.siso_linsys.pole()) + iosys_siso.pole(), tsys.siso_linsys.pole()) omega = np.logspace(.1, 10, 100) - mag_io, phase_io, omega_io = iosys_siso.freqresp(omega) - mag_ss, phase_ss, omega_ss = self.siso_linsys.freqresp(omega) + mag_io, phase_io, omega_io = iosys_siso.frequency_response(omega) + mag_ss, phase_ss, omega_ss = tsys.siso_linsys.frequency_response(omega) np.testing.assert_array_equal(mag_io, mag_ss) np.testing.assert_array_equal(phase_io, phase_ss) np.testing.assert_array_equal(omega_io, omega_ss) # LinearIOSystem methods should override StateSpace methods - io_mul = iosys_siso * iosys_siso - self.assertTrue(isinstance(io_mul, ct.InputOutputSystem)) + io_mul = iosys_siso * iosys_siso2 + assert isinstance(io_mul, ct.InputOutputSystem) # But also retain linear structure - self.assertTrue(isinstance(io_mul, ct.StateSpace)) + assert isinstance(io_mul, ct.StateSpace) # And make sure the systems match - ss_series = self.siso_linsys * self.siso_linsys + ss_series = tsys.siso_linsys * tsys.siso_linsys np.testing.assert_array_equal(io_mul.A, ss_series.A) np.testing.assert_array_equal(io_mul.B, ss_series.B) np.testing.assert_array_equal(io_mul.C, ss_series.C) np.testing.assert_array_equal(io_mul.D, ss_series.D) # Make sure that series does the same thing - io_series = ct.series(iosys_siso, iosys_siso) - self.assertTrue(isinstance(io_series, ct.InputOutputSystem)) - self.assertTrue(isinstance(io_series, ct.StateSpace)) + io_series = ct.series(iosys_siso, iosys_siso2) + assert isinstance(io_series, ct.InputOutputSystem) + assert isinstance(io_series, ct.StateSpace) np.testing.assert_array_equal(io_series.A, ss_series.A) np.testing.assert_array_equal(io_series.B, ss_series.B) np.testing.assert_array_equal(io_series.C, ss_series.C) np.testing.assert_array_equal(io_series.D, ss_series.D) # Test out feedback as well - io_feedback = ct.feedback(iosys_siso, iosys_siso) - self.assertTrue(isinstance(io_series, ct.InputOutputSystem)) + io_feedback = ct.feedback(iosys_siso, iosys_siso2) + assert isinstance(io_series, ct.InputOutputSystem) # But also retain linear structure - self.assertTrue(isinstance(io_series, ct.StateSpace)) + assert isinstance(io_series, ct.StateSpace) # And make sure the systems match - ss_feedback = ct.feedback(self.siso_linsys, self.siso_linsys) + ss_feedback = ct.feedback(tsys.siso_linsys, tsys.siso_linsys) np.testing.assert_array_equal(io_feedback.A, ss_feedback.A) np.testing.assert_array_equal(io_feedback.B, ss_feedback.B) np.testing.assert_array_equal(io_feedback.C, ss_feedback.C) np.testing.assert_array_equal(io_feedback.D, ss_feedback.D) - def test_duplicates(self): - nlios = ios.NonlinearIOSystem(lambda t,x,u,params: x, \ - lambda t, x, u, params: u*u, \ - inputs=1, outputs=1, states=1, name="sys") + # Make sure series interconnections are done in the right order + ss_sys1 = ct.rss(2, 3, 2) + io_sys1 = ct.ss2io(ss_sys1) + ss_sys2 = ct.rss(2, 2, 3) + io_sys2 = ct.ss2io(ss_sys2) + io_series = io_sys2 * io_sys1 + assert io_series.ninputs == 2 + assert io_series.noutputs == 2 + assert io_series.nstates == 4 + + # While we are at it, check that the state space matrices match + ss_series = ss_sys2 * ss_sys1 + np.testing.assert_array_equal(io_series.A, ss_series.A) + np.testing.assert_array_equal(io_series.B, ss_series.B) + np.testing.assert_array_equal(io_series.C, ss_series.C) + np.testing.assert_array_equal(io_series.D, ss_series.D) - # Turn off deprecation warnings - warnings.simplefilter("ignore", category=DeprecationWarning) - warnings.simplefilter("ignore", category=PendingDeprecationWarning) + def test_docstring_example(self): + P = ct.LinearIOSystem( + ct.rss(2, 2, 2, strictly_proper=True), name='P') + C = ct.LinearIOSystem(ct.rss(2, 2, 2), name='C') + S = ct.InterconnectedSystem( + [C, P], + connections = [ + ['P.u[0]', 'C.y[0]'], ['P.u[1]', 'C.y[1]'], + ['C.u[0]', '-P.y[0]'], ['C.u[1]', '-P.y[1]']], + inplist = ['C.u[0]', 'C.u[1]'], + outlist = ['P.y[0]', 'P.y[1]'], + ) + ss_P = ct.StateSpace(P.linearize(0, 0)) + ss_C = ct.StateSpace(C.linearize(0, 0)) + ss_eye = ct.StateSpace( + [], np.zeros((0, 2)), np.zeros((2, 0)), np.eye(2)) + ss_S = ct.feedback(ss_P * ss_C, ss_eye) + io_S = S.linearize(0, 0) + np.testing.assert_array_almost_equal(io_S.A, ss_S.A) + np.testing.assert_array_almost_equal(io_S.B, ss_S.B) + np.testing.assert_array_almost_equal(io_S.C, ss_S.C) + np.testing.assert_array_almost_equal(io_S.D, ss_S.D) + + @pytest.mark.usefixtures("editsdefaults") + def test_duplicates(self, tsys): + nlios = ios.NonlinearIOSystem(lambda t, x, u, params: x, + lambda t, x, u, params: u * u, + inputs=1, outputs=1, states=1, + name="sys") # Duplicate objects - with warnings.catch_warnings(record=True) as warnval: - # Trigger a warning + with pytest.warns(UserWarning, match="Duplicate object"): ios_series = nlios * nlios - # Verify that we got a warning - self.assertEqual(len(warnval), 1) - self.assertTrue(issubclass(warnval[-1].category, UserWarning)) - self.assertTrue("Duplicate object" in str(warnval[-1].message)) - # Nonduplicate objects + ct.config.use_legacy_defaults('0.8.4') # changed delims in 0.9.0 + ct.config.use_numpy_matrix(False) # np.matrix deprecated nlios1 = nlios.copy() nlios2 = nlios.copy() - with warnings.catch_warnings(record=True) as warnval: + with pytest.warns(UserWarning, match="Duplicate name"): ios_series = nlios1 * nlios2 - self.assertEquals(len(warnval), 1) - # when subsystems have the same name, duplicates are - # renamed - self.assertTrue("copy of sys_1.x[0]" in ios_series.state_index.keys()) - self.assertTrue("copy of sys.x[0]" in ios_series.state_index.keys()) + assert "copy of sys_1.x[0]" in ios_series.state_index.keys() + assert "copy of sys.x[0]" in ios_series.state_index.keys() # Duplicate names - iosys_siso = ct.LinearIOSystem(self.siso_linsys) - nlios1 = ios.NonlinearIOSystem(None, \ - lambda t, x, u, params: u*u, inputs=1, outputs=1, name="sys") - nlios2 = ios.NonlinearIOSystem(None, \ - lambda t, x, u, params: u*u, inputs=1, outputs=1, name="sys") - with warnings.catch_warnings(record=True) as warnval: - # Trigger a warning - iosys = ct.InterconnectedSystem( - (nlios1, iosys_siso, nlios2), inputs=0, outputs=0, states=0) - - # Verify that we got a warning - self.assertEqual(len(warnval), 1) - self.assertTrue(issubclass(warnval[-1].category, UserWarning)) - self.assertTrue("Duplicate name" in str(warnval[-1].message)) + iosys_siso = ct.LinearIOSystem(tsys.siso_linsys) + nlios1 = ios.NonlinearIOSystem(None, + lambda t, x, u, params: u * u, + inputs=1, outputs=1, name="sys") + nlios2 = ios.NonlinearIOSystem(None, + lambda t, x, u, params: u * u, + inputs=1, outputs=1, name="sys") + + with pytest.warns(UserWarning, match="Duplicate name"): + ct.InterconnectedSystem([nlios1, iosys_siso, nlios2], + inputs=0, outputs=0, states=0) # Same system, different names => everything should be OK - nlios1 = ios.NonlinearIOSystem(None, \ - lambda t, x, u, params: u*u, inputs=1, outputs=1, name="nlios1") - nlios2 = ios.NonlinearIOSystem(None, \ - lambda t, x, u, params: u*u, inputs=1, outputs=1, name="nlios2") - with warnings.catch_warnings(record=True) as warnval: - iosys = ct.InterconnectedSystem( - (nlios1, iosys_siso, nlios2), inputs=0, outputs=0, states=0) - self.assertEqual(len(warnval), 0) + nlios1 = ios.NonlinearIOSystem(None, + lambda t, x, u, params: u * u, + inputs=1, outputs=1, name="nlios1") + nlios2 = ios.NonlinearIOSystem(None, + lambda t, x, u, params: u * u, + inputs=1, outputs=1, name="nlios2") + with pytest.warns(None) as record: + ct.InterconnectedSystem([nlios1, iosys_siso, nlios2], + inputs=0, outputs=0, states=0) + if record: + pytest.fail("Warning not expected: " + record[0].message) + + +def test_linear_interconnection(): + ss_sys1 = ct.rss(2, 2, 2, strictly_proper=True) + ss_sys2 = ct.rss(2, 2, 2) + io_sys1 = ios.LinearIOSystem( + ss_sys1, inputs = ('u[0]', 'u[1]'), + outputs = ('y[0]', 'y[1]'), name = 'sys1') + io_sys2 = ios.LinearIOSystem( + ss_sys2, inputs = ('u[0]', 'u[1]'), + outputs = ('y[0]', 'y[1]'), name = 'sys2') + nl_sys2 = ios.NonlinearIOSystem( + lambda t, x, u, params: np.array( + np.dot(ss_sys2.A, np.reshape(x, (-1, 1))) \ + + np.dot(ss_sys2.B, np.reshape(u, (-1, 1)))).reshape((-1,)), + lambda t, x, u, params: np.array( + np.dot(ss_sys2.C, np.reshape(x, (-1, 1))) \ + + np.dot(ss_sys2.D, np.reshape(u, (-1, 1)))).reshape((-1,)), + states = 2, + inputs = ('u[0]', 'u[1]'), + outputs = ('y[0]', 'y[1]'), + name = 'sys2') + + # Create a "regular" InterconnectedSystem + nl_connect = ios.interconnect( + (io_sys1, nl_sys2), + connections=[ + ['sys1.u[1]', 'sys2.y[0]'], + ['sys2.u[0]', 'sys1.y[1]'] + ], + inplist=[ + ['sys1.u[0]', 'sys1.u[1]'], + ['sys2.u[1]']], + outlist=[ + ['sys1.y[0]', '-sys2.y[0]'], + ['sys2.y[1]'], + ['sys2.u[1]']]) + assert isinstance(nl_connect, ios.InterconnectedSystem) + assert not isinstance(nl_connect, ios.LinearICSystem) + + # Now take its linearization + ss_connect = nl_connect.linearize(0, 0) + assert isinstance(ss_connect, ios.LinearIOSystem) + + io_connect = ios.interconnect( + (io_sys1, io_sys2), + connections=[ + ['sys1.u[1]', 'sys2.y[0]'], + ['sys2.u[0]', 'sys1.y[1]'] + ], + inplist=[ + ['sys1.u[0]', 'sys1.u[1]'], + ['sys2.u[1]']], + outlist=[ + ['sys1.y[0]', '-sys2.y[0]'], + ['sys2.y[1]'], + ['sys2.u[1]']]) + assert isinstance(io_connect, ios.InterconnectedSystem) + assert isinstance(io_connect, ios.LinearICSystem) + assert isinstance(io_connect, ios.LinearIOSystem) + assert isinstance(io_connect, ct.StateSpace) + + # Finally compare the linearization with the linear system + np.testing.assert_array_almost_equal(io_connect.A, ss_connect.A) + np.testing.assert_array_almost_equal(io_connect.B, ss_connect.B) + np.testing.assert_array_almost_equal(io_connect.C, ss_connect.C) + np.testing.assert_array_almost_equal(io_connect.D, ss_connect.D) -# Predator prey dynamics def predprey(t, x, u, params={}): + """Predator prey dynamics""" r = params.get('r', 2) d = params.get('d', 0.7) b = params.get('b', 0.3) @@ -1096,8 +1349,8 @@ def predprey(t, x, u, params={}): return np.array([dx0, dx1]) -# Reduced planar vertical takeoff and landing dynamics def pvtol(t, x, u, params={}): + """Reduced planar vertical takeoff and landing dynamics""" from math import sin, cos m = params.get('m', 4.) # kg, system mass J = params.get('J', 0.0475) # kg m^2, system inertia @@ -1112,6 +1365,7 @@ def pvtol(t, x, u, params={}): -l/J * sin(x[0]) + r/J * u[0] ]) + def pvtol_full(t, x, u, params={}): from math import sin, cos m = params.get('m', 4.) # kg, system mass @@ -1128,8 +1382,8 @@ def pvtol_full(t, x, u, params={}): ]) -# Second order system dynamics def secord_update(t, x, u, params={}): + """Second order system dynamics""" omega0 = params.get('omega0', 1.) zeta = params.get('zeta', 0.5) u = np.array(u, ndmin=1) @@ -1137,9 +1391,8 @@ def secord_update(t, x, u, params={}): x[1], -2 * zeta * omega0 * x[1] - omega0*omega0 * x[0] + u[0] ]) -def secord_output(t, x, u, params={}): - return np.array([x[0]]) -if __name__ == '__main__': - unittest.main() +def secord_output(t, x, u, params={}): + """Second order system dynamics output""" + return np.array([x[0]]) diff --git a/control/tests/lti_test.py b/control/tests/lti_test.py index ed832fb05..1bf633e84 100644 --- a/control/tests/lti_test.py +++ b/control/tests/lti_test.py @@ -1,14 +1,18 @@ -#!/usr/bin/env python +"""lti_test.py""" -import unittest import numpy as np -from control.lti import * -from control.xferfcn import tf -from control import c2d -from control.matlab import tf2ss +import pytest +from .conftest import editsdefaults + +import control as ct +from control import c2d, tf, tf2ss, NonlinearIOSystem +from control.lti import (LTI, common_timebase, damp, dcgain, isctime, isdtime, + issiso, pole, timebaseEqual, zero) +from control.tests.conftest import slycotonly from control.exception import slycot_check -class TestUtils(unittest.TestCase): +class TestLTI: + def test_pole(self): sys = tf(126, [-1, 42]) np.testing.assert_equal(sys.pole(), 42) @@ -20,31 +24,32 @@ def test_zero(self): np.testing.assert_equal(zero(sys), 42) def test_issiso(self): - self.assertEqual(issiso(1), True) - self.assertRaises(ValueError, issiso, 1, strict=True) + assert issiso(1) + with pytest.raises(ValueError): + issiso(1, strict=True) # SISO transfer function sys = tf([-1, 42], [1, 10]) - self.assertEqual(issiso(sys), True) - self.assertEqual(issiso(sys, strict=True), True) + assert issiso(sys) + assert issiso(sys, strict=True) # SISO state space system sys = tf2ss(sys) - self.assertEqual(issiso(sys), True) - self.assertEqual(issiso(sys, strict=True), True) + assert issiso(sys) + assert issiso(sys, strict=True) - @unittest.skipIf(not slycot_check(), "slycot not installed") + @slycotonly def test_issiso_mimo(self): # MIMO transfer function sys = tf([[[-1, 41], [1]], [[1, 2], [3, 4]]], [[[1, 10], [1, 20]], [[1, 30], [1, 40]]]); - self.assertEqual(issiso(sys), False) - self.assertEqual(issiso(sys, strict=True), False) + assert not issiso(sys) + assert not issiso(sys, strict=True) # MIMO state space system sys = tf2ss(sys) - self.assertEqual(issiso(sys), False) - self.assertEqual(issiso(sys, strict=True), False) + assert not issiso(sys) + assert not issiso(sys, strict=True) def test_damp(self): # Test the continuous time case. @@ -70,6 +75,192 @@ def test_dcgain(self): np.testing.assert_equal(sys.dcgain(), 42) np.testing.assert_equal(dcgain(sys), 42) + @pytest.mark.parametrize("dt1, dt2, expected", + [(None, None, True), + (None, 0, True), + (None, 1, True), + pytest.param(None, True, True, + marks=pytest.mark.xfail( + reason="returns false")), + (0, 0, True), + (0, 1, False), + (0, True, False), + (1, 1, True), + (1, 2, False), + (1, True, False), + (True, True, True)]) + def test_timebaseEqual_deprecated(self, dt1, dt2, expected): + """Test that timbaseEqual throws a warning and returns as documented""" + sys1 = tf([1], [1, 2, 3], dt1) + sys2 = tf([1], [1, 4, 5], dt2) + + print(sys1.dt) + print(sys2.dt) + + with pytest.deprecated_call(): + assert timebaseEqual(sys1, sys2) is expected + # Make sure behaviour is symmetric + with pytest.deprecated_call(): + assert timebaseEqual(sys2, sys1) is expected + + @pytest.mark.parametrize("dt1, dt2, expected", + [(None, None, None), + (None, 0, 0), + (None, 1, 1), + (None, True, True), + (True, True, True), + (True, 1, 1), + (1, 1, 1), + (0, 0, 0), + ]) + @pytest.mark.parametrize("sys1", [True, False]) + @pytest.mark.parametrize("sys2", [True, False]) + def test_common_timebase(self, dt1, dt2, expected, sys1, sys2): + """Test that common_timbase adheres to :ref:`conventions-ref`""" + i1 = tf([1], [1, 2, 3], dt1) if sys1 else dt1 + i2 = tf([1], [1, 4, 5], dt2) if sys2 else dt2 + assert common_timebase(i1, i2) == expected + # Make sure behaviour is symmetric + assert common_timebase(i2, i1) == expected + + @pytest.mark.parametrize("i1, i2", + [(True, 0), + (0, 1), + (1, 2)]) + def test_common_timebase_errors(self, i1, i2): + """Test that common_timbase throws errors on invalid combinations""" + with pytest.raises(ValueError): + common_timebase(i1, i2) + # Make sure behaviour is symmetric + with pytest.raises(ValueError): + common_timebase(i2, i1) + + @pytest.mark.parametrize("dt, ref, strictref", + [(None, True, False), + (0, False, False), + (1, True, True), + (True, True, True)]) + @pytest.mark.parametrize("objfun, arg", + [(LTI, ()), + (NonlinearIOSystem, (lambda x: x, ))]) + def test_isdtime(self, objfun, arg, dt, ref, strictref): + """Test isdtime and isctime functions to follow convention""" + obj = objfun(*arg, dt=dt) + + assert isdtime(obj) == ref + assert isdtime(obj, strict=True) == strictref + + if dt is not None: + ref = not ref + strictref = not strictref + assert isctime(obj) == ref + assert isctime(obj, strict=True) == strictref + + @pytest.mark.usefixtures("editsdefaults") + @pytest.mark.parametrize("fcn", [ct.ss, ct.tf, ct.frd, ct.ss2io]) + @pytest.mark.parametrize("nstate, nout, ninp, omega, squeeze, shape", [ + [1, 1, 1, 0.1, None, ()], # SISO + [1, 1, 1, [0.1], None, (1,)], + [1, 1, 1, [0.1, 1, 10], None, (3,)], + [2, 1, 1, 0.1, True, ()], + [2, 1, 1, [0.1], True, ()], + [2, 1, 1, [0.1, 1, 10], True, (3,)], + [3, 1, 1, 0.1, False, (1, 1)], + [3, 1, 1, [0.1], False, (1, 1, 1)], + [3, 1, 1, [0.1, 1, 10], False, (1, 1, 3)], + [1, 2, 1, 0.1, None, (2, 1)], # SIMO + [1, 2, 1, [0.1], None, (2, 1, 1)], + [1, 2, 1, [0.1, 1, 10], None, (2, 1, 3)], + [2, 2, 1, 0.1, True, (2,)], + [2, 2, 1, [0.1], True, (2,)], + [3, 2, 1, 0.1, False, (2, 1)], + [3, 2, 1, [0.1], False, (2, 1, 1)], + [3, 2, 1, [0.1, 1, 10], False, (2, 1, 3)], + [1, 1, 2, [0.1, 1, 10], None, (1, 2, 3)], # MISO + [2, 1, 2, [0.1, 1, 10], True, (2, 3)], + [3, 1, 2, [0.1, 1, 10], False, (1, 2, 3)], + [1, 2, 2, [0.1, 1, 10], None, (2, 2, 3)], # MIMO + [2, 2, 2, [0.1, 1, 10], True, (2, 2, 3)], + [3, 2, 2, [0.1, 1, 10], False, (2, 2, 3)] + ]) + def test_squeeze(self, fcn, nstate, nout, ninp, omega, squeeze, shape): + # Create the system to be tested + if fcn == ct.frd: + sys = fcn(ct.rss(nstate, nout, ninp), [1e-2, 1e-1, 1, 1e1, 1e2]) + elif fcn == ct.tf and (nout > 1 or ninp > 1) and not slycot_check(): + pytest.skip("Conversion of MIMO systems to transfer functions " + "requires slycot.") + else: + sys = fcn(ct.rss(nstate, nout, ninp)) + + # Convert the frequency list to an array for easy of use + isscalar = not hasattr(omega, '__len__') + omega = np.array(omega) + + # Call the transfer function directly and make sure shape is correct + assert sys(omega * 1j, squeeze=squeeze).shape == shape + + # Make sure that evalfr also works as expected + assert ct.evalfr(sys, omega * 1j, squeeze=squeeze).shape == shape + + # Check frequency response + mag, phase, _ = sys.frequency_response(omega, squeeze=squeeze) + if isscalar and squeeze is not True: + # sys.frequency_response() expects a list as an argument + # Add the shape of the input to the expected shape + assert mag.shape == shape + (1,) + assert phase.shape == shape + (1,) + else: + assert mag.shape == shape + assert phase.shape == shape + + # Make sure the default shape lines up with squeeze=None case + if squeeze is None: + assert sys(omega * 1j).shape == shape + + # Changing config.default to False should return 3D frequency response + ct.config.set_defaults('control', squeeze_frequency_response=False) + mag, phase, _ = sys.frequency_response(omega) + if isscalar: + assert mag.shape == (sys.noutputs, sys.ninputs, 1) + assert phase.shape == (sys.noutputs, sys.ninputs, 1) + assert sys(omega * 1j).shape == (sys.noutputs, sys.ninputs) + assert ct.evalfr(sys, omega * 1j).shape == (sys.noutputs, sys.ninputs) + else: + assert mag.shape == (sys.noutputs, sys.ninputs, len(omega)) + assert phase.shape == (sys.noutputs, sys.ninputs, len(omega)) + assert sys(omega * 1j).shape == \ + (sys.noutputs, sys.ninputs, len(omega)) + assert ct.evalfr(sys, omega * 1j).shape == \ + (sys.noutputs, sys.ninputs, len(omega)) + + @pytest.mark.parametrize("fcn", [ct.ss, ct.tf, ct.frd, ct.ss2io]) + def test_squeeze_exceptions(self, fcn): + if fcn == ct.frd: + sys = fcn(ct.rss(2, 1, 1), [1e-2, 1e-1, 1, 1e1, 1e2]) + else: + sys = fcn(ct.rss(2, 1, 1)) + + with pytest.raises(ValueError, match="unknown squeeze value"): + sys.frequency_response([1], squeeze=1) + sys([1], squeeze='siso') + evalfr(sys, [1], squeeze='siso') + + with pytest.raises(ValueError, match="must be 1D"): + sys.frequency_response([[0.1, 1], [1, 10]]) + sys([[0.1, 1], [1, 10]]) + evalfr(sys, [[0.1, 1], [1, 10]]) + + with pytest.warns(DeprecationWarning, match="LTI `inputs`"): + ninputs = sys.inputs + assert ninputs == sys.ninputs + + with pytest.warns(DeprecationWarning, match="LTI `outputs`"): + noutputs = sys.outputs + assert noutputs == sys.noutputs -if __name__ == "__main__": - unittest.main() + if isinstance(sys, ct.StateSpace): + with pytest.warns( + DeprecationWarning, match="StateSpace `states`"): + nstates = sys.states + assert nstates == sys.nstates diff --git a/control/tests/margin_test.py b/control/tests/margin_test.py old mode 100755 new mode 100644 index 80916da1b..a1246103f --- a/control/tests/margin_test.py +++ b/control/tests/margin_test.py @@ -93,7 +93,7 @@ def test_stability_margins_3input(tsys): sys, refout, refoutall = tsys """Test stability_margins() function with mag, phase, omega input""" omega = np.logspace(-2, 2, 2000) - mag, phase, omega_ = sys.freqresp(omega) + mag, phase, omega_ = sys.frequency_response(omega) out = stability_margins((mag, phase*180/np.pi, omega_)) assert_allclose(out, refout, atol=1.5e-3) @@ -104,12 +104,11 @@ def test_margin_sys(tsys): out = margin(sys) assert_allclose(out, np.array(refout)[[0, 1, 3, 4]], atol=1.5e-3) - def test_margin_3input(tsys): sys, refout, refoutall = tsys """Test margin() function with mag, phase, omega input""" omega = np.logspace(-2, 2, 2000) - mag, phase, omega_ = sys.freqresp(omega) + mag, phase, omega_ = sys.frequency_response(omega) out = margin((mag, phase*180/np.pi, omega_)) assert_allclose(out, np.array(refout)[[0, 1, 3, 4]], atol=1.5e-3) @@ -145,7 +144,7 @@ def test_mag_phase_omega(): sys = TransferFunction(15, [1, 6, 11, 6]) out = stability_margins(sys) omega = np.logspace(-2, 2, 1000) - mag, phase, omega = sys.freqresp(omega) + mag, phase, omega = sys.frequency_response(omega) out2 = stability_margins((mag, phase*180/np.pi, omega)) ind = [0, 1, 3, 4] # indices of gm, pm, wg, wp -- ignore sm marg1 = np.array(out)[ind] @@ -337,16 +336,41 @@ def test_zmore_stability_margins(tsys_zmore): @pytest.mark.parametrize( 'cnum, cden, dt,' 'ref,' - 'rtol', - [([2], [1, 3, 2, 0], 1e-2, # gh-465 - (2.9558, 32.8170, 0.43584, 1.4037, 0.74953, 0.97079), - 0.1 # very crude tolerance, because the gradients are not great - ), - ([2], [1, 3, 3, 1], .1, # 2/(s+1)**3 - [3.4927, 69.9996, 0.5763, 1.6283, 0.7631, 1.2019], - 1e-3)]) -def test_stability_margins_discrete(cnum, cden, dt, ref, rtol): + 'rtol, poly_is_inaccurate', + [( # gh-465 + [2], [1, 3, 2, 0], 1e-2, + [ 2.955761, 32.398492, 0.429535, 1.403725, 0.749367, 0.923898], + 1e-5, True), + ( # 2/(s+1)**3 + [2], [1, 3, 3, 1], .1, + [3.4927, 65.4212, 0.5763, 1.6283, 0.76625, 1.2019], + 1e-4, True), + ( # gh-523 a + [1.1 * 4 * np.pi**2], [1, 2 * 0.2 * 2 * np.pi, 4 * np.pi**2], .05, + [2.3842, 18.161, 0.26953, 11.712, 8.7478, 9.1504], + 1e-4, False), + ( # gh-523 b + # H1 = w1**2 / (z**2 + 2*zt*w1 * z + w1**2) + # H2 = w2**2 / (z**2 + 2*zt*w2 * z + w2**2) + # H = H1 * H2 + # w1 = 1, w2 = 100, zt = 0.5 + [5e4], [1., 101., 10101., 10100., 10000.], 1e-3, + [18.8766, 26.3564, 0.406841, 9.76358, 2.32933, 2.55986], + 1e-5, True), + ]) +@pytest.mark.filterwarnings("error") +def test_stability_margins_discrete(cnum, cden, dt, + ref, + rtol, poly_is_inaccurate): """Test stability_margins with discrete TF input""" tf = TransferFunction(cnum, cden).sample(dt) - out = stability_margins(tf) + if poly_is_inaccurate: + with pytest.warns(UserWarning, match="numerical inaccuracy in 'poly'"): + out = stability_margins(tf) + # cover the explicit frd branch and make sure it yields the same + # results as the fallback mechanism + out_frd = stability_margins(tf, method='frd') + assert_allclose(out, out_frd) + else: + out = stability_margins(tf) assert_allclose(out, ref, rtol=rtol) diff --git a/control/tests/mateqn_test.py b/control/tests/mateqn_test.py index 29f31c853..facb1ce08 100644 --- a/control/tests/mateqn_test.py +++ b/control/tests/mateqn_test.py @@ -1,15 +1,6 @@ -#!/usr/bin/env python -from __future__ import print_function -# -# mateqn_test.py - test wuit for matrix equation solvers -# -#! Currently uses numpy.testing framework; will dump you out of unittest -#! if an error occurs. Should figure out the right way to fix this. +"""mateqn_test.py - test suite for matrix equation solvers -""" Test cases for lyap, dlyap, care and dare functions in the file -pyctrl_lin_alg.py. """ - -"""Copyright (c) 2011, All rights reserved. +Copyright (c) 2020, All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions @@ -42,18 +33,18 @@ Author: Bjorn Olofsson """ -import unittest -from numpy import array -from numpy.testing import assert_array_almost_equal, assert_array_less, \ - assert_raises -# need scipy version of eigvals for generalized eigenvalue problem +from numpy import array, zeros +from numpy.testing import assert_array_almost_equal, assert_array_less +import pytest from scipy.linalg import eigvals, solve -from scipy import zeros,dot -from control.mateqn import lyap,dlyap,care,dare -from control.exception import slycot_check, ControlArgument -@unittest.skipIf(not slycot_check(), "slycot not installed") -class TestMatrixEquations(unittest.TestCase): +from control.mateqn import lyap, dlyap, care, dare +from control.exception import ControlArgument +from control.tests.conftest import slycotonly + + +@slycotonly +class TestMatrixEquations: """These are tests for the matrix equation solvers in mateqn.py""" def test_lyap(self): @@ -90,7 +81,8 @@ def test_lyap_g(self): E = array([[1,2],[2,1]]) X = lyap(A,Q,None,E) # print("The solution obtained is ", X) - assert_array_almost_equal(A.dot(X).dot(E.T) + E.dot(X).dot(A.T) + Q, zeros((2,2))) + assert_array_almost_equal(A.dot(X).dot(E.T) + E.dot(X).dot(A.T) + Q, + zeros((2,2))) def test_dlyap(self): A = array([[-0.6, 0],[-0.1, -0.4]]) @@ -111,7 +103,8 @@ def test_dlyap_g(self): E = array([[1, 1],[2, 1]]) X = dlyap(A,Q,None,E) # print("The solution obtained is ", X) - assert_array_almost_equal(A.dot(X).dot(A.T) - E.dot(X).dot(E.T) + Q, zeros((2,2))) + assert_array_almost_equal(A.dot(X).dot(A.T) - E.dot(X).dot(E.T) + Q, + zeros((2,2))) def test_dlyap_sylvester(self): A = 5 @@ -135,7 +128,8 @@ def test_care(self): X,L,G = care(A,B,Q) # print("The solution obtained is", X) - assert_array_almost_equal(A.T.dot(X) + X.dot(A) - X.dot(B).dot(B.T).dot(X) + Q, + M = A.T.dot(X) + X.dot(A) - X.dot(B).dot(B.T).dot(X) + Q + assert_array_almost_equal(M, zeros((2,2))) assert_array_almost_equal(B.T.dot(X), G) @@ -156,6 +150,7 @@ def test_care_g(self): - (E.T.dot(X).dot(B) + S).dot(Gref) + Q, zeros((2,2))) + def test_care_g2(self): A = array([[-2, -1],[-1, -1]]) Q = array([[0, 0],[0, 1]]) B = array([[1],[0]]) @@ -183,9 +178,7 @@ def test_dare(self): Gref = solve(B.T.dot(X).dot(B) + R, B.T.dot(X).dot(A)) assert_array_almost_equal(Gref, G) assert_array_almost_equal( - A.T.dot(X).dot(A) - X - - A.T.dot(X).dot(B).dot(Gref) + Q, - zeros((2,2))) + X, A.T.dot(X).dot(A) - A.T.dot(X).dot(B).dot(Gref) + Q) # check for stable closed loop lam = eigvals(A - B.dot(G)) assert_array_less(abs(lam), 1.0) @@ -197,10 +190,13 @@ def test_dare(self): X,L,G = dare(A,B,Q,R) # print("The solution obtained is", X) + AtXA = A.T.dot(X).dot(A) + AtXB = A.T.dot(X).dot(B) + BtXA = B.T.dot(X).dot(A) + BtXB = B.T.dot(X).dot(B) assert_array_almost_equal( - A.T.dot(X).dot(A) - X - - A.T.dot(X).dot(B) * solve(B.T.dot(X).dot(B) + R, B.T.dot(X).dot(A)) + Q, zeros((2,2))) - assert_array_almost_equal(B.T.dot(X).dot(A) / (B.T.dot(X).dot(B) + R), G) + X, AtXA - AtXB.dot(solve(BtXB + R, BtXA)) + Q) + assert_array_almost_equal(BtXA / (BtXB + R), G) # check for stable closed loop lam = eigvals(A - B.dot(G)) assert_array_less(abs(lam), 1.0) @@ -216,29 +212,32 @@ def test_dare_g(self): X,L,G = dare(A,B,Q,R,S,E) # print("The solution obtained is", X) Gref = solve(B.T.dot(X).dot(B) + R, B.T.dot(X).dot(A) + S.T) - assert_array_almost_equal(Gref,G) + assert_array_almost_equal(Gref, G) assert_array_almost_equal( - A.T.dot(X).dot(A) - E.T.dot(X).dot(E) - - (A.T.dot(X).dot(B) + S).dot(Gref) + Q, - zeros((2,2)) ) + E.T.dot(X).dot(E), + A.T.dot(X).dot(A) - (A.T.dot(X).dot(B) + S).dot(Gref) + Q) # check for stable closed loop lam = eigvals(A - B.dot(G), E) assert_array_less(abs(lam), 1.0) - A = array([[-0.6, 0],[-0.1, -0.4]]) - Q = array([[2, 1],[1, 3]]) - B = array([[1],[2]]) + def test_dare_g2(self): + A = array([[-0.6, 0], [-0.1, -0.4]]) + Q = array([[2, 1], [1, 3]]) + B = array([[1], [2]]) R = 1 - S = array([[1],[2]]) - E = array([[2, 1],[1, 2]]) + S = array([[1], [2]]) + E = array([[2, 1], [1, 2]]) - X,L,G = dare(A,B,Q,R,S,E) + X, L, G = dare(A, B, Q, R, S, E) # print("The solution obtained is", X) + AtXA = A.T.dot(X).dot(A) + AtXB = A.T.dot(X).dot(B) + BtXA = B.T.dot(X).dot(A) + BtXB = B.T.dot(X).dot(B) + EtXE = E.T.dot(X).dot(E) assert_array_almost_equal( - A.T.dot(X).dot(A) - E.T.dot(X).dot(E) - - (A.T.dot(X).dot(B) + S).dot(solve(B.T.dot(X).dot(B) + R, B.T.dot(X).dot(A) + S.T)) + Q, - zeros((2,2)) ) - assert_array_almost_equal((B.T.dot(X).dot(A) + S.T) / (B.T.dot(X).dot(B) + R), G) + EtXE, AtXA - (AtXB + S).dot(solve(BtXB + R, BtXA + S.T)) + Q) + assert_array_almost_equal((BtXA + S.T) / (BtXB + R), G) # check for stable closed loop lam = eigvals(A - B.dot(G), E) assert_array_less(abs(lam), 1.0) @@ -260,16 +259,26 @@ def test_raise(self): Efq = array([[2, 1, 0], [1, 2, 0]]) for cdlyap in [lyap, dlyap]: - assert_raises(ControlArgument, cdlyap, Afq, Q) - assert_raises(ControlArgument, cdlyap, A, Qfq) - assert_raises(ControlArgument, cdlyap, A, Qfs) - assert_raises(ControlArgument, cdlyap, Afq, Q, C) - assert_raises(ControlArgument, cdlyap, A, Qfq, C) - assert_raises(ControlArgument, cdlyap, A, Q, Cfd) - assert_raises(ControlArgument, cdlyap, A, Qfq, None, E) - assert_raises(ControlArgument, cdlyap, A, Q, None, Efq) - assert_raises(ControlArgument, cdlyap, A, Qfs, None, E) - assert_raises(ControlArgument, cdlyap, A, Q, C, E) + with pytest.raises(ControlArgument): + cdlyap(Afq, Q) + with pytest.raises(ControlArgument): + cdlyap(A, Qfq) + with pytest.raises(ControlArgument): + cdlyap(A, Qfs) + with pytest.raises(ControlArgument): + cdlyap(Afq, Q, C) + with pytest.raises(ControlArgument): + cdlyap(A, Qfq, C) + with pytest.raises(ControlArgument): + cdlyap(A, Q, Cfd) + with pytest.raises(ControlArgument): + cdlyap(A, Qfq, None, E) + with pytest.raises(ControlArgument): + cdlyap(A, Q, None, Efq) + with pytest.raises(ControlArgument): + cdlyap(A, Qfs, None, E) + with pytest.raises(ControlArgument): + cdlyap(A, Q, C, E) B = array([[1, 0], [0, 1]]) Bf = array([[1, 0], [0, 1], [1, 1]]) @@ -281,23 +290,34 @@ def test_raise(self): E = array([[2, 1], [1, 2]]) Ef = array([[2, 1], [1, 2], [1, 2]]) - assert_raises(ControlArgument, care, Afq, B, Q) - assert_raises(ControlArgument, care, A, B, Qfq) - assert_raises(ControlArgument, care, A, Bf, Q) - assert_raises(ControlArgument, care, 1, B, 1) - assert_raises(ControlArgument, care, A, B, Qfs) - assert_raises(ValueError, dare, A, B, Q, Rfs) + with pytest.raises(ControlArgument): + care(Afq, B, Q) + with pytest.raises(ControlArgument): + care(A, B, Qfq) + with pytest.raises(ControlArgument): + care(A, Bf, Q) + with pytest.raises(ControlArgument): + care(1, B, 1) + with pytest.raises(ControlArgument): + care(A, B, Qfs) + with pytest.raises(ValueError): + dare(A, B, Q, Rfs) for cdare in [care, dare]: - assert_raises(ControlArgument, cdare, Afq, B, Q, R, S, E) - assert_raises(ControlArgument, cdare, A, B, Qfq, R, S, E) - assert_raises(ControlArgument, cdare, A, Bf, Q, R, S, E) - assert_raises(ControlArgument, cdare, A, B, Q, R, S, Ef) - assert_raises(ControlArgument, cdare, A, B, Q, Rfq, S, E) - assert_raises(ControlArgument, cdare, A, B, Q, R, Sf, E) - assert_raises(ControlArgument, cdare, A, B, Qfs, R, S, E) - assert_raises(ControlArgument, cdare, A, B, Q, Rfs, S, E) - assert_raises(ControlArgument, cdare, A, B, Q, R, S) - - -if __name__ == "__main__": - unittest.main() + with pytest.raises(ControlArgument): + cdare(Afq, B, Q, R, S, E) + with pytest.raises(ControlArgument): + cdare(A, B, Qfq, R, S, E) + with pytest.raises(ControlArgument): + cdare(A, Bf, Q, R, S, E) + with pytest.raises(ControlArgument): + cdare(A, B, Q, R, S, Ef) + with pytest.raises(ControlArgument): + cdare(A, B, Q, Rfq, S, E) + with pytest.raises(ControlArgument): + cdare(A, B, Q, R, Sf, E) + with pytest.raises(ControlArgument): + cdare(A, B, Qfs, R, S, E) + with pytest.raises(ControlArgument): + cdare(A, B, Q, Rfs, S, E) + with pytest.raises(ControlArgument): + cdare(A, B, Q, R, S) diff --git a/control/tests/test_control_matlab.py b/control/tests/matlab2_test.py similarity index 80% rename from control/tests/test_control_matlab.py rename to control/tests/matlab2_test.py index aa8633e7c..633ceef6f 100644 --- a/control/tests/test_control_matlab.py +++ b/control/tests/matlab2_test.py @@ -1,30 +1,30 @@ -''' -Copyright (C) 2011 by Eike Welk. +"""matlab2_test.py Test the control.matlab toolbox. -''' -import unittest +Copyright (C) 2011 by Eike Welk. +""" + +from matplotlib.pyplot import figure, plot, legend, subplot2grid import numpy as np -import scipy.signal +from numpy import array, matrix, zeros, linspace, r_ from numpy.testing import assert_array_almost_equal -from numpy import array, asarray, matrix, asmatrix, zeros, ones, linspace,\ - all, hstack, vstack, c_, r_ -from matplotlib.pyplot import show, figure, plot, legend, subplot2grid -from control.matlab import ss, step, impulse, initial, lsim, dcgain, \ - ss2tf + +import pytest +import scipy.signal + +from control.matlab import ss, step, impulse, initial, lsim, dcgain, ss2tf from control.statesp import _mimo2siso from control.timeresp import _check_convert_array -from control.exception import slycot_check -import warnings +from control.tests.conftest import slycotonly -class TestControlMatlab(unittest.TestCase): - def setUp(self): - pass +class TestControlMatlab: + """Test the control.matlab toolbox.""" - def make_SISO_mats(self): + @pytest.fixture + def SISO_mats(self): """Return matrices for a SISO system""" A = array([[-81.82, -45.45], [ 10., -1. ]]) @@ -34,7 +34,8 @@ def make_SISO_mats(self): D = zeros((1, 1)) return A, B, C, D - def make_MIMO_mats(self): + @pytest.fixture + def MIMO_mats(self): """Return matrices for a MIMO system""" A = array([[-81.82, -45.45, 0, 0 ], [ 10, -1, 0, 0 ], @@ -49,39 +50,40 @@ def make_MIMO_mats(self): D = zeros((2, 2)) return A, B, C, D - def test_dcgain(self): - """Test function dcgain with different systems""" - if slycot_check(): - #Test MIMO systems - A, B, C, D = self.make_MIMO_mats() - - gain1 = dcgain(ss(A, B, C, D)) - gain2 = dcgain(A, B, C, D) - sys_tf = ss2tf(A, B, C, D) - gain3 = dcgain(sys_tf) - gain4 = dcgain(sys_tf.num, sys_tf.den) - #print("gain1:", gain1) - - assert_array_almost_equal(gain1, - array([[0.0269, 0. ], - [0. , 0.0269]]), - decimal=4) - assert_array_almost_equal(gain1, gain2) - assert_array_almost_equal(gain3, gain4) - assert_array_almost_equal(gain1, gain4) - - #Test SISO systems - A, B, C, D = self.make_SISO_mats() + @slycotonly + def test_dcgain_mimo(self, MIMO_mats): + """Test function dcgain with MIMO systems""" + #Test MIMO systems + A, B, C, D = MIMO_mats + + gain1 = dcgain(ss(A, B, C, D)) + gain2 = dcgain(A, B, C, D) + sys_tf = ss2tf(A, B, C, D) + gain3 = dcgain(sys_tf) + gain4 = dcgain(sys_tf.num, sys_tf.den) + #print("gain1:", gain1) + + assert_array_almost_equal(gain1, + array([[0.0269, 0. ], + [0. , 0.0269]]), + decimal=4) + assert_array_almost_equal(gain1, gain2) + assert_array_almost_equal(gain3, gain4) + assert_array_almost_equal(gain1, gain4) + + def test_dcgain_siso(self, SISO_mats): + """Test function dcgain with SISO systems""" + A, B, C, D = SISO_mats gain1 = dcgain(ss(A, B, C, D)) assert_array_almost_equal(gain1, array([[0.0269]]), decimal=4) - def test_dcgain_2(self): + def test_dcgain_2(self, SISO_mats): """Test function dcgain with different systems""" #Create different forms of a SISO system - A, B, C, D = self.make_SISO_mats() + A, B, C, D = SISO_mats num, den = scipy.signal.ss2tf(A, B, C, D) # numerator is only a constant here; pick it out to avoid numpy warning Z, P, k = scipy.signal.tf2zpk(num[0][-1], den) @@ -108,39 +110,39 @@ def test_dcgain_2(self): 0.026948], decimal=6) - def test_step(self): + def test_step(self, SISO_mats, MIMO_mats, mplcleanup): """Test function ``step``.""" figure(); plot_shape = (1, 3) #Test SISO system - A, B, C, D = self.make_SISO_mats() + A, B, C, D = SISO_mats sys = ss(A, B, C, D) #print(sys) #print("gain:", dcgain(sys)) subplot2grid(plot_shape, (0, 0)) - t, y = step(sys) + y, t = step(sys) plot(t, y) subplot2grid(plot_shape, (0, 1)) T = linspace(0, 2, 100) X0 = array([1, 1]) - t, y = step(sys, T, X0) + y, t = step(sys, T, X0) plot(t, y) # Test output of state vector - t, y, x = step(sys, return_x=True) + y, t, x = step(sys, return_x=True) #Test MIMO system - A, B, C, D = self.make_MIMO_mats() + A, B, C, D = MIMO_mats sys = ss(A, B, C, D) subplot2grid(plot_shape, (0, 2)) - t, y = step(sys) - plot(t, y) + y, t = step(sys) + plot(t, y[:, 0, 0]) - def test_impulse(self): - A, B, C, D = self.make_SISO_mats() + def test_impulse(self, SISO_mats, mplcleanup): + A, B, C, D = SISO_mats sys = ss(A, B, C, D) figure() @@ -158,23 +160,23 @@ def test_impulse(self): #Test system with direct feed-though, the function should print a warning. D = [[0.5]] sys_ft = ss(A, B, C, D) - with warnings.catch_warnings(): - warnings.simplefilter("ignore") + with pytest.warns(UserWarning, match="has direct feedthrough"): t, y = impulse(sys_ft) plot(t, y, label='Direct feedthrough D=[[0.5]]') + def test_impulse_mimo(self, MIMO_mats, mplcleanup): #Test MIMO system - A, B, C, D = self.make_MIMO_mats() - sys = ss(A, B, C, D) - t, y = impulse(sys) - plot(t, y, label='MIMO System') + A, B, C, D = MIMO_mats + sys = ss(A, B, C, D) + y, t = impulse(sys) + plot(t, y[:, :, 0], label='MIMO System') legend(loc='best') #show() - def test_initial(self): - A, B, C, D = self.make_SISO_mats() + def test_initial(self, SISO_mats, MIMO_mats, mplcleanup): + A, B, C, D = SISO_mats sys = ss(A, B, C, D) figure(); plot_shape = (1, 3) @@ -186,11 +188,10 @@ def test_initial(self): #X0=[1,1] : produces a spike subplot2grid(plot_shape, (0, 1)) - t, y = initial(sys, X0=array(matrix("1; 1"))) + t, y = initial(sys, X0=array([[1], [1]])) plot(t, y) - #Test MIMO system - A, B, C, D = self.make_MIMO_mats() + A, B, C, D = MIMO_mats sys = ss(A, B, C, D) #X0=[1,1] : produces same spike as above spike subplot2grid(plot_shape, (0, 2)) @@ -200,7 +201,8 @@ def test_initial(self): #show() #! Old test; no longer functional?? (RMM, 3 Nov 2012) - @unittest.skip("skipping test_check_convert_shape, need to update test") + @pytest.mark.skip( + reason="skipping test_check_convert_shape, need to update test") def test_check_convert_shape(self): #TODO: check if shape is correct everywhere. #Correct input --------------------------------------------- @@ -270,9 +272,9 @@ def test_check_convert_shape(self): self.assertRaises(ValueError, _check_convert_array(array([1., 2, 3, 4]), [(3,), (1,3)], 'Test: ')) - @unittest.skip("skipping test_lsim, need to update test") - def test_lsim(self): - A, B, C, D = self.make_SISO_mats() + @pytest.mark.skip(reason="need to update test") + def test_lsim(self, SISO_mats, MIMO_mats): + A, B, C, D = SISO_mats sys = ss(A, B, C, D) figure(); plot_shape = (2, 2) @@ -304,7 +306,7 @@ def test_lsim(self): #Test with MIMO system subplot2grid(plot_shape, (1, 1)) - A, B, C, D = self.make_MIMO_mats() + A, B, C, D = MIMO_mats sys = ss(A, B, C, D) t = array(linspace(0, 1, 100)) u = array([r_[1:1:50j, 0:0:50j], @@ -350,14 +352,14 @@ def assert_systems_behave_equal(self, sys1, sys2): y2, t2 = step(sys2, t1) assert_array_almost_equal(y1, y2) - def test_convert_MIMO_to_SISO(self): + def test_convert_MIMO_to_SISO(self, SISO_mats, MIMO_mats): '''Convert mimo to siso systems''' #Test with our usual systems -------------------------------------------- #SISO PT2 system - As, Bs, Cs, Ds = self.make_SISO_mats() + As, Bs, Cs, Ds = SISO_mats sys_siso = ss(As, Bs, Cs, Ds) #MIMO system that contains two independent copies of the SISO system above - Am, Bm, Cm, Dm = self.make_MIMO_mats() + Am, Bm, Cm, Dm = MIMO_mats sys_mimo = ss(Am, Bm, Cm, Dm) # t, y = step(sys_siso) # plot(t, y, label='sys_siso d=0') @@ -420,24 +422,3 @@ def test_convert_MIMO_to_SISO(self): self.assert_systems_behave_equal(sys_siso, sys_siso_01) self.assert_systems_behave_equal(sys_siso, sys_siso_10) - def debug_nasty_import_problem(): - ''' - ``*.egg`` files have precedence over ``PYTHONPATH``. Therefore packages - that were installed with ``easy_install``, can not be easily developed with - Eclipse. - - See also: - http://bugs.python.org/setuptools/issue53 - - Use this function to debug the issue. - ''' - #print the directories where python searches for modules and packages. - import sys - print('sys.path: -----------------------------------') - for name in sys.path: - print(name) - - -if __name__ == '__main__': - unittest.main() -# vi:ts=4:sw=4:expandtab diff --git a/control/tests/matlab_test.py b/control/tests/matlab_test.py index 7d81288e4..61bc3bdcb 100644 --- a/control/tests/matlab_test.py +++ b/control/tests/matlab_test.py @@ -1,22 +1,36 @@ -#!/usr/bin/env python -# -# matlab_test.py - test MATLAB compatibility -# RMM, 30 Mar 2011 (based on TestMatlab from v0.4a) -# -# This test suite just goes through and calls all of the MATLAB -# functions using different systems and arguments to make sure that -# nothing crashes. It doesn't test actual functionality; the module -# specific unit tests will do that. - -from __future__ import print_function -import unittest +"""matlab_test.py - test MATLAB compatibility + +RMM, 30 Mar 2011 (based on TestMatlab from v0.4a) + +This test suite just goes through and calls all of the MATLAB +functions using different systems and arguments to make sure that +nothing crashes. Many test don't test actual functionality; the module +specific unit tests will do that. +""" + import numpy as np -from scipy.linalg import eigvals +import pytest import scipy as sp -from control.matlab import * +from scipy.linalg import eigvals + +from control.matlab import ss, ss2tf, ssdata, tf, tf2ss, tfdata, rss, drss, frd +from control.matlab import parallel, series, feedback +from control.matlab import pole, zero, damp +from control.matlab import step, stepinfo, impulse, initial, lsim +from control.matlab import margin, dcgain +from control.matlab import linspace, logspace +from control.matlab import bode, rlocus, nyquist, nichols, ngrid, pzmap +from control.matlab import freqresp, evalfr +from control.matlab import hsvd, balred, modred, minreal +from control.matlab import place, place_varga, acker +from control.matlab import lqr, ctrb, obsv, gram +from control.matlab import pade +from control.matlab import unwrap, c2d, isctime, isdtime +from control.matlab import connect, append +from control.exception import ControlArgument + from control.frdata import FRD -from control.exception import slycot_check -import warnings +from control.tests.conftest import slycotonly # for running these through Matlab or Octave ''' @@ -55,96 +69,124 @@ ''' -class TestMatlab(unittest.TestCase): - def setUp(self): + +@pytest.fixture(scope="class") +def fixedseed(): + """Get consistent test results""" + np.random.seed(0) + + +class tsystems: + """struct for test systems""" + + pass + + +@pytest.mark.usefixtures("fixedseed") +class TestMatlab: + """Test matlab style functions""" + + @pytest.fixture + def siso(self): """Set up some systems for testing out MATLAB functions""" - A = np.matrix("1. -2.; 3. -4.") - B = np.matrix("5.; 7.") - C = np.matrix("6. 8.") - D = np.matrix("9.") - self.siso_ss1 = ss(A,B,C,D) + s = tsystems() + + A = np.array([[1., -2.], [3., -4.]]) + B = np.array([[5.], [7.]]) + C = np.array([[6., 8.]]) + D = np.array([[9.]]) + s.ss1 = ss(A, B, C, D) # Create some transfer functions - self.siso_tf1 = tf([1], [1, 2, 1]); - self.siso_tf2 = tf([1, 1], [1, 2, 3, 1]); + s.tf1 = tf([1], [1, 2, 1]) + s.tf2 = tf([1, 1], [1, 2, 3, 1]) # Conversions - self.siso_tf3 = tf(self.siso_ss1); - self.siso_ss2 = ss(self.siso_tf2); - self.siso_ss3 = tf2ss(self.siso_tf3); - self.siso_tf4 = ss2tf(self.siso_ss2); - - #Create MIMO system, contains ``siso_ss1`` twice - A = np.matrix("1. -2. 0. 0.;" - "3. -4. 0. 0.;" - "0. 0. 1. -2.;" - "0. 0. 3. -4. ") - B = np.matrix("5. 0.;" - "7. 0.;" - "0. 5.;" - "0. 7. ") - C = np.matrix("6. 8. 0. 0.;" - "0. 0. 6. 8. ") - D = np.matrix("9. 0.;" - "0. 9. ") - self.mimo_ss1 = ss(A, B, C, D) - - # get consistent test results - np.random.seed(0) - - def testParallel(self): - sys1 = parallel(self.siso_ss1, self.siso_ss2) - sys1 = parallel(self.siso_ss1, self.siso_tf2) - sys1 = parallel(self.siso_tf1, self.siso_ss2) - sys1 = parallel(1, self.siso_ss2) - sys1 = parallel(1, self.siso_tf2) - sys1 = parallel(self.siso_ss1, 1) - sys1 = parallel(self.siso_tf1, 1) - - def testSeries(self): - sys1 = series(self.siso_ss1, self.siso_ss2) - sys1 = series(self.siso_ss1, self.siso_tf2) - sys1 = series(self.siso_tf1, self.siso_ss2) - sys1 = series(1, self.siso_ss2) - sys1 = series(1, self.siso_tf2) - sys1 = series(self.siso_ss1, 1) - sys1 = series(self.siso_tf1, 1) - - def testFeedback(self): - sys1 = feedback(self.siso_ss1, self.siso_ss2) - sys1 = feedback(self.siso_ss1, self.siso_tf2) - sys1 = feedback(self.siso_tf1, self.siso_ss2) - sys1 = feedback(1, self.siso_ss2) - sys1 = feedback(1, self.siso_tf2) - sys1 = feedback(self.siso_ss1, 1) - sys1 = feedback(self.siso_tf1, 1) - - def testPoleZero(self): - pole(self.siso_ss1); - pole(self.siso_tf1); - pole(self.siso_tf2); - zero(self.siso_ss1); - zero(self.siso_tf1); - zero(self.siso_tf2); - - def testPZmap(self): - # pzmap(self.siso_ss1); not implemented - # pzmap(self.siso_ss2); not implemented - pzmap(self.siso_tf1); - pzmap(self.siso_tf2); - pzmap(self.siso_tf2, plot=False); - - def testStep(self): + s.tf3 = tf(s.ss1) + s.ss2 = ss(s.tf2) + s.ss3 = tf2ss(s.tf3) + s.tf4 = ss2tf(s.ss2) + return s + + @pytest.fixture + def mimo(self): + """Create MIMO system, contains ``siso_ss1`` twice""" + m = tsystems() + A = np.array([[1., -2., 0., 0.], + [3., -4., 0., 0.], + [0., 0., 1., -2.], + [0., 0., 3., -4.]]) + B = np.array([[5., 0.], + [7., 0.], + [0., 5.], + [0., 7.]]) + C = np.array([[6., 8., 0., 0.], + [0., 0., 6., 8.]]) + D = np.array([[9., 0.], + [0., 9.]]) + m.ss1 = ss(A, B, C, D) + return m + + def testParallel(self, siso): + """Call parallel()""" + sys1 = parallel(siso.ss1, siso.ss2) + sys1 = parallel(siso.ss1, siso.tf2) + sys1 = parallel(siso.tf1, siso.ss2) + sys1 = parallel(1, siso.ss2) + sys1 = parallel(1, siso.tf2) + sys1 = parallel(siso.ss1, 1) + sys1 = parallel(siso.tf1, 1) + + def testSeries(self, siso): + """Call series()""" + sys1 = series(siso.ss1, siso.ss2) + sys1 = series(siso.ss1, siso.tf2) + sys1 = series(siso.tf1, siso.ss2) + sys1 = series(1, siso.ss2) + sys1 = series(1, siso.tf2) + sys1 = series(siso.ss1, 1) + sys1 = series(siso.tf1, 1) + + def testFeedback(self, siso): + """Call feedback()""" + sys1 = feedback(siso.ss1, siso.ss2) + sys1 = feedback(siso.ss1, siso.tf2) + sys1 = feedback(siso.tf1, siso.ss2) + sys1 = feedback(1, siso.ss2) + sys1 = feedback(1, siso.tf2) + sys1 = feedback(siso.ss1, 1) + sys1 = feedback(siso.tf1, 1) + + def testPoleZero(self, siso): + """Call pole() and zero()""" + pole(siso.ss1) + pole(siso.tf1) + pole(siso.tf2) + zero(siso.ss1) + zero(siso.tf1) + zero(siso.tf2) + + @pytest.mark.parametrize( + "subsys", ["tf1", "tf2"]) + def testPZmap(self, siso, subsys, mplcleanup): + """Call pzmap()""" + # pzmap(siso.ss1); not implemented + # pzmap(siso.ss2); not implemented + pzmap(getattr(siso, subsys)) + pzmap(getattr(siso, subsys), plot=False) + + def testStep(self, siso): + """Test step()""" t = np.linspace(0, 1, 10) # Test transfer function - yout, tout = step(self.siso_tf1, T=t) + yout, tout = step(siso.tf1, T=t) youttrue = np.array([0, 0.0057, 0.0213, 0.0446, 0.0739, 0.1075, 0.1443, 0.1832, 0.2235, 0.2642]) np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) np.testing.assert_array_almost_equal(tout, t) # Test SISO system with direct feedthrough - sys = self.siso_ss1 + sys = siso.ss1 youttrue = np.array([9., 17.6457, 24.7072, 30.4855, 35.2234, 39.1165, 42.3227, 44.9694, 47.1599, 48.9776]) @@ -157,7 +199,7 @@ def testStep(self): np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) np.testing.assert_array_almost_equal(tout, t) - X0 = np.array([0, 0]); + X0 = np.array([0, 0]) yout, tout = step(sys, T=t, X0=X0) np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) np.testing.assert_array_almost_equal(tout, t) @@ -166,63 +208,85 @@ def testStep(self): np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) np.testing.assert_array_almost_equal(tout, t) - if slycot_check(): - # Test MIMO system, which contains ``siso_ss1`` twice - sys = self.mimo_ss1 - y_00, _t = step(sys, T=t, input=0, output=0) - y_11, _t = step(sys, T=t, input=1, output=1) - np.testing.assert_array_almost_equal(y_00, youttrue, decimal=4) - np.testing.assert_array_almost_equal(y_11, youttrue, decimal=4) + @slycotonly + def testStep_mimo(self, mimo): + """Test step for MIMO system""" + sys = mimo.ss1 + t = np.linspace(0, 1, 10) + youttrue = np.array([9., 17.6457, 24.7072, 30.4855, 35.2234, 39.1165, + 42.3227, 44.9694, 47.1599, 48.9776]) + + y_00, _t = step(sys, T=t, input=0, output=0) + y_11, _t = step(sys, T=t, input=1, output=1) + np.testing.assert_array_almost_equal(y_00, youttrue, decimal=4) + np.testing.assert_array_almost_equal(y_11, youttrue, decimal=4) + + def testStepinfo(self, siso): + """Test the stepinfo function (no return value check)""" + infodict = stepinfo(siso.ss1) + assert isinstance(infodict, dict) + assert len(infodict) == 9 - def testImpulse(self): + def testImpulse(self, siso): + """Test impulse()""" t = np.linspace(0, 1, 10) # test transfer function - yout, tout = impulse(self.siso_tf1, T=t) + yout, tout = impulse(siso.tf1, T=t) youttrue = np.array([0., 0.0994, 0.1779, 0.2388, 0.2850, 0.3188, 0.3423, 0.3573, 0.3654, 0.3679]) np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) np.testing.assert_array_almost_equal(tout, t) + sys = siso.ss1 + youttrue = np.array([86., 70.1808, 57.3753, 46.9975, 38.5766, 31.7344, + 26.1668, 21.6292, 17.9245, 14.8945]) # produce a warning for a system with direct feedthrough - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - #Test SISO system - sys = self.siso_ss1 - youttrue = np.array([86., 70.1808, 57.3753, 46.9975, 38.5766, 31.7344, - 26.1668, 21.6292, 17.9245, 14.8945]) + with pytest.warns(UserWarning, match="System has direct feedthrough"): + # Test SISO system yout, tout = impulse(sys, T=t) np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) np.testing.assert_array_almost_equal(tout, t) + # produce a warning for a system with direct feedthrough + with pytest.warns(UserWarning, match="System has direct feedthrough"): # Play with arguments yout, tout = impulse(sys, T=t, X0=0) np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) np.testing.assert_array_almost_equal(tout, t) - X0 = np.array([0, 0]); + # produce a warning for a system with direct feedthrough + with pytest.warns(UserWarning, match="System has direct feedthrough"): + X0 = np.array([0, 0]) yout, tout = impulse(sys, T=t, X0=X0) np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) np.testing.assert_array_almost_equal(tout, t) + # produce a warning for a system with direct feedthrough + with pytest.warns(UserWarning, match="System has direct feedthrough"): yout, tout, xout = impulse(sys, T=t, X0=0, return_x=True) np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) np.testing.assert_array_almost_equal(tout, t) - if slycot_check(): - #Test MIMO system, which contains ``siso_ss1`` twice - sys = self.mimo_ss1 - y_00, _t = impulse(sys, T=t, input=0, output=0) - y_11, _t = impulse(sys, T=t, input=1, output=1) - np.testing.assert_array_almost_equal(y_00, youttrue, decimal=4) - np.testing.assert_array_almost_equal(y_11, youttrue, decimal=4) - - def testInitial(self): - #Test SISO system - sys = self.siso_ss1 + @slycotonly + def testImpulse_mimo(self, mimo): + """Test impulse() for MIMO system""" t = np.linspace(0, 1, 10) - x0 = np.matrix(".5; 1.") + youttrue = np.array([86., 70.1808, 57.3753, 46.9975, 38.5766, 31.7344, + 26.1668, 21.6292, 17.9245, 14.8945]) + sys = mimo.ss1 + with pytest.warns(UserWarning, match="System has direct feedthrough"): + y_00, _t = impulse(sys, T=t, input=0, output=0) + y_11, _t = impulse(sys, T=t, input=1, output=1) + np.testing.assert_array_almost_equal(y_00, youttrue, decimal=4) + np.testing.assert_array_almost_equal(y_11, youttrue, decimal=4) + + def testInitial(self, siso): + """Test initial() for SISO system""" + t = np.linspace(0, 1, 10) + x0 = np.array([[.5], [1.]]) youttrue = np.array([11., 8.1494, 5.9361, 4.2258, 2.9118, 1.9092, 1.1508, 0.5833, 0.1645, -0.1391]) + sys = siso.ss1 yout, tout = initial(sys, T=t, X0=x0) np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) np.testing.assert_array_almost_equal(tout, t) @@ -232,70 +296,82 @@ def testInitial(self): np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) np.testing.assert_array_almost_equal(tout, t) - if slycot_check(): - #Test MIMO system, which contains ``siso_ss1`` twice - sys = self.mimo_ss1 - x0 = np.matrix(".5; 1.; .5; 1.") - y_00, _t = initial(sys, T=t, X0=x0, input=0, output=0) - y_11, _t = initial(sys, T=t, X0=x0, input=1, output=1) - np.testing.assert_array_almost_equal(y_00, youttrue, decimal=4) - np.testing.assert_array_almost_equal(y_11, youttrue, decimal=4) - - def testLsim(self): + @slycotonly + def testInitial_mimo(self, mimo): + """Test initial() for MIMO system""" + t = np.linspace(0, 1, 10) + x0 = np.array([[.5], [1.], [.5], [1.]]) + youttrue = np.array([11., 8.1494, 5.9361, 4.2258, 2.9118, 1.9092, + 1.1508, 0.5833, 0.1645, -0.1391]) + sys = mimo.ss1 + y_00, _t = initial(sys, T=t, X0=x0, input=0, output=0) + y_11, _t = initial(sys, T=t, X0=x0, input=1, output=1) + np.testing.assert_array_almost_equal(y_00, youttrue, decimal=4) + np.testing.assert_array_almost_equal(y_11, youttrue, decimal=4) + + def testLsim(self, siso): + """Test lsim() for SISO system""" t = np.linspace(0, 1, 10) - #compute step response - test with state space, and transfer function - #objects + # compute step response - test with state space, and transfer function + # objects u = np.array([1., 1, 1, 1, 1, 1, 1, 1, 1, 1]) youttrue = np.array([9., 17.6457, 24.7072, 30.4855, 35.2234, 39.1165, 42.3227, 44.9694, 47.1599, 48.9776]) - yout, tout, _xout = lsim(self.siso_ss1, u, t) + yout, tout, _xout = lsim(siso.ss1, u, t) np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) np.testing.assert_array_almost_equal(tout, t) - yout, _t, _xout = lsim(self.siso_tf3, u, t) + with pytest.warns(UserWarning, match="Internal conversion"): + yout, _t, _xout = lsim(siso.tf3, u, t) np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) - #test with initial value and special algorithm for ``U=0`` - u=0 - x0 = np.matrix(".5; 1.") + # test with initial value and special algorithm for ``U=0`` + u = 0 + x0 = np.array([[.5], [1.]]) youttrue = np.array([11., 8.1494, 5.9361, 4.2258, 2.9118, 1.9092, 1.1508, 0.5833, 0.1645, -0.1391]) - yout, _t, _xout = lsim(self.siso_ss1, u, t, x0) + yout, _t, _xout = lsim(siso.ss1, u, t, x0) np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) - if slycot_check(): - #Test MIMO system, which contains ``siso_ss1`` twice - #first system: initial value, second system: step response - u = np.array([[0., 1.], [0, 1], [0, 1], [0, 1], [0, 1], - [0, 1], [0, 1], [0, 1], [0, 1], [0, 1]]) - x0 = np.matrix(".5; 1; 0; 0") - youttrue = np.array([[11., 9.], [8.1494, 17.6457], - [5.9361, 24.7072], [4.2258, 30.4855], - [2.9118, 35.2234], [1.9092, 39.1165], - [1.1508, 42.3227], [0.5833, 44.9694], - [0.1645, 47.1599], [-0.1391, 48.9776]]) - yout, _t, _xout = lsim(self.mimo_ss1, u, t, x0) - np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) + @slycotonly + def testLsim_mimo(self, mimo): + """Test lsim() for MIMO system. + + first system: initial value, second system: step response + """ + t = np.linspace(0, 1, 10) + + u = np.array([[0., 1.], [0, 1], [0, 1], [0, 1], [0, 1], + [0, 1], [0, 1], [0, 1], [0, 1], [0, 1]]) + x0 = np.array([[.5], [1], [0], [0]]) + youttrue = np.array([[11., 9.], [8.1494, 17.6457], + [5.9361, 24.7072], [4.2258, 30.4855], + [2.9118, 35.2234], [1.9092, 39.1165], + [1.1508, 42.3227], [0.5833, 44.9694], + [0.1645, 47.1599], [-0.1391, 48.9776]]) + yout, _t, _xout = lsim(mimo.ss1, u, t, x0) + np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) - def testMargin(self): + def testMargin(self, siso): + """Test margin()""" #! TODO: check results to make sure they are OK - gm, pm, wg, wp = margin(self.siso_tf1); - gm, pm, wg, wp = margin(self.siso_tf2); - gm, pm, wg, wp = margin(self.siso_ss1); - gm, pm, wg, wp = margin(self.siso_ss2); - gm, pm, wg, wp = margin(self.siso_ss2*self.siso_ss2*2); + gm, pm, wg, wp = margin(siso.tf1) + gm, pm, wg, wp = margin(siso.tf2) + gm, pm, wg, wp = margin(siso.ss1) + gm, pm, wg, wp = margin(siso.ss2) + gm, pm, wg, wp = margin(siso.ss2 * siso.ss2 * 2) np.testing.assert_array_almost_equal( [gm, pm, wg, wp], [1.5451, 75.9933, 1.2720, 0.6559], decimal=3) - def testDcgain(self): - #Create different forms of a SISO system - A, B, C, D = self.siso_ss1.A, self.siso_ss1.B, self.siso_ss1.C, \ - self.siso_ss1.D + def testDcgain(self, siso): + """Test dcgain() for SISO system""" + # Create different forms of a SISO system using scipy.signal + A, B, C, D = siso.ss1.A, siso.ss1.B, siso.ss1.C, siso.ss1.D Z, P, k = sp.signal.ss2zpk(A, B, C, D) num, den = sp.signal.ss2tf(A, B, C, D) - sys_ss = self.siso_ss1 + sys_ss = siso.ss1 - #Compute the gain with ``dcgain`` + # Compute the gain with ``dcgain`` gain_abcd = dcgain(A, B, C, D) gain_zpk = dcgain(Z, P, k) gain_numden = dcgain(np.squeeze(num), den) @@ -303,282 +379,327 @@ def testDcgain(self): # print('\ngain_abcd:', gain_abcd, 'gain_zpk:', gain_zpk) # print('gain_numden:', gain_numden, 'gain_sys_ss:', gain_sys_ss) - #Compute the gain with a long simulation + # Compute the gain with a long simulation t = linspace(0, 1000, 1000) y, _t = step(sys_ss, t) gain_sim = y[-1] # print('gain_sim:', gain_sim) - #All gain values must be approximately equal to the known gain + # All gain values must be approximately equal to the known gain np.testing.assert_array_almost_equal( - [gain_abcd, gain_zpk, gain_numden, gain_sys_ss, - gain_sim], + [gain_abcd, gain_zpk, gain_numden, gain_sys_ss, gain_sim], [59, 59, 59, 59, 59]) - if slycot_check(): - # Test with MIMO system, which contains ``siso_ss1`` twice - gain_mimo = dcgain(self.mimo_ss1) - # print('gain_mimo: \n', gain_mimo) - np.testing.assert_array_almost_equal(gain_mimo, [[59., 0 ], - [0, 59.]]) - - def testBode(self): - bode(self.siso_ss1) - bode(self.siso_tf1) - bode(self.siso_tf2) - (mag, phase, freq) = bode(self.siso_tf2, plot=False) - bode(self.siso_tf1, self.siso_tf2) - w = logspace(-3, 3); - bode(self.siso_ss1, w) - bode(self.siso_ss1, self.siso_tf2, w) -# Not yet implemented -# bode(self.siso_ss1, '-', self.siso_tf1, 'b--', self.siso_tf2, 'k.') - - def testRlocus(self): - rlocus(self.siso_ss1) - rlocus(self.siso_tf1) - rlocus(self.siso_tf2) + def testDcgain_mimo(self, mimo): + """Test dcgain() for MIMO system""" + gain_mimo = dcgain(mimo.ss1) + # print('gain_mimo: \n', gain_mimo) + np.testing.assert_array_almost_equal(gain_mimo, [[59., 0], + [0, 59.]]) + + def testBode(self, siso, mplcleanup): + """Call bode()""" + bode(siso.ss1) + bode(siso.tf1) + bode(siso.tf2) + (mag, phase, freq) = bode(siso.tf2, plot=False) + bode(siso.tf1, siso.tf2) + w = logspace(-3, 3) + bode(siso.ss1, w) + bode(siso.ss1, siso.tf2, w) + # Not yet implemented + # bode(siso.ss1, '-', siso.tf1, 'b--', siso.tf2, 'k.') + + @pytest.mark.parametrize("subsys", ["ss1", "tf1", "tf2"]) + def testRlocus(self, siso, subsys, mplcleanup): + """Call rlocus()""" + rlocus(getattr(siso, subsys)) + + def testRlocus_list(self, siso, mplcleanup): + """Test rlocus() with list""" klist = [1, 10, 100] - rlist, klist_out = rlocus(self.siso_tf2, klist, plot=False) + rlist, klist_out = rlocus(siso.tf2, klist, plot=False) np.testing.assert_equal(len(rlist), len(klist)) np.testing.assert_array_equal(klist, klist_out) - def testNyquist(self): - nyquist(self.siso_ss1) - nyquist(self.siso_tf1) - nyquist(self.siso_tf2) - w = logspace(-3, 3); - nyquist(self.siso_tf2, w) - (real, imag, freq) = nyquist(self.siso_tf2, w, plot=False) - - def testNichols(self): - nichols(self.siso_ss1) - nichols(self.siso_tf1) - nichols(self.siso_tf2) - w = logspace(-3, 3); - nichols(self.siso_tf2, w) - nichols(self.siso_tf2, grid=False) - - def testFreqresp(self): + def testNyquist(self, siso): + """Call nyquist()""" + nyquist(siso.ss1) + nyquist(siso.tf1) + nyquist(siso.tf2) + w = logspace(-3, 3) + nyquist(siso.tf2, w) + (real, imag, freq) = nyquist(siso.tf2, w, plot=False) + + @pytest.mark.parametrize("subsys", ["ss1", "tf1", "tf2"]) + def testNichols(self, siso, subsys, mplcleanup): + """Call nichols()""" + nichols(getattr(siso, subsys)) + + def testNichols_logspace(self, siso, mplcleanup): + """Call nichols() with logspace w""" + w = logspace(-3, 3) + nichols(siso.tf2, w) + + def testNichols_ngrid(self, siso, mplcleanup): + """Call nichols() and ngrid()""" + nichols(siso.tf2, grid=False) + ngrid() + + def testFreqresp(self, siso): + """Call freqresp()""" w = logspace(-3, 3) - freqresp(self.siso_ss1, w) - freqresp(self.siso_ss2, w) - freqresp(self.siso_ss3, w) - freqresp(self.siso_tf1, w) - freqresp(self.siso_tf2, w) - freqresp(self.siso_tf3, w) - - def testEvalfr(self): + freqresp(siso.ss1, w) + freqresp(siso.ss2, w) + freqresp(siso.ss3, w) + freqresp(siso.tf1, w) + freqresp(siso.tf2, w) + freqresp(siso.tf3, w) + + def testEvalfr(self, siso): + """Call evalfr()""" w = 1j - np.testing.assert_almost_equal(evalfr(self.siso_ss1, w), 44.8-21.4j) - evalfr(self.siso_ss2, w) - evalfr(self.siso_ss3, w) - evalfr(self.siso_tf1, w) - evalfr(self.siso_tf2, w) - evalfr(self.siso_tf3, w) - if slycot_check(): - np.testing.assert_array_almost_equal( - evalfr(self.mimo_ss1, w), - np.array( [[44.8-21.4j, 0.], [0., 44.8-21.4j]])) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testHsvd(self): - hsvd(self.siso_ss1) - hsvd(self.siso_ss2) - hsvd(self.siso_ss3) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testBalred(self): - balred(self.siso_ss1, 1) - balred(self.siso_ss2, 2) - balred(self.siso_ss3, [2, 2]) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testModred(self): - modred(self.siso_ss1, [1]) - modred(self.siso_ss2 * self.siso_ss1, [0, 1]) - modred(self.siso_ss1, [1], 'matchdc') - modred(self.siso_ss1, [1], 'truncate') - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testPlace_varga(self): - place_varga(self.siso_ss1.A, self.siso_ss1.B, [-2, -2]) - - def testPlace(self): - place(self.siso_ss1.A, self.siso_ss1.B, [-2, -2.5]) - - def testAcker(self): - acker(self.siso_ss1.A, self.siso_ss1.B, [-2, -2.5]) - - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testLQR(self): - (K, S, E) = lqr(self.siso_ss1.A, self.siso_ss1.B, np.eye(2), np.eye(1)) + np.testing.assert_almost_equal(evalfr(siso.ss1, w), 44.8 - 21.4j) + evalfr(siso.ss2, w) + evalfr(siso.ss3, w) + evalfr(siso.tf1, w) + evalfr(siso.tf2, w) + evalfr(siso.tf3, w) + + def testEvalfr_mimo(self, mimo): + """Test evalfr() MIMO""" + fr = evalfr(mimo.ss1, 1j) + ref = np.array([[44.8 - 21.4j, 0.], [0., 44.8 - 21.4j]]) + np.testing.assert_array_almost_equal(fr, ref) + + @slycotonly + def testHsvd(self, siso): + """Call hsvd()""" + hsvd(siso.ss1) + hsvd(siso.ss2) + hsvd(siso.ss3) + + @slycotonly + def testBalred(self, siso): + """Call balred()""" + balred(siso.ss1, 1) + balred(siso.ss2, 2) + balred(siso.ss3, [2, 2]) + + @slycotonly + def testModred(self, siso): + """Call modred()""" + modred(siso.ss1, [1]) + modred(siso.ss2 * siso.ss1, [0, 1]) + modred(siso.ss1, [1], 'matchdc') + modred(siso.ss1, [1], 'truncate') + + @slycotonly + def testPlace_varga(self, siso): + """Call place_varga()""" + place_varga(siso.ss1.A, siso.ss1.B, [-2, -2]) + + def testPlace(self, siso): + """Call place()""" + place(siso.ss1.A, siso.ss1.B, [-2, -2.5]) + + def testAcker(self, siso): + """Call acker()""" + acker(siso.ss1.A, siso.ss1.B, [-2, -2.5]) + + @slycotonly + def testLQR(self, siso): + """Call lqr()""" + (K, S, E) = lqr(siso.ss1.A, siso.ss1.B, np.eye(2), np.eye(1)) # Should work if [Q N;N' R] is positive semi-definite - (K, S, E) = lqr(self.siso_ss2.A, self.siso_ss2.B, 10*np.eye(3), \ - np.eye(1), [[1], [1], [2]]) - - @unittest.skip("check not yet implemented") - def testLQR_checks(self): - # Make sure we get a warning if [Q N;N' R] is not positive semi-definite - (K, S, E) = lqr(self.siso_ss2.A, self.siso_ss2.B, np.eye(3), \ - np.eye(1), [[1], [1], [2]]) + (K, S, E) = lqr(siso.ss2.A, siso.ss2.B, 10 * np.eye(3), np.eye(1), + [[1], [1], [2]]) def testRss(self): + """Call rss()""" rss(1) rss(2) rss(2, 1, 3) def testDrss(self): + """Call drss()""" drss(1) drss(2) drss(2, 1, 3) - def testCtrb(self): - ctrb(self.siso_ss1.A, self.siso_ss1.B) - ctrb(self.siso_ss2.A, self.siso_ss2.B) + def testCtrb(self, siso): + """Call ctrb()""" + ctrb(siso.ss1.A, siso.ss1.B) + ctrb(siso.ss2.A, siso.ss2.B) - def testObsv(self): - obsv(self.siso_ss1.A, self.siso_ss1.C) - obsv(self.siso_ss2.A, self.siso_ss2.C) + def testObsv(self, siso): + """Call obsv()""" + obsv(siso.ss1.A, siso.ss1.C) + obsv(siso.ss2.A, siso.ss2.C) - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testGram(self): - gram(self.siso_ss1, 'c') - gram(self.siso_ss2, 'c') - gram(self.siso_ss1, 'o') - gram(self.siso_ss2, 'o') + @slycotonly + def testGram(self, siso): + """Call gram()""" + gram(siso.ss1, 'c') + gram(siso.ss2, 'c') + gram(siso.ss1, 'o') + gram(siso.ss2, 'o') def testPade(self): + """Call pade()""" pade(1, 1) pade(1, 2) pade(5, 4) - def testOpers(self): - self.siso_ss1 + self.siso_ss2 - self.siso_tf1 + self.siso_tf2 - self.siso_ss1 + self.siso_tf2 - self.siso_tf1 + self.siso_ss2 - self.siso_ss1 * self.siso_ss2 - self.siso_tf1 * self.siso_tf2 - self.siso_ss1 * self.siso_tf2 - self.siso_tf1 * self.siso_ss2 - # self.siso_ss1 / self.siso_ss2 not implemented yet - # self.siso_tf1 / self.siso_tf2 - # self.siso_ss1 / self.siso_tf2 - # self.siso_tf1 / self.siso_ss2 + def testOpers(self, siso): + """Use arithmetic operators""" + siso.ss1 + siso.ss2 + siso.tf1 + siso.tf2 + siso.ss1 + siso.tf2 + siso.tf1 + siso.ss2 + siso.ss1 * siso.ss2 + siso.tf1 * siso.tf2 + siso.ss1 * siso.tf2 + siso.tf1 * siso.ss2 + # siso.ss1 / siso.ss2 not implemented yet + # siso.tf1 / siso.tf2 + # siso.ss1 / siso.tf2 + # siso.tf1 / siso.ss2 def testUnwrap(self): - phase = np.array(range(1, 100)) / 10.; + """Call unwrap()""" + phase = np.array(range(1, 100)) / 10. wrapped = phase % (2 * np.pi) unwrapped = unwrap(wrapped) - def testSISOssdata(self): - ssdata_1 = ssdata(self.siso_ss2); - ssdata_2 = ssdata(self.siso_tf2); + def testSISOssdata(self, siso): + """Call ssdata() + + At least test for consistency between ss and tf + """ + ssdata_1 = ssdata(siso.ss2) + ssdata_2 = ssdata(siso.tf2) for i in range(len(ssdata_1)): np.testing.assert_array_almost_equal(ssdata_1[i], ssdata_2[i]) - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testMIMOssdata(self): - m = (self.mimo_ss1.A, self.mimo_ss1.B, self.mimo_ss1.C, self.mimo_ss1.D) - ssdata_1 = ssdata(self.mimo_ss1); + @slycotonly + def testMIMOssdata(self, mimo): + """Test ssdata() MIMO""" + m = (mimo.ss1.A, mimo.ss1.B, mimo.ss1.C, mimo.ss1.D) + ssdata_1 = ssdata(mimo.ss1) for i in range(len(ssdata_1)): np.testing.assert_array_almost_equal(ssdata_1[i], m[i]) - def testSISOtfdata(self): - tfdata_1 = tfdata(self.siso_tf2); - tfdata_2 = tfdata(self.siso_tf2); + def testSISOtfdata(self, siso): + """Call tfdata()""" + tfdata_1 = tfdata(siso.tf2) + tfdata_2 = tfdata(siso.tf2) for i in range(len(tfdata_1)): np.testing.assert_array_almost_equal(tfdata_1[i], tfdata_2[i]) def testDamp(self): - A = np.mat('''-0.2 0.06 0 -1; - 0 0 1 0; - -17 0 -3.8 1; - 9.4 0 -0.4 -0.6''') - B = np.mat('''-0.01 0.06; - 0 0; - -32 5.4; - 2.6 -7''') + """Test damp()""" + A = np.array([[-0.2, 0.06, 0, -1], + [0, 0, 1, 0], + [-17, 0, -3.8, 1], + [9.4, 0, -0.4, -0.6]]) + B = np.array([[-0.01, 0.06], + [0, 0], + [-32, 5.4], + [2.6, -7]]) C = np.eye(4) - D = np.zeros((4,2)) + D = np.zeros((4, 2)) sys = ss(A, B, C, D) wn, Z, p = damp(sys, False) # print (wn) np.testing.assert_array_almost_equal( - wn, np.array([4.07381994, 3.28874827, 3.28874827, + wn, np.array([4.07381994, 3.28874827, 3.28874827, 1.08937685e-03])) np.testing.assert_array_almost_equal( - Z, np.array([1.0, 0.07983139, 0.07983139, 1.0])) + Z, np.array([1.0, 0.07983139, 0.07983139, 1.0])) def testConnect(self): - sys1 = ss("1. -2; 3. -4", "5.; 7", "6, 8", "9.") - sys2 = ss("-1.", "1.", "1.", "0.") + """Test append() and connect()""" + sys1 = ss([[1., -2], + [3., -4]], + [[5.], + [7]], + [[6, 8]], + [[9.]]) + sys2 = ss(-1., 1., 1., 0.) sys = append(sys1, sys2) - Q= np.mat([ [ 1, 2], [2, -1] ]) # basically feedback, output 2 in 1 + Q = np.array([[1, 2], # basically feedback, output 2 in 1 + [2, -1]]) sysc = connect(sys, Q, [2], [1, 2]) # print(sysc) np.testing.assert_array_almost_equal( - sysc.A, np.mat('1 -2 5; 3 -4 7; -6 -8 -10')) + sysc.A, np.array([[1, -2, 5], [3, -4, 7], [-6, -8, -10]])) np.testing.assert_array_almost_equal( - sysc.B, np.mat('0; 0; 1')) + sysc.B, np.array([[0], [0], [1]])) np.testing.assert_array_almost_equal( - sysc.C, np.mat('6 8 9; 0 0 1')) + sysc.C, np.array([[6, 8, 9], [0, 0, 1]])) np.testing.assert_array_almost_equal( - sysc.D, np.mat('0; 0')) + sysc.D, np.array([[0], [0]])) def testConnect2(self): - sys = append(ss([[-5, -2.25], [4, 0]], [[2], [0]], - [[0, 1.125]], [[0]]), - ss([[-1.6667, 0], [1, 0]], [[2], [0]], - [[0, 3.3333]], [[0]]), - 1) - Q = [ [ 1, 3], [2, 1], [3, -2]] + """Test append and connect() case 2""" + sys = append(ss([[-5, -2.25], + [4, 0]], + [[2], + [0]], + [[0, 1.125]], + [[0]]), + ss([[-1.6667, 0], + [1, 0]], + [[2], [0]], + [[0, 3.3333]], [[0]]), + 1) + Q = [[1, 3], + [2, 1], + [3, -2]] sysc = connect(sys, Q, [3], [3, 1, 2]) np.testing.assert_array_almost_equal( - sysc.A, np.mat([[-5, -2.25, 0, -6.6666], - [4, 0, 0, 0], - [0, 2.25, -1.6667, 0], - [0, 0, 1, 0]])) + sysc.A, np.array([[-5, -2.25, 0, -6.6666], + [4, 0, 0, 0], + [0, 2.25, -1.6667, 0], + [0, 0, 1, 0]])) np.testing.assert_array_almost_equal( - sysc.B, np.mat([[2], [0], [0], [0]])) + sysc.B, np.array([[2], [0], [0], [0]])) np.testing.assert_array_almost_equal( - sysc.C, np.mat([[0, 0, 0, -3.3333], - [0, 1.125, 0, 0], - [0, 0, 0, 3.3333]])) + sysc.C, np.array([[0, 0, 0, -3.3333], + [0, 1.125, 0, 0], + [0, 0, 0, 3.3333]])) np.testing.assert_array_almost_equal( - sysc.D, np.mat([[1], [0], [0]])) - - + sysc.D, np.array([[1], [0], [0]])) def testFRD(self): + """Test frd()""" h = tf([1], [1, 2, 2]) omega = np.logspace(-1, 2, 10) frd1 = frd(h, omega) assert isinstance(frd1, FRD) - frd2 = frd(frd1.fresp[0,0,:], omega) + frd2 = frd(frd1.fresp[0, 0, :], omega) assert isinstance(frd2, FRD) - @unittest.skipIf(not slycot_check(), "slycot not installed") + @slycotonly def testMinreal(self, verbose=False): """Test a minreal model reduction""" - #A = [-2, 0.5, 0; 0.5, -0.3, 0; 0, 0, -0.1] + # A = [-2, 0.5, 0; 0.5, -0.3, 0; 0, 0, -0.1] A = [[-2, 0.5, 0], [0.5, -0.3, 0], [0, 0, -0.1]] - #B = [0.3, -1.3; 0.1, 0; 1, 0] + # B = [0.3, -1.3; 0.1, 0; 1, 0] B = [[0.3, -1.3], [0.1, 0.], [1.0, 0.0]] - #C = [0, 0.1, 0; -0.3, -0.2, 0] + # C = [0, 0.1, 0; -0.3, -0.2, 0] C = [[0., 0.1, 0.0], [-0.3, -0.2, 0.0]] - #D = [0 -0.8; -0.3 0] + # D = [0 -0.8; -0.3 0] D = [[0., -0.8], [-0.3, 0.]] # sys = ss(A, B, C, D) sys = ss(A, B, C, D) sysr = minreal(sys, verbose=verbose) - self.assertEqual(sysr.states, 2) - self.assertEqual(sysr.inputs, sys.inputs) - self.assertEqual(sysr.outputs, sys.outputs) + assert sysr.nstates == 2 + assert sysr.ninputs == sys.ninputs + assert sysr.noutputs == sys.noutputs np.testing.assert_array_almost_equal( eigvals(sysr.A), [-2.136154, -0.1638459]) @@ -590,43 +711,46 @@ def testMinreal(self, verbose=False): np.testing.assert_array_almost_equal(hm.den[0][0], hr.den[0][0]) def testSS2cont(self): + """Test c2d()""" sys = ss( - np.mat("-3 4 2; -1 -3 0; 2 5 3"), - np.mat("1 4 ; -3 -3; -2 1"), - np.mat("4 2 -3; 1 4 3"), - np.mat("-2 4; 0 1")) + np.array([[-3, 4, 2], [-1, -3, 0], [2, 5, 3]]), + np.array([[1, 4], [-3, -3], [-2, 1]]), + np.array([[4, 2, -3], [1, 4, 3]]), + np.array([[-2, 4], [0, 1]])) sysd = c2d(sys, 0.1) np.testing.assert_array_almost_equal( - np.mat( - """0.742840837331905 0.342242024293711 0.203124211149560; - -0.074130792143890 0.724553295044645 -0.009143771143630; - 0.180264783290485 0.544385612448419 1.370501013067845"""), + np.array( + [[ 0.742840837331905, 0.342242024293711, 0.203124211149560], + [-0.074130792143890, 0.724553295044645, -0.009143771143630], + [ 0.180264783290485, 0.544385612448419, 1.370501013067845]]), sysd.A) np.testing.assert_array_almost_equal( - np.mat(""" 0.012362066084719 0.301932197918268; - -0.260952977031384 -0.274201791021713; - -0.304617775734327 0.075182622718853"""), sysd.B) + np.array([[ 0.012362066084719, 0.301932197918268], + [-0.260952977031384, -0.274201791021713], + [-0.304617775734327, 0.075182622718853]]), + sysd.B) def testCombi01(self): - # test from a "real" case, combines tf, ss, connect and margin - # this is a type 2 system, with phase starting at -180. The - # margin command should remove the solution for w = nearly zero + """Test from a "real" case, combines tf, ss, connect and margin. + This is a type 2 system, with phase starting at -180. The + margin command should remove the solution for w = nearly zero. + """ # Example is a concocted two-body satellite with flexible link - Jb = 400; - Jp = 1000; - k = 10; - b = 5; + Jb = 400 + Jp = 1000 + k = 10 + b = 5 # can now define an "s" variable, to make TF's - s = tf([1, 0], [1]); - hb1 = 1/(Jb*s); - hb2 = 1/s; - hp1 = 1/(Jp*s); - hp2 = 1/s; + s = tf([1, 0], [1]) + hb1 = 1/(Jb*s) + hb2 = 1/s + hp1 = 1/(Jp*s) + hp2 = 1/s # convert to ss and append - sat0 = append(ss(hb1), ss(hb2), k, b, ss(hp1), ss(hp2)); + sat0 = append(ss(hb1), ss(hb2), k, b, ss(hp1), ss(hp2)) # connection of the elements with connect call Q = [[1, -3, -4], # link moment (spring, damper), feedback to body @@ -635,9 +759,9 @@ def testCombi01(self): [4, 1, -5], # damper input [5, 3, 4], # link moment, acting on payload [6, 5, 0]] - inputs = [1]; - outputs = [1, 2, 5, 6]; - sat1 = connect(sat0, Q, inputs, outputs); + inputs = [1] + outputs = [1, 2, 5, 6] + sat1 = connect(sat0, Q, inputs, outputs) # matched notch filter wno = 0.19 @@ -659,35 +783,52 @@ def testCombi01(self): gm, pm, wg, wp = margin(Hol) # print("%f %f %f %f" % (gm, pm, wg, wp)) - self.assertAlmostEqual(gm, 3.32065569155) - self.assertAlmostEqual(pm, 46.9740430224) - self.assertAlmostEqual(wg, 0.176469728448) - self.assertAlmostEqual(wp, 0.0616288455466) + np.testing.assert_allclose(gm, 3.32065569155) + np.testing.assert_allclose(pm, 46.9740430224) + np.testing.assert_allclose(wg, 0.176469728448) + np.testing.assert_allclose(wp, 0.0616288455466) def test_tf_string_args(self): - # Make sure that the 's' variable is defined properly + """Make sure s and z are defined properly""" s = tf('s') G = (s + 1)/(s**2 + 2*s + 1) np.testing.assert_array_almost_equal(G.num, [[[1, 1]]]) np.testing.assert_array_almost_equal(G.den, [[[1, 2, 1]]]) - self.assertTrue(isctime(G, strict=True)) + assert isctime(G, strict=True) - # Make sure that the 'z' variable is defined properly z = tf('z') G = (z + 1)/(z**2 + 2*z + 1) np.testing.assert_array_almost_equal(G.num, [[[1, 1]]]) np.testing.assert_array_almost_equal(G.den, [[[1, 2, 1]]]) - self.assertTrue(isdtime(G, strict=True)) + assert isdtime(G, strict=True) + + def test_matlab_wrapper_exceptions(self): + """Test out exceptions in matlab/wrappers.py""" + sys = tf([1], [1, 2, 1]) + + # Extra arguments in bode + with pytest.raises(ControlArgument, match="not all arguments"): + bode(sys, 'r-', [1e-2, 1e2], 5.0) + + # Multiple plot styles + with pytest.warns(UserWarning, match="plot styles not implemented"): + bode(sys, 'r-', sys, 'b--', [1e-2, 1e2]) + + # Incorrect number of arguments to dcgain + with pytest.raises(ValueError, match="needs either 1, 2, 3 or 4"): + dcgain(1, 2, 3, 4, 5) + + def test_matlab_freqplot_passthru(self, mplcleanup): + """Test nyquist and bode to make sure the pass arguments through""" + sys = tf([1], [1, 2, 1]) + bode((sys,)) # Passing tuple will call bode_plot + nyquist((sys,)) # Passing tuple will call nyquist_plot #! TODO: not yet implemented # def testMIMOtfdata(self): -# sisotf = ss2tf(self.siso_ss1) +# sisotf = ss2tf(siso.ss1) # tfdata_1 = tfdata(sisotf) -# tfdata_2 = tfdata(self.mimo_ss1, input=0, output=0) +# tfdata_2 = tfdata(mimo.ss1, input=0, output=0) # for i in range(len(tfdata)): # np.testing.assert_array_almost_equal(tfdata_1[i], tfdata_2[i]) - - -if __name__ == '__main__': - unittest.main() diff --git a/control/tests/minreal_test.py b/control/tests/minreal_test.py index 595bb08b0..466f9384d 100644 --- a/control/tests/minreal_test.py +++ b/control/tests/minreal_test.py @@ -1,27 +1,28 @@ -#!/usr/bin/env python -# -# minreal_test.py - test state space class -# Rvp, 13 Jun 2013 +"""minreal_test.py - test state space class + +Rvp, 13 Jun 2013 +""" -import unittest import numpy as np from scipy.linalg import eigvals -from control import matlab +import pytest + +from control import rss, ss, zero from control.statesp import StateSpace from control.xferfcn import TransferFunction from itertools import permutations -from control.exception import slycot_check +from control.tests.conftest import slycotonly -@unittest.skipIf(not slycot_check(), "slycot not installed") -class TestMinreal(unittest.TestCase): - """Tests for the StateSpace class.""" - def setUp(self): - np.random.seed(5) - # depending on the seed and minreal performance, a number of - # reductions is produced. If random gen or minreal change, this - # will be likely to fail - self.nreductions = 0 +@pytest.fixture +def fixedseed(scope="class"): + np.random.seed(5) + + +@slycotonly +@pytest.mark.usefixtures("fixedseed") +class TestMinreal: + """Tests for the StateSpace class.""" def assert_numden_almost_equal(self, n1, n2, d1, d2): n1[np.abs(n1) < 1e-10] = 0. @@ -35,13 +36,18 @@ def assert_numden_almost_equal(self, n1, n2, d1, d2): np.testing.assert_array_almost_equal(n1, n2) np.testing.assert_array_almost_equal(d2, d2) - def testMinrealBrute(self): + + # depending on the seed and minreal performance, a number of + # reductions is produced. If random gen or minreal change, this + # will be likely to fail + nreductions = 0 + for n, m, p in permutations(range(1,6), 3): - s = matlab.rss(n, p, m) + s = rss(n, p, m) sr = s.minreal() - if s.states > sr.states: - self.nreductions += 1 + if s.nstates > sr.nstates: + nreductions += 1 else: # Check to make sure that poles and zeros match @@ -53,30 +59,30 @@ def testMinrealBrute(self): for i in range(m): for j in range(p): # Extract SISO dynamixs from input i to output j - s1 = matlab.ss(s.A, s.B[:,i], s.C[j,:], s.D[j,i]) - s2 = matlab.ss(sr.A, sr.B[:,i], sr.C[j,:], sr.D[j,i]) + s1 = ss(s.A, s.B[:,i], s.C[j,:], s.D[j,i]) + s2 = ss(sr.A, sr.B[:,i], sr.C[j,:], sr.D[j,i]) # Check that the zeros match # Note: sorting doesn't work => have to do the hard way - z1 = matlab.zero(s1) - z2 = matlab.zero(s2) + z1 = zero(s1) + z2 = zero(s2) # Start by making sure we have the same # of zeros - self.assertEqual(len(z1), len(z2)) + assert len(z1) == len(z2) # Make sure all zeros in s1 are in s2 - for zero in z1: - # Find the closest zero - self.assertAlmostEqual(min(abs(z2 - zero)), 0.) + for z in z1: + # Find the closest zero TODO: find proper bounds + assert min(abs(z2 - z)) <= 1e-7 # Make sure all zeros in s2 are in s1 - for zero in z2: + for z in z2: # Find the closest zero - self.assertAlmostEqual(min(abs(z1 - zero)), 0.) + assert min(abs(z1 - z)) <= 1e-7 # Make sure that the number of systems reduced is as expected # (Need to update this number if you change the seed at top of file) - self.assertEqual(self.nreductions, 2) + assert nreductions == 2 def testMinrealSS(self): """Test a minreal model reduction""" @@ -92,9 +98,9 @@ def testMinrealSS(self): sys = StateSpace(A, B, C, D) sysr = sys.minreal() - self.assertEqual(sysr.states, 2) - self.assertEqual(sysr.inputs, sys.inputs) - self.assertEqual(sysr.outputs, sys.outputs) + assert sysr.nstates == 2 + assert sysr.ninputs == sys.ninputs + assert sysr.noutputs == sys.noutputs np.testing.assert_array_almost_equal( eigvals(sysr.A), [-2.136154, -0.1638459]) @@ -108,6 +114,3 @@ def testMinrealtf(self): np.testing.assert_array_almost_equal(hm.num[0][0], hr.num[0][0]) np.testing.assert_array_almost_equal(hm.den[0][0], hr.den[0][0]) - -if __name__ == "__main__": - unittest.main() diff --git a/control/tests/modelsimp_array_test.py b/control/tests/modelsimp_array_test.py deleted file mode 100644 index dbd6a5796..000000000 --- a/control/tests/modelsimp_array_test.py +++ /dev/null @@ -1,251 +0,0 @@ -#!/usr/bin/env python -# -# modelsimp_test.py - test model reduction functions -# RMM, 30 Mar 2011 (based on TestModelSimp from v0.4a) - -import unittest -import numpy as np -import warnings -import control -from control.modelsimp import * -from control.matlab import * -from control.exception import slycot_check, ControlMIMONotImplemented - -class TestModelsimp(unittest.TestCase): - def setUp(self): - # Use array instead of matrix (and save old value to restore at end) - control.use_numpy_matrix(False) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testHSVD(self): - A = np.array([[1., -2.], [3., -4.]]) - B = np.array([[5.], [7.]]) - C = np.array([[6., 8.]]) - D = np.array([[9.]]) - sys = ss(A,B,C,D) - hsv = hsvd(sys) - hsvtrue = np.array([24.42686, 0.5731395]) # from MATLAB - np.testing.assert_array_almost_equal(hsv, hsvtrue) - - # Make sure default type values are correct - self.assertTrue(isinstance(hsv, np.ndarray)) - self.assertFalse(isinstance(hsv, np.matrix)) - - # Check that using numpy.matrix does *not* affect answer - with warnings.catch_warnings(record=True) as w: - control.use_numpy_matrix(True) - self.assertTrue(issubclass(w[-1].category, UserWarning)) - - # Redefine the system (using np.matrix for storage) - sys = ss(A, B, C, D) - - # Compute the Hankel singular value decomposition - hsv = hsvd(sys) - - # Make sure that return type is correct - self.assertTrue(isinstance(hsv, np.ndarray)) - self.assertFalse(isinstance(hsv, np.matrix)) - - # Go back to using the normal np.array representation - control.use_numpy_matrix(False) - - def testMarkovSignature(self): - U = np.array([[1., 1., 1., 1., 1.]]) - Y = U - m = 3 - H = markov(Y, U, m, transpose=False) - Htrue = np.array([[1., 0., 0.]]) - np.testing.assert_array_almost_equal( H, Htrue ) - - # Make sure that transposed data also works - H = markov(np.transpose(Y), np.transpose(U), m, transpose=True) - np.testing.assert_array_almost_equal( H, np.transpose(Htrue) ) - - # Default (in v0.8.4 and below) should be transpose=True (w/ warning) - import warnings - warnings.simplefilter('always', UserWarning) # don't supress - with warnings.catch_warnings(record=True) as w: - # Set up warnings filter to only show warnings in control module - warnings.filterwarnings("ignore") - warnings.filterwarnings("always", module="control") - - # Generate Markov parameters without any arguments - H = markov(np.transpose(Y), np.transpose(U), m) - np.testing.assert_array_almost_equal( H, np.transpose(Htrue) ) - - # Make sure we got a warning - self.assertEqual(len(w), 1) - self.assertIn("assumed to be in rows", str(w[-1].message)) - self.assertIn("change in a future release", str(w[-1].message)) - - # Test example from docstring - T = np.linspace(0, 10, 100) - U = np.ones((1, 100)) - T, Y, _ = control.forced_response( - control.tf([1], [1, 0.5], True), T, U) - H = markov(Y, U, 3, transpose=False) - - # Test example from issue #395 - inp = np.array([1, 2]) - outp = np.array([2, 4]) - mrk = markov(outp, inp, 1, transpose=False) - - # Make sure MIMO generates an error - U = np.ones((2, 100)) # 2 inputs (Y unchanged, with 1 output) - np.testing.assert_raises(ControlMIMONotImplemented, markov, Y, U, m) - - # Make sure markov() returns the right answer - def testMarkovResults(self): - # - # Test over a range of parameters - # - # k = order of the system - # m = number of Markov parameters - # n = size of the data vector - # - # Values should match exactly for n = m, otherewise you get a - # close match but errors due to the assumption that C A^k B = - # 0 for k > m-2 (see modelsimp.py). - # - for k, m, n in \ - ((2, 2, 2), (2, 5, 5), (5, 2, 2), (5, 5, 5), (5, 10, 10)): - - # Generate stable continuous time system - Hc = control.rss(k, 1, 1) - - # Choose sampling time based on fastest time constant / 10 - w, _ = np.linalg.eig(Hc.A) - Ts = np.min(-np.real(w)) / 10. - - # Convert to a discrete time system via sampling - Hd = control.c2d(Hc, Ts, 'zoh') - - # Compute the Markov parameters from state space - Mtrue = np.hstack([Hd.D] + [np.dot( - Hd.C, np.dot(np.linalg.matrix_power(Hd.A, i), - Hd.B)) for i in range(m-1)]) - - # Generate input/output data - T = np.array(range(n)) * Ts - U = np.cos(T) + np.sin(T/np.pi) - _, Y, _ = control.forced_response(Hd, T, U, squeeze=True) - Mcomp = markov(Y, U, m, transpose=False) - - # Compare to results from markov() - np.testing.assert_array_almost_equal(Mtrue, Mcomp) - - def testModredMatchDC(self): - #balanced realization computed in matlab for the transfer function: - # num = [1 11 45 32], den = [1 15 60 200 60] - A = np.array( - [[-1.958, -1.194, 1.824, -1.464], - [-1.194, -0.8344, 2.563, -1.351], - [-1.824, -2.563, -1.124, 2.704], - [-1.464, -1.351, -2.704, -11.08]]) - B = np.array([[-0.9057], [-0.4068], [-0.3263], [-0.3474]]) - C = np.array([[-0.9057, -0.4068, 0.3263, -0.3474]]) - D = np.array([[0.]]) - sys = ss(A,B,C,D) - rsys = modred(sys,[2, 3],'matchdc') - Artrue = np.array([[-4.431, -4.552], [-4.552, -5.361]]) - Brtrue = np.array([[-1.362], [-1.031]]) - Crtrue = np.array([[-1.362, -1.031]]) - Drtrue = np.array([[-0.08384]]) - np.testing.assert_array_almost_equal(rsys.A, Artrue,decimal=3) - np.testing.assert_array_almost_equal(rsys.B, Brtrue,decimal=3) - np.testing.assert_array_almost_equal(rsys.C, Crtrue,decimal=3) - np.testing.assert_array_almost_equal(rsys.D, Drtrue,decimal=2) - - def testModredUnstable(self): - # Check if an error is thrown when an unstable system is given - A = np.array( - [[4.5418, 3.3999, 5.0342, 4.3808], - [0.3890, 0.3599, 0.4195, 0.1760], - [-4.2117, -3.2395, -4.6760, -4.2180], - [0.0052, 0.0429, 0.0155, 0.2743]]) - B = np.array([[1.0, 1.0], [2.0, 2.0], [3.0, 3.0], [4.0, 4.0]]) - C = np.array([[1.0, 2.0, 3.0, 4.0], [1.0, 2.0, 3.0, 4.0]]) - D = np.array([[0.0, 0.0], [0.0, 0.0]]) - sys = ss(A,B,C,D) - np.testing.assert_raises(ValueError, modred, sys, [2, 3]) - - def testModredTruncate(self): - #balanced realization computed in matlab for the transfer function: - # num = [1 11 45 32], den = [1 15 60 200 60] - A = np.array( - [[-1.958, -1.194, 1.824, -1.464], - [-1.194, -0.8344, 2.563, -1.351], - [-1.824, -2.563, -1.124, 2.704], - [-1.464, -1.351, -2.704, -11.08]]) - B = np.array([[-0.9057], [-0.4068], [-0.3263], [-0.3474]]) - C = np.array([[-0.9057, -0.4068, 0.3263, -0.3474]]) - D = np.array([[0.]]) - sys = ss(A,B,C,D) - rsys = modred(sys,[2, 3],'truncate') - Artrue = np.array([[-1.958, -1.194], [-1.194, -0.8344]]) - Brtrue = np.array([[-0.9057], [-0.4068]]) - Crtrue = np.array([[-0.9057, -0.4068]]) - Drtrue = np.array([[0.]]) - np.testing.assert_array_almost_equal(rsys.A, Artrue) - np.testing.assert_array_almost_equal(rsys.B, Brtrue) - np.testing.assert_array_almost_equal(rsys.C, Crtrue) - np.testing.assert_array_almost_equal(rsys.D, Drtrue) - - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testBalredTruncate(self): - #controlable canonical realization computed in matlab for the transfer function: - # num = [1 11 45 32], den = [1 15 60 200 60] - A = np.array( - [[-15., -7.5, -6.25, -1.875], - [8., 0., 0., 0.], - [0., 4., 0., 0.], - [0., 0., 1., 0.]]) - B = np.array([[2.], [0.], [0.], [0.]]) - C = np.array([[0.5, 0.6875, 0.7031, 0.5]]) - D = np.array([[0.]]) - sys = ss(A,B,C,D) - orders = 2 - rsys = balred(sys,orders,method='truncate') - Artrue = np.array([[-1.958, -1.194], [-1.194, -0.8344]]) - Brtrue = np.array([[0.9057], [0.4068]]) - Crtrue = np.array([[0.9057, 0.4068]]) - Drtrue = np.array([[0.]]) - np.testing.assert_array_almost_equal(rsys.A, Artrue,decimal=2) - np.testing.assert_array_almost_equal(rsys.B, Brtrue,decimal=4) - np.testing.assert_array_almost_equal(rsys.C, Crtrue,decimal=4) - np.testing.assert_array_almost_equal(rsys.D, Drtrue,decimal=4) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testBalredMatchDC(self): - #controlable canonical realization computed in matlab for the transfer function: - # num = [1 11 45 32], den = [1 15 60 200 60] - A = np.array( - [[-15., -7.5, -6.25, -1.875], - [8., 0., 0., 0.], - [0., 4., 0., 0.], - [0., 0., 1., 0.]]) - B = np.array([[2.], [0.], [0.], [0.]]) - C = np.array([[0.5, 0.6875, 0.7031, 0.5]]) - D = np.array([[0.]]) - sys = ss(A,B,C,D) - orders = 2 - rsys = balred(sys,orders,method='matchdc') - Artrue = np.array( - [[-4.43094773, -4.55232904], - [-4.55232904, -5.36195206]]) - Brtrue = np.array([[1.36235673], [1.03114388]]) - Crtrue = np.array([[1.36235673, 1.03114388]]) - Drtrue = np.array([[-0.08383902]]) - np.testing.assert_array_almost_equal(rsys.A, Artrue,decimal=2) - np.testing.assert_array_almost_equal(rsys.B, Brtrue,decimal=4) - np.testing.assert_array_almost_equal(rsys.C, Crtrue,decimal=4) - np.testing.assert_array_almost_equal(rsys.D, Drtrue,decimal=4) - - def tearDown(self): - # Reset configuration variables to their original settings - control.config.reset_defaults() - - -if __name__ == '__main__': - unittest.main() diff --git a/control/tests/modelsimp_test.py b/control/tests/modelsimp_test.py index c0ba72a3b..df656e1fc 100644 --- a/control/tests/modelsimp_test.py +++ b/control/tests/modelsimp_test.py @@ -1,135 +1,220 @@ -#!/usr/bin/env python -# -# modelsimp_test.py - test model reduction functions -# RMM, 30 Mar 2011 (based on TestModelSimp from v0.4a) +"""modelsimp_array_test.py - test model reduction functions + +RMM, 30 Mar 2011 (based on TestModelSimp from v0.4a) +""" -import unittest import numpy as np -from control.modelsimp import * -from control.matlab import * -from control.exception import slycot_check - -class TestModelsimp(unittest.TestCase): - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testHSVD(self): - A = np.matrix("1. -2.; 3. -4.") - B = np.matrix("5.; 7.") - C = np.matrix("6. 8.") - D = np.matrix("9.") - sys = ss(A,B,C,D) +import pytest + + +from control import StateSpace, forced_response, tf, rss, c2d +from control.exception import ControlMIMONotImplemented +from control.tests.conftest import slycotonly, matarrayin +from control.modelsimp import balred, hsvd, markov, modred + + +class TestModelsimp: + """Test model reduction functions""" + + @slycotonly + def testHSVD(self, matarrayout, matarrayin): + A = matarrayin([[1., -2.], [3., -4.]]) + B = matarrayin([[5.], [7.]]) + C = matarrayin([[6., 8.]]) + D = matarrayin([[9.]]) + sys = StateSpace(A, B, C, D) hsv = hsvd(sys) - hsvtrue = [24.42686, 0.5731395] # from MATLAB + hsvtrue = np.array([24.42686, 0.5731395]) # from MATLAB np.testing.assert_array_almost_equal(hsv, hsvtrue) - def testMarkov(self): - U = np.matrix("1.; 1.; 1.; 1.; 1.") + # test for correct return type: ALWAYS return ndarray, even when + # use_numpy_matrix(True) was used + assert isinstance(hsv, np.ndarray) + assert not isinstance(hsv, np.matrix) + + def testMarkovSignature(self, matarrayout, matarrayin): + U = matarrayin([[1., 1., 1., 1., 1.]]) Y = U - M = 3 - H = markov(Y, U, M) - Htrue = np.matrix("1.; 0.; 0.") - np.testing.assert_array_almost_equal( H, Htrue ) + m = 3 + H = markov(Y, U, m, transpose=False) + Htrue = np.array([[1., 0., 0.]]) + np.testing.assert_array_almost_equal(H, Htrue) + + # Make sure that transposed data also works + H = markov(np.transpose(Y), np.transpose(U), m, transpose=True) + np.testing.assert_array_almost_equal(H, np.transpose(Htrue)) + + # Generate Markov parameters without any arguments + H = markov(Y, U, m) + np.testing.assert_array_almost_equal(H, Htrue) + + # Test example from docstring + T = np.linspace(0, 10, 100) + U = np.ones((1, 100)) + T, Y = forced_response(tf([1], [1, 0.5], True), T, U) + H = markov(Y, U, 3, transpose=False) + + # Test example from issue #395 + inp = np.array([1, 2]) + outp = np.array([2, 4]) + mrk = markov(outp, inp, 1, transpose=False) - def testModredMatchDC(self): + # Make sure MIMO generates an error + U = np.ones((2, 100)) # 2 inputs (Y unchanged, with 1 output) + with pytest.raises(ControlMIMONotImplemented): + markov(Y, U, m) + + # Make sure markov() returns the right answer + @pytest.mark.parametrize("k, m, n", + [(2, 2, 2), + (2, 5, 5), + (5, 2, 2), + (5, 5, 5), + (5, 10, 10)]) + def testMarkovResults(self, k, m, n): + # + # Test over a range of parameters + # + # k = order of the system + # m = number of Markov parameters + # n = size of the data vector + # + # Values should match exactly for n = m, otherewise you get a + # close match but errors due to the assumption that C A^k B = + # 0 for k > m-2 (see modelsimp.py). + # + + # Generate stable continuous time system + Hc = rss(k, 1, 1) + + # Choose sampling time based on fastest time constant / 10 + w, _ = np.linalg.eig(Hc.A) + Ts = np.min(-np.real(w)) / 10. + + # Convert to a discrete time system via sampling + Hd = c2d(Hc, Ts, 'zoh') + + # Compute the Markov parameters from state space + Mtrue = np.hstack([Hd.D] + [np.dot( + Hd.C, np.dot(np.linalg.matrix_power(Hd.A, i), + Hd.B)) for i in range(m-1)]) + + # Generate input/output data + T = np.array(range(n)) * Ts + U = np.cos(T) + np.sin(T/np.pi) + _, Y = forced_response(Hd, T, U, squeeze=True) + Mcomp = markov(Y, U, m) + + # Compare to results from markov() + np.testing.assert_array_almost_equal(Mtrue, Mcomp) + + def testModredMatchDC(self, matarrayin): #balanced realization computed in matlab for the transfer function: # num = [1 11 45 32], den = [1 15 60 200 60] - A = np.matrix('-1.958, -1.194, 1.824, -1.464; \ - -1.194, -0.8344, 2.563, -1.351; \ - -1.824, -2.563, -1.124, 2.704; \ - -1.464, -1.351, -2.704, -11.08') - B = np.matrix('-0.9057; -0.4068; -0.3263; -0.3474') - C = np.matrix('-0.9057, -0.4068, 0.3263, -0.3474') - D = np.matrix('0.') - sys = ss(A,B,C,D) + A = matarrayin( + [[-1.958, -1.194, 1.824, -1.464], + [-1.194, -0.8344, 2.563, -1.351], + [-1.824, -2.563, -1.124, 2.704], + [-1.464, -1.351, -2.704, -11.08]]) + B = matarrayin([[-0.9057], [-0.4068], [-0.3263], [-0.3474]]) + C = matarrayin([[-0.9057, -0.4068, 0.3263, -0.3474]]) + D = matarrayin([[0.]]) + sys = StateSpace(A, B, C, D) rsys = modred(sys,[2, 3],'matchdc') - Artrue = np.matrix('-4.431, -4.552; -4.552, -5.361') - Brtrue = np.matrix('-1.362; -1.031') - Crtrue = np.matrix('-1.362, -1.031') - Drtrue = np.matrix('-0.08384') - np.testing.assert_array_almost_equal(rsys.A, Artrue,decimal=3) - np.testing.assert_array_almost_equal(rsys.B, Brtrue,decimal=3) - np.testing.assert_array_almost_equal(rsys.C, Crtrue,decimal=3) - np.testing.assert_array_almost_equal(rsys.D, Drtrue,decimal=2) - - def testModredUnstable(self): - # Check if an error is thrown when an unstable system is given - A = np.matrix('4.5418, 3.3999, 5.0342, 4.3808; \ - 0.3890, 0.3599, 0.4195, 0.1760; \ - -4.2117, -3.2395, -4.6760, -4.2180; \ - 0.0052, 0.0429, 0.0155, 0.2743') - B = np.matrix('1.0, 1.0; 2.0, 2.0; 3.0, 3.0; 4.0, 4.0') - C = np.matrix('1.0, 2.0, 3.0, 4.0; 1.0, 2.0, 3.0, 4.0') - D = np.matrix('0.0, 0.0; 0.0, 0.0') - sys = ss(A,B,C,D) + Artrue = np.array([[-4.431, -4.552], [-4.552, -5.361]]) + Brtrue = np.array([[-1.362], [-1.031]]) + Crtrue = np.array([[-1.362, -1.031]]) + Drtrue = np.array([[-0.08384]]) + np.testing.assert_array_almost_equal(rsys.A, Artrue, decimal=3) + np.testing.assert_array_almost_equal(rsys.B, Brtrue, decimal=3) + np.testing.assert_array_almost_equal(rsys.C, Crtrue, decimal=3) + np.testing.assert_array_almost_equal(rsys.D, Drtrue, decimal=2) + + def testModredUnstable(self, matarrayin): + """Check if an error is thrown when an unstable system is given""" + A = matarrayin( + [[4.5418, 3.3999, 5.0342, 4.3808], + [0.3890, 0.3599, 0.4195, 0.1760], + [-4.2117, -3.2395, -4.6760, -4.2180], + [0.0052, 0.0429, 0.0155, 0.2743]]) + B = matarrayin([[1.0, 1.0], [2.0, 2.0], [3.0, 3.0], [4.0, 4.0]]) + C = matarrayin([[1.0, 2.0, 3.0, 4.0], [1.0, 2.0, 3.0, 4.0]]) + D = matarrayin([[0.0, 0.0], [0.0, 0.0]]) + sys = StateSpace(A, B, C, D) np.testing.assert_raises(ValueError, modred, sys, [2, 3]) - def testModredTruncate(self): + def testModredTruncate(self, matarrayin): #balanced realization computed in matlab for the transfer function: # num = [1 11 45 32], den = [1 15 60 200 60] - A = np.matrix('-1.958, -1.194, 1.824, -1.464; \ - -1.194, -0.8344, 2.563, -1.351; \ - -1.824, -2.563, -1.124, 2.704; \ - -1.464, -1.351, -2.704, -11.08') - B = np.matrix('-0.9057; -0.4068; -0.3263; -0.3474') - C = np.matrix('-0.9057, -0.4068, 0.3263, -0.3474') - D = np.matrix('0.') - sys = ss(A,B,C,D) + A = matarrayin( + [[-1.958, -1.194, 1.824, -1.464], + [-1.194, -0.8344, 2.563, -1.351], + [-1.824, -2.563, -1.124, 2.704], + [-1.464, -1.351, -2.704, -11.08]]) + B = matarrayin([[-0.9057], [-0.4068], [-0.3263], [-0.3474]]) + C = matarrayin([[-0.9057, -0.4068, 0.3263, -0.3474]]) + D = matarrayin([[0.]]) + sys = StateSpace(A, B, C, D) rsys = modred(sys,[2, 3],'truncate') - Artrue = np.matrix('-1.958, -1.194; -1.194, -0.8344') - Brtrue = np.matrix('-0.9057; -0.4068') - Crtrue = np.matrix('-0.9057, -0.4068') - Drtrue = np.matrix('0.') + Artrue = np.array([[-1.958, -1.194], [-1.194, -0.8344]]) + Brtrue = np.array([[-0.9057], [-0.4068]]) + Crtrue = np.array([[-0.9057, -0.4068]]) + Drtrue = np.array([[0.]]) np.testing.assert_array_almost_equal(rsys.A, Artrue) np.testing.assert_array_almost_equal(rsys.B, Brtrue) np.testing.assert_array_almost_equal(rsys.C, Crtrue) np.testing.assert_array_almost_equal(rsys.D, Drtrue) - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testBalredTruncate(self): - #controlable canonical realization computed in matlab for the transfer function: + @slycotonly + def testBalredTruncate(self, matarrayin): + # controlable canonical realization computed in matlab for the transfer + # function: # num = [1 11 45 32], den = [1 15 60 200 60] - A = np.matrix('-15., -7.5, -6.25, -1.875; \ - 8., 0., 0., 0.; \ - 0., 4., 0., 0.; \ - 0., 0., 1., 0.') - B = np.matrix('2.; 0.; 0.; 0.') - C = np.matrix('0.5, 0.6875, 0.7031, 0.5') - D = np.matrix('0.') - sys = ss(A,B,C,D) + A = matarrayin( + [[-15., -7.5, -6.25, -1.875], + [8., 0., 0., 0.], + [0., 4., 0., 0.], + [0., 0., 1., 0.]]) + B = matarrayin([[2.], [0.], [0.], [0.]]) + C = matarrayin([[0.5, 0.6875, 0.7031, 0.5]]) + D = matarrayin([[0.]]) + sys = StateSpace(A, B, C, D) orders = 2 - rsys = balred(sys,orders,method='truncate') - Artrue = np.matrix('-1.958, -1.194; -1.194, -0.8344') - Brtrue = np.matrix('0.9057; 0.4068') - Crtrue = np.matrix('0.9057, 0.4068') - Drtrue = np.matrix('0.') - np.testing.assert_array_almost_equal(rsys.A, Artrue,decimal=2) - np.testing.assert_array_almost_equal(rsys.B, Brtrue,decimal=4) - np.testing.assert_array_almost_equal(rsys.C, Crtrue,decimal=4) - np.testing.assert_array_almost_equal(rsys.D, Drtrue,decimal=4) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testBalredMatchDC(self): - #controlable canonical realization computed in matlab for the transfer function: + rsys = balred(sys, orders, method='truncate') + Artrue = np.array([[-1.958, -1.194], [-1.194, -0.8344]]) + Brtrue = np.array([[0.9057], [0.4068]]) + Crtrue = np.array([[0.9057, 0.4068]]) + Drtrue = np.array([[0.]]) + np.testing.assert_array_almost_equal(rsys.A, Artrue, decimal=2) + np.testing.assert_array_almost_equal(rsys.B, Brtrue, decimal=4) + np.testing.assert_array_almost_equal(rsys.C, Crtrue, decimal=4) + np.testing.assert_array_almost_equal(rsys.D, Drtrue, decimal=4) + + @slycotonly + def testBalredMatchDC(self, matarrayin): + # controlable canonical realization computed in matlab for the transfer + # function: # num = [1 11 45 32], den = [1 15 60 200 60] - A = np.matrix('-15., -7.5, -6.25, -1.875; \ - 8., 0., 0., 0.; \ - 0., 4., 0., 0.; \ - 0., 0., 1., 0.') - B = np.matrix('2.; 0.; 0.; 0.') - C = np.matrix('0.5, 0.6875, 0.7031, 0.5') - D = np.matrix('0.') - sys = ss(A,B,C,D) + A = matarrayin( + [[-15., -7.5, -6.25, -1.875], + [8., 0., 0., 0.], + [0., 4., 0., 0.], + [0., 0., 1., 0.]]) + B = matarrayin([[2.], [0.], [0.], [0.]]) + C = matarrayin([[0.5, 0.6875, 0.7031, 0.5]]) + D = matarrayin([[0.]]) + sys = StateSpace(A, B, C, D) orders = 2 rsys = balred(sys,orders,method='matchdc') - Artrue = np.matrix('-4.43094773, -4.55232904; -4.55232904, -5.36195206') - Brtrue = np.matrix('1.36235673; 1.03114388') - Crtrue = np.matrix('1.36235673, 1.03114388') - Drtrue = np.matrix('-0.08383902') - np.testing.assert_array_almost_equal(rsys.A, Artrue,decimal=2) - np.testing.assert_array_almost_equal(rsys.B, Brtrue,decimal=4) - np.testing.assert_array_almost_equal(rsys.C, Crtrue,decimal=4) - np.testing.assert_array_almost_equal(rsys.D, Drtrue,decimal=4) - - -if __name__ == '__main__': - unittest.main() + Artrue = np.array( + [[-4.43094773, -4.55232904], + [-4.55232904, -5.36195206]]) + Brtrue = np.array([[1.36235673], [1.03114388]]) + Crtrue = np.array([[1.36235673, 1.03114388]]) + Drtrue = np.array([[-0.08383902]]) + np.testing.assert_array_almost_equal(rsys.A, Artrue, decimal=2) + np.testing.assert_array_almost_equal(rsys.B, Brtrue, decimal=4) + np.testing.assert_array_almost_equal(rsys.C, Crtrue, decimal=4) + np.testing.assert_array_almost_equal(rsys.D, Drtrue, decimal=4) + diff --git a/control/tests/nichols_test.py b/control/tests/nichols_test.py index 9cf15ae44..4cdfcaa65 100644 --- a/control/tests/nichols_test.py +++ b/control/tests/nichols_test.py @@ -1,34 +1,28 @@ -#!/usr/bin/env python -# -# nichols_test.py - test Nichols plot -# RMM, 31 Mar 2011 +"""nichols_test.py - test Nichols plot -import unittest -import numpy as np -from control.matlab import * +RMM, 31 Mar 2011 +""" -class TestStateSpace(unittest.TestCase): - """Tests for the Nichols plots.""" +import pytest - def setUp(self): - """Set up a system to test operations on.""" +from control import StateSpace, nichols_plot, nichols - A = [[-3., 4., 2.], [-1., -3., 0.], [2., 5., 3.]] - B = [[1.], [-3.], [-2.]] - C = [[4., 2., -3.]] - D = [[0.]] - self.sys = StateSpace(A, B, C, D) +@pytest.fixture() +def tsys(): + """Set up a system to test operations on.""" + A = [[-3., 4., 2.], [-1., -3., 0.], [2., 5., 3.]] + B = [[1.], [-3.], [-2.]] + C = [[4., 2., -3.]] + D = [[0.]] + return StateSpace(A, B, C, D) - def testNicholsPlain(self): - """Generate a Nichols plot.""" - nichols(self.sys) - def testNgrid(self): - """Generate a Nichols plot.""" - nichols(self.sys, grid=False) - ngrid() +def test_nichols(tsys, mplcleanup): + """Generate a Nichols plot.""" + nichols_plot(tsys) -if __name__ == "__main__": - unittest.main() +def test_nichols_alias(tsys, mplcleanup): + """Test the control.nichols alias and the grid=False parameter""" + nichols(tsys, grid=False) diff --git a/control/tests/nyquist_test.py b/control/tests/nyquist_test.py new file mode 100644 index 000000000..84898cc74 --- /dev/null +++ b/control/tests/nyquist_test.py @@ -0,0 +1,288 @@ +"""nyquist_test.py - test Nyquist plots + +RMM, 30 Jan 2021 + +This set of unit tests covers various Nyquist plot configurations. Because +much of the output from these tests are graphical, this file can also be run +from ipython to generate plots interactively. + +""" + +import pytest +import numpy as np +import scipy as sp +import matplotlib.pyplot as plt +import control as ct + +# In interactive mode, turn on ipython interactive graphics +plt.ion() + + +# Utility function for counting unstable poles of open loop (P in FBS) +def _P(sys, indent='right'): + if indent == 'right': + return (sys.pole().real > 0).sum() + elif indent == 'left': + return (sys.pole().real >= 0).sum() + elif indent == 'none': + if any(sys.pole().real == 0): + raise ValueError("indent must be left or right for imaginary pole") + else: + raise TypeError("unknown indent value") + + +# Utility function for counting unstable poles of closed loop (Z in FBS) +def _Z(sys): + return (sys.feedback().pole().real >= 0).sum() + + +# Basic tests +@pytest.mark.usefixtures("mplcleanup") +def test_nyquist_basic(): + # Simple Nyquist plot + sys = ct.rss(5, 1, 1) + N_sys = ct.nyquist_plot(sys) + assert _Z(sys) == N_sys + _P(sys) + + # Unstable system + sys = ct.tf([10], [1, 2, 2, 1]) + N_sys = ct.nyquist_plot(sys) + assert _Z(sys) > 0 + assert _Z(sys) == N_sys + _P(sys) + + # Multiple systems - return value is final system + sys1 = ct.rss(3, 1, 1) + sys2 = ct.rss(4, 1, 1) + sys3 = ct.rss(5, 1, 1) + counts = ct.nyquist_plot([sys1, sys2, sys3]) + for N_sys, sys in zip(counts, [sys1, sys2, sys3]): + assert _Z(sys) == N_sys + _P(sys) + + # Nyquist plot with poles at the origin, omega specified + sys = ct.tf([1], [1, 3, 2]) * ct.tf([1], [1, 0]) + omega = np.linspace(0, 1e2, 100) + count, contour = ct.nyquist_plot(sys, omega, return_contour=True) + np.testing.assert_array_equal( + contour[contour.real < 0], omega[contour.real < 0]) + + # Make sure things match at unmodified frequencies + np.testing.assert_almost_equal( + contour[contour.real == 0], + 1j*np.linspace(0, 1e2, 100)[contour.real == 0]) + + # Make sure that we can turn off frequency modification + count, contour_indented = ct.nyquist_plot( + sys, np.linspace(1e-4, 1e2, 100), return_contour=True) + assert not all(contour_indented.real == 0) + count, contour = ct.nyquist_plot( + sys, np.linspace(1e-4, 1e2, 100), return_contour=True, + indent_direction='none') + np.testing.assert_almost_equal(contour, 1j*np.linspace(1e-4, 1e2, 100)) + + # Nyquist plot with poles at the origin, omega unspecified + sys = ct.tf([1], [1, 3, 2]) * ct.tf([1], [1, 0]) + count, contour = ct.nyquist_plot(sys, return_contour=True) + assert _Z(sys) == count + _P(sys) + + # Nyquist plot with poles at the origin, return contour + sys = ct.tf([1], [1, 3, 2]) * ct.tf([1], [1, 0]) + count, contour = ct.nyquist_plot(sys, return_contour=True) + assert _Z(sys) == count + _P(sys) + + # Nyquist plot with poles on imaginary axis, omega specified + sys = ct.tf([1], [1, 3, 2]) * ct.tf([1], [1, 0, 1]) + count = ct.nyquist_plot(sys, np.linspace(1e-3, 1e1, 1000)) + assert _Z(sys) == count + _P(sys) + + # Nyquist plot with poles on imaginary axis, omega specified, with contour + sys = ct.tf([1], [1, 3, 2]) * ct.tf([1], [1, 0, 1]) + count, contour = ct.nyquist_plot( + sys, np.linspace(1e-3, 1e1, 1000), return_contour=True) + assert _Z(sys) == count + _P(sys) + + # Nyquist plot with poles on imaginary axis, return contour + sys = ct.tf([1], [1, 3, 2]) * ct.tf([1], [1, 0, 1]) + count, contour = ct.nyquist_plot(sys, return_contour=True) + assert _Z(sys) == count + _P(sys) + + # Nyquist plot with poles at the origin and on imaginary axis + sys = ct.tf([1], [1, 3, 2]) * ct.tf([1], [1, 0, 1]) * ct.tf([1], [1, 0]) + count, contour = ct.nyquist_plot(sys, return_contour=True) + assert _Z(sys) == count + _P(sys) + + +# Some FBS examples, for comparison +@pytest.mark.usefixtures("mplcleanup") +def test_nyquist_fbs_examples(): + s = ct.tf('s') + + """Run through various examples from FBS2e to compare plots""" + plt.figure() + plt.title("Figure 10.4: L(s) = 1.4 e^{-s}/(s+1)^2") + sys = ct.tf([1.4], [1, 2, 1]) * ct.tf(*ct.pade(1, 4)) + count = ct.nyquist_plot(sys) + assert _Z(sys) == count + _P(sys) + + plt.figure() + plt.title("Figure 10.4: L(s) = 1/(s + a)^2 with a = 0.6") + sys = 1/(s + 0.6)**3 + count = ct.nyquist_plot(sys) + assert _Z(sys) == count + _P(sys) + + plt.figure() + plt.title("Figure 10.6: L(s) = 1/(s (s+1)^2) - pole at the origin") + sys = 1/(s * (s+1)**2) + count = ct.nyquist_plot(sys) + assert _Z(sys) == count + _P(sys) + + plt.figure() + plt.title("Figure 10.10: L(s) = 3 (s+6)^2 / (s (s+1)^2)") + sys = 3 * (s+6)**2 / (s * (s+1)**2) + count = ct.nyquist_plot(sys) + assert _Z(sys) == count + _P(sys) + + plt.figure() + plt.title("Figure 10.10: L(s) = 3 (s+6)^2 / (s (s+1)^2) [zoom]") + count = ct.nyquist_plot(sys, omega_limits=[1.5, 1e3]) + # Frequency limits for zoom give incorrect encirclement count + # assert _Z(sys) == count + _P(sys) + assert count == -1 + + +@pytest.mark.parametrize("arrows", [ + None, # default argument + 1, 2, 3, 4, # specified number of arrows + [0.1, 0.5, 0.9], # specify arc lengths +]) +@pytest.mark.usefixtures("mplcleanup") +def test_nyquist_arrows(arrows): + sys = ct.tf([1.4], [1, 2, 1]) * ct.tf(*ct.pade(1, 4)) + plt.figure(); + plt.title("L(s) = 1.4 e^{-s}/(s+1)^2 / arrows = %s" % arrows) + count = ct.nyquist_plot(sys, arrows=arrows) + assert _Z(sys) == count + _P(sys) + + +@pytest.mark.usefixtures("mplcleanup") +def test_nyquist_encirclements(): + # Example 14.14: effect of friction in a cart-pendulum system + s = ct.tf('s') + sys = (0.02 * s**3 - 0.1 * s) / (s**4 + s**3 + s**2 + 0.25 * s + 0.04) + + plt.figure(); + count = ct.nyquist_plot(sys) + plt.title("Stable system; encirclements = %d" % count) + assert _Z(sys) == count + _P(sys) + + plt.figure(); + count = ct.nyquist_plot(sys * 3) + plt.title("Unstable system; encirclements = %d" % count) + assert _Z(sys * 3) == count + _P(sys * 3) + + # System with pole at the origin + sys = ct.tf([3], [1, 2, 2, 1, 0]) + + plt.figure(); + count = ct.nyquist_plot(sys) + plt.title("Pole at the origin; encirclements = %d" % count) + assert _Z(sys) == count + _P(sys) + + +@pytest.mark.usefixtures("mplcleanup") +def test_nyquist_indent(): + # FBS Figure 10.10 + s = ct.tf('s') + sys = 3 * (s+6)**2 / (s * (s+1)**2) + + plt.figure(); + count = ct.nyquist_plot(sys) + plt.title("Pole at origin; indent_radius=default") + assert _Z(sys) == count + _P(sys) + + plt.figure(); + count = ct.nyquist_plot(sys, indent_radius=0.01) + plt.title("Pole at origin; indent_radius=0.01; encirclements = %d" % count) + assert _Z(sys) == count + _P(sys) + + plt.figure(); + count = ct.nyquist_plot(sys, indent_direction='left') + plt.title( + "Pole at origin; indent_direction='left'; encirclements = %d" % count) + assert _Z(sys) == count + _P(sys, indent='left') + + # System with poles on the imaginary axis + sys = ct.tf([1, 1], [1, 0, 1]) + + # Imaginary poles with standard indentation + plt.figure(); + count = ct.nyquist_plot(sys) + plt.title("Imaginary poles; encirclements = %d" % count) + assert _Z(sys) == count + _P(sys) + + # Imaginary poles with indentation to the left + plt.figure(); + count = ct.nyquist_plot(sys, indent_direction='left', label_freq=300) + plt.title( + "Imaginary poles; indent_direction='left'; encirclements = %d" % count) + assert _Z(sys) == count + _P(sys, indent='left') + + # Imaginary poles with no indentation + plt.figure(); + count = ct.nyquist_plot( + sys, np.linspace(0, 1e3, 1000), indent_direction='none') + plt.title( + "Imaginary poles; indent_direction='none'; encirclements = %d" % count) + assert _Z(sys) == count + _P(sys) + + +def test_nyquist_exceptions(): + # MIMO not implemented + sys = ct.rss(2, 2, 2) + with pytest.raises( + ct.exception.ControlMIMONotImplemented, + match="only supports SISO"): + ct.nyquist_plot(sys) + + # Legacy keywords for arrow size + sys = ct.rss(2, 1, 1) + with pytest.warns(FutureWarning, match="use `arrow_size` instead"): + ct.nyquist_plot(sys, arrow_width=8, arrow_length=6) + + # Discrete time system sampled above Nyquist frequency + sys = ct.drss(2, 1, 1) + sys.dt = 0.01 + with pytest.warns(UserWarning, match="above Nyquist"): + ct.nyquist_plot(sys, np.logspace(-2, 3)) + + +# +# Interactive mode: generate plots for manual viewing +# +# Running this script in python (or better ipython) will show a collection of +# figures that should all look OK on the screeen. +# + +# Start by clearing existing figures +plt.close('all') + +print("Nyquist examples from FBS") +test_nyquist_fbs_examples() + +print("Arrow test") +test_nyquist_arrows(None) +test_nyquist_arrows(1) +test_nyquist_arrows(3) +test_nyquist_arrows([0.1, 0.5, 0.9]) + +print("Stability checks") +test_nyquist_encirclements() + +print("Indentation checks") +test_nyquist_indent() + +print("Unusual Nyquist plot") +sys = ct.tf([1], [1, 3, 2]) * ct.tf([1], [1, 0, 1]) +plt.figure() +plt.title("Poles: %s" % np.array2string(sys.pole(), precision=2, separator=',')) +count = ct.nyquist_plot(sys) +assert _Z(sys) == count + _P(sys) diff --git a/control/tests/optimal_test.py b/control/tests/optimal_test.py new file mode 100644 index 000000000..528313e9d --- /dev/null +++ b/control/tests/optimal_test.py @@ -0,0 +1,469 @@ +"""optimal_test.py - tests for optimization based control + +RMM, 17 Apr 2019 check the functionality for optimization based control. +RMM, 30 Dec 2020 convert to pytest +""" + +import pytest +import warnings +import numpy as np +import scipy as sp +import math +import control as ct +import control.optimal as opt +import control.flatsys as flat +from control.tests.conftest import slycotonly +from numpy.lib import NumpyVersion + + +def test_finite_horizon_simple(): + # Define a linear system with constraints + # Source: https://www.mpt3.org/UI/RegulationProblem + + # LTI prediction model + sys = ct.ss2io(ct.ss([[1, 1], [0, 1]], [[1], [0.5]], np.eye(2), 0, 1)) + + # State and input constraints + constraints = [ + (sp.optimize.LinearConstraint, np.eye(3), [-5, -5, -1], [5, 5, 1]), + ] + + # Quadratic state and input penalty + Q = [[1, 0], [0, 1]] + R = [[1]] + cost = opt.quadratic_cost(sys, Q, R) + + # Set up the optimal control problem + time = np.arange(0, 5, 1) + x0 = [4, 0] + + # Retrieve the full open-loop predictions + res = opt.solve_ocp( + sys, time, x0, cost, constraints, squeeze=True) + t, u_openloop = res.time, res.inputs + np.testing.assert_almost_equal( + u_openloop, [-1, -1, 0.1393, 0.3361, -5.204e-16], decimal=4) + + # Convert controller to an explicit form (not implemented yet) + # mpc_explicit = opt.explicit_mpc(); + + # Test explicit controller + # u_explicit = mpc_explicit(x0) + # np.testing.assert_array_almost_equal(u_openloop, u_explicit) + + +# +# Compare to LQR solution +# +# The next unit test is intended to confirm that a finite horizon +# optimal control problem with terminal cost set to LQR "cost to go" +# gives the same answer as LQR. Unfortunately, it requires a discrete +# time LQR function which is not yet availbale => for now this just +# tests the interface a bit. +# +@slycotonly +def test_discrete_lqr(): + # oscillator model defined in 2D + # Source: https://www.mpt3.org/UI/RegulationProblem + A = [[0.5403, -0.8415], [0.8415, 0.5403]] + B = [[-0.4597], [0.8415]] + C = [[1, 0]] + D = [[0]] + + # Linear discrete-time model with sample time 1 + sys = ct.ss2io(ct.ss(A, B, C, D, 1)) + + # Include weights on states/inputs + Q = np.eye(2) + R = 1 + K, S, E = ct.lqr(A, B, Q, R) # note: *continuous* time LQR + + # Compute the integral and terminal cost + integral_cost = opt.quadratic_cost(sys, Q, R) + terminal_cost = opt.quadratic_cost(sys, S, None) + + # Formulate finite horizon MPC problem + time = np.arange(0, 5, 1) + x0 = np.array([1, 1]) + optctrl = opt.OptimalControlProblem( + sys, time, integral_cost, terminal_cost=terminal_cost) + res1 = optctrl.compute_trajectory(x0, return_states=True) + + with pytest.xfail("discrete LQR not implemented"): + # Result should match LQR + K, S, E = ct.dlqr(A, B, Q, R) + lqr_sys = ct.ss2io(ct.ss(A - B @ K, B, C, D, 1)) + _, _, lqr_x = ct.input_output_response( + lqr_sys, time, 0, x0, return_x=True) + np.testing.assert_almost_equal(res1.states, lqr_x) + + # Add state and input constraints + trajectory_constraints = [ + (sp.optimize.LinearConstraint, np.eye(3), [-10, -10, -1], [10, 10, 1]), + ] + + # Re-solve + res2 = opt.solve_ocp( + sys, time, x0, integral_cost, constraints, terminal_cost=terminal_cost) + + # Make sure we got a different solution + assert np.any(np.abs(res1.inputs - res2.inputs) > 0.1) + + +def test_mpc_iosystem(): + # model of an aircraft discretized with 0.2s sampling time + # Source: https://www.mpt3.org/UI/RegulationProblem + A = [[0.99, 0.01, 0.18, -0.09, 0], + [ 0, 0.94, 0, 0.29, 0], + [ 0, 0.14, 0.81, -0.9, 0], + [ 0, -0.2, 0, 0.95, 0], + [ 0, 0.09, 0, 0, 0.9]] + B = [[ 0.01, -0.02], + [-0.14, 0], + [ 0.05, -0.2], + [ 0.02, 0], + [-0.01, 0]] + C = [[0, 1, 0, 0, -1], + [0, 0, 1, 0, 0], + [0, 0, 0, 1, 0], + [1, 0, 0, 0, 0]] + model = ct.ss2io(ct.ss(A, B, C, 0, 0.2)) + + # For the simulation we need the full state output + sys = ct.ss2io(ct.ss(A, B, np.eye(5), 0, 0.2)) + + # compute the steady state values for a particular value of the input + ud = np.array([0.8, -0.3]) + xd = np.linalg.inv(np.eye(5) - A) @ B @ ud + yd = C @ xd + + # provide constraints on the system signals + constraints = [opt.input_range_constraint(sys, [-5, -6], [5, 6])] + + # provide penalties on the system signals + Q = model.C.transpose() @ np.diag([10, 10, 10, 10]) @ model.C + R = np.diag([3, 2]) + cost = opt.quadratic_cost(model, Q, R, x0=xd, u0=ud) + + # online MPC controller object is constructed with a horizon 6 + ctrl = opt.create_mpc_iosystem( + model, np.arange(0, 6) * 0.2, cost, constraints) + + # Define an I/O system implementing model predictive control + loop = ct.feedback(sys, ctrl, 1) + + # Choose a nearby initial condition to speed up computation + X0 = np.hstack([xd, np.kron(ud, np.ones(6))]) * 0.99 + + Nsim = 12 + tout, xout = ct.input_output_response( + loop, np.arange(0, Nsim) * 0.2, 0, X0) + + # Make sure the system converged to the desired state + np.testing.assert_allclose( + xout[0:sys.nstates, -1], xd, atol=0.1, rtol=0.01) + + +# Test various constraint combinations; need to use a somewhat convoluted +# parametrization due to the need to define sys instead the test function +@pytest.mark.parametrize("constraint_list", [ + [(sp.optimize.LinearConstraint, np.eye(3), [-5, -5, -1], [5, 5, 1],)], + [(opt.state_range_constraint, [-5, -5], [5, 5]), + (opt.input_range_constraint, [-1], [1])], + [(opt.state_range_constraint, [-5, -5], [5, 5]), + (opt.input_poly_constraint, np.array([[1], [-1]]), [1, 1])], + [(opt.state_poly_constraint, + np.array([[1, 0], [0, 1], [-1, 0], [0, -1]]), [5, 5, 5, 5]), + (opt.input_poly_constraint, np.array([[1], [-1]]), [1, 1])], + [(opt.output_range_constraint, [-5, -5], [5, 5]), + (opt.input_poly_constraint, np.array([[1], [-1]]), [1, 1])], + [(opt.output_poly_constraint, + np.array([[1, 0], [0, 1], [-1, 0], [0, -1]]), [5, 5, 5, 5]), + (opt.input_poly_constraint, np.array([[1], [-1]]), [1, 1])], + [(sp.optimize.NonlinearConstraint, + lambda x, u: np.array([x[0], x[1], u[0]]), [-5, -5, -1], [5, 5, 1])], +]) +def test_constraint_specification(constraint_list): + sys = ct.ss2io(ct.ss([[1, 1], [0, 1]], [[1], [0.5]], np.eye(2), 0, 1)) + + """Test out different forms of constraints on a simple problem""" + # Parse out the constraint + constraints = [] + for constraint_setup in constraint_list: + if constraint_setup[0] in \ + (sp.optimize.LinearConstraint, sp.optimize.NonlinearConstraint): + # No processing required + constraints.append(constraint_setup) + else: + # Call the function in the first argument to set up the constraint + constraints.append(constraint_setup[0](sys, *constraint_setup[1:])) + + # Quadratic state and input penalty + Q = [[1, 0], [0, 1]] + R = [[1]] + cost = opt.quadratic_cost(sys, Q, R) + + # Create a model predictive controller system + time = np.arange(0, 5, 1) + optctrl = opt.OptimalControlProblem(sys, time, cost, constraints) + + # Compute optimal control and compare against MPT3 solution + x0 = [4, 0] + res = optctrl.compute_trajectory(x0, squeeze=True) + t, u_openloop = res.time, res.inputs + np.testing.assert_almost_equal( + u_openloop, [-1, -1, 0.1393, 0.3361, -5.204e-16], decimal=3) + + +@pytest.mark.parametrize("sys_args", [ + pytest.param( + ([[1, 0], [0, 1]], np.eye(2), np.eye(2), 0, True), + id = "discrete, no timebase"), + pytest.param( + ([[1, 0], [0, 1]], np.eye(2), np.eye(2), 0, 1), + id = "discrete, dt=1"), + pytest.param( + (np.zeros((2,2)), np.eye(2), np.eye(2), 0), + id = "continuous"), +]) +def test_terminal_constraints(sys_args): + """Test out the ability to handle terminal constraints""" + # Create the system + sys = ct.ss2io(ct.ss(*sys_args)) + + # Shortest path to a point is a line + Q = np.zeros((2, 2)) + R = np.eye(2) + cost = opt.quadratic_cost(sys, Q, R) + + # Set up the terminal constraint to be the origin + final_point = [opt.state_range_constraint(sys, [0, 0], [0, 0])] + + # Create the optimal control problem + time = np.arange(0, 3, 1) + optctrl = opt.OptimalControlProblem( + sys, time, cost, terminal_constraints=final_point) + + # Find a path to the origin + x0 = np.array([4, 3]) + res = optctrl.compute_trajectory(x0, squeeze=True, return_x=True) + t, u1, x1 = res.time, res.inputs, res.states + + # Bug prior to SciPy 1.6 will result in incorrect results + if NumpyVersion(sp.__version__) < '1.6.0': + pytest.xfail("SciPy 1.6 or higher required") + + np.testing.assert_almost_equal(x1[:,-1], 0, decimal=4) + + # Make sure it is a straight line + Tf = time[-1] + if ct.isctime(sys): + # Continuous time is not that accurate on the input, so just skip test + pass + else: + # Final point doesn't affect cost => don't need to test + np.testing.assert_almost_equal( + u1[:, 0:-1], + np.kron((-x0/Tf).reshape((2, 1)), np.ones(time.shape))[:, 0:-1]) + np.testing.assert_allclose( + x1, np.kron(x0.reshape((2, 1)), time[::-1]/Tf), atol=0.1, rtol=0.01) + + # Re-run using initial guess = optional and make sure nothing changes + res = optctrl.compute_trajectory(x0, initial_guess=u1) + np.testing.assert_almost_equal(res.inputs, u1) + + # Re-run using a basis function and see if we get the same answer + res = opt.solve_ocp(sys, time, x0, cost, terminal_constraints=final_point, + basis=flat.BezierFamily(4, Tf)) + np.testing.assert_almost_equal(res.inputs, u1, decimal=2) + + # Impose some cost on the state, which should change the path + Q = np.eye(2) + R = np.eye(2) * 0.1 + cost = opt.quadratic_cost(sys, Q, R) + optctrl = opt.OptimalControlProblem( + sys, time, cost, terminal_constraints=final_point) + + # Turn off warning messages, since we sometimes don't get convergence + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", message="unable to solve", category=UserWarning) + # Find a path to the origin + res = optctrl.compute_trajectory( + x0, squeeze=True, return_x=True, initial_guess=u1) + t, u2, x2 = res.time, res.inputs, res.states + + # Not all configurations are able to converge (?) + if res.success: + np.testing.assert_almost_equal(x2[:,-1], 0) + + # Make sure that it is *not* a straight line path + assert np.any(np.abs(x2 - x1) > 0.1) + assert np.any(np.abs(u2) > 1) # Make sure next test is useful + + # Add some bounds on the inputs + constraints = [opt.input_range_constraint(sys, [-1, -1], [1, 1])] + optctrl = opt.OptimalControlProblem( + sys, time, cost, constraints, terminal_constraints=final_point) + res = optctrl.compute_trajectory(x0, squeeze=True, return_x=True) + t, u3, x3 = res.time, res.inputs, res.states + + # Check the answers only if we converged + if res.success: + np.testing.assert_almost_equal(x3[:,-1], 0, decimal=4) + + # Make sure we got a new path and didn't violate the constraints + assert np.any(np.abs(x3 - x1) > 0.1) + np.testing.assert_array_less(np.abs(u3), 1 + 1e-6) + + # Make sure that infeasible problems are handled sensibly + x0 = np.array([10, 3]) + with pytest.warns(UserWarning, match="unable to solve"): + res = optctrl.compute_trajectory(x0, squeeze=True, return_x=True) + assert not res.success + + +def test_optimal_logging(capsys): + """Test logging functions (mainly for code coverage)""" + sys = ct.ss2io(ct.ss(np.eye(2), np.eye(2), np.eye(2), 0, 1)) + + # Set up the optimal control problem + cost = opt.quadratic_cost(sys, 1, 1) + state_constraint = opt.state_range_constraint( + sys, [-np.inf, 1], [10, 1]) + input_constraint = opt.input_range_constraint(sys, [-100, -100], [100, 100]) + time = np.arange(0, 3, 1) + x0 = [-1, 1] + + # Solve it, with logging turned on (with warning due to mixed constraints) + with pytest.warns(sp.optimize.optimize.OptimizeWarning, + match="Equality and inequality .* same element"): + res = opt.solve_ocp( + sys, time, x0, cost, input_constraint, terminal_cost=cost, + terminal_constraints=state_constraint, log=True) + + # Make sure the output has info available only with logging turned on + captured = capsys.readouterr() + assert captured.out.find("process time") != -1 + + +@pytest.mark.parametrize("fun, args, exception, match", [ + [opt.quadratic_cost, (np.zeros((2, 3)), np.eye(2)), ValueError, + "Q matrix is the wrong shape"], + [opt.quadratic_cost, (np.eye(2), 1), ValueError, + "R matrix is the wrong shape"], +]) +def test_constraint_constructor_errors(fun, args, exception, match): + """Test various error conditions for constraint constructors""" + sys = ct.ss2io(ct.rss(2, 2, 2)) + with pytest.raises(exception, match=match): + fun(sys, *args) + + +@pytest.mark.parametrize("fun, args, exception, match", [ + [opt.input_poly_constraint, (np.zeros((2, 3)), [0, 0]), ValueError, + "polytope matrix must match number of inputs"], + [opt.output_poly_constraint, (np.zeros((2, 3)), [0, 0]), ValueError, + "polytope matrix must match number of outputs"], + [opt.state_poly_constraint, (np.zeros((2, 3)), [0, 0]), ValueError, + "polytope matrix must match number of states"], + [opt.input_poly_constraint, (np.zeros((2, 2)), [0, 0, 0]), ValueError, + "number of bounds must match number of constraints"], + [opt.output_poly_constraint, (np.zeros((2, 2)), [0, 0, 0]), ValueError, + "number of bounds must match number of constraints"], + [opt.state_poly_constraint, (np.zeros((2, 2)), [0, 0, 0]), ValueError, + "number of bounds must match number of constraints"], + [opt.input_poly_constraint, (np.zeros((2, 2)), [[0, 0, 0]]), ValueError, + "number of bounds must match number of constraints"], + [opt.output_poly_constraint, (np.zeros((2, 2)), [[0, 0, 0]]), ValueError, + "number of bounds must match number of constraints"], + [opt.state_poly_constraint, (np.zeros((2, 2)), 0), ValueError, + "number of bounds must match number of constraints"], + [opt.input_range_constraint, ([1, 2, 3], [0, 0]), ValueError, + "input bounds must match"], + [opt.output_range_constraint, ([2, 3], [0, 0, 0]), ValueError, + "output bounds must match"], + [opt.state_range_constraint, ([1, 2, 3], [0, 0, 0]), ValueError, + "state bounds must match"], +]) +def test_constraint_constructor_errors(fun, args, exception, match): + """Test various error conditions for constraint constructors""" + sys = ct.ss2io(ct.rss(2, 2, 2)) + with pytest.raises(exception, match=match): + fun(sys, *args) + + +def test_ocp_argument_errors(): + sys = ct.ss2io(ct.ss([[1, 1], [0, 1]], [[1], [0.5]], np.eye(2), 0, 1)) + + # State and input constraints + constraints = [ + (sp.optimize.LinearConstraint, np.eye(3), [-5, -5, -1], [5, 5, 1]), + ] + + # Quadratic state and input penalty + Q = [[1, 0], [0, 1]] + R = [[1]] + cost = opt.quadratic_cost(sys, Q, R) + + # Set up the optimal control problem + time = np.arange(0, 5, 1) + x0 = [4, 0] + + # Trajectory constraints not in the right form + with pytest.raises(TypeError, match="constraints must be a list"): + res = opt.solve_ocp(sys, time, x0, cost, np.eye(2)) + + # Terminal constraints not in the right form + with pytest.raises(TypeError, match="constraints must be a list"): + res = opt.solve_ocp( + sys, time, x0, cost, constraints, terminal_constraints=np.eye(2)) + + # Initial guess in the wrong shape + with pytest.raises(ValueError, match="initial guess is the wrong shape"): + res = opt.solve_ocp( + sys, time, x0, cost, constraints, initial_guess=np.zeros((4,1,1))) + + +def test_optimal_basis_simple(): + sys = ct.ss2io(ct.ss([[1, 1], [0, 1]], [[1], [0.5]], np.eye(2), 0, 1)) + + # State and input constraints + constraints = [ + (sp.optimize.LinearConstraint, np.eye(3), [-5, -5, -1], [5, 5, 1]), + ] + + # Quadratic state and input penalty + Q = [[1, 0], [0, 1]] + R = [[1]] + cost = opt.quadratic_cost(sys, Q, R) + + # Set up the optimal control problem + Tf = 5 + time = np.arange(0, Tf, 1) + x0 = [4, 0] + + # Basic optimal control problem + res1 = opt.solve_ocp( + sys, time, x0, cost, constraints, + basis=flat.BezierFamily(4, Tf), return_x=True) + assert res1.success + + # Make sure the constraints were satisfied + np.testing.assert_array_less(np.abs(res1.states[0]), 5 + 1e-6) + np.testing.assert_array_less(np.abs(res1.states[1]), 5 + 1e-6) + np.testing.assert_array_less(np.abs(res1.inputs[0]), 1 + 1e-6) + + # Pass an initial guess and rerun + res2 = opt.solve_ocp( + sys, time, x0, cost, constraints, initial_guess=0.99*res1.inputs, + basis=flat.BezierFamily(4, Tf), return_x=True) + assert res2.success + np.testing.assert_allclose(res2.inputs, res1.inputs, atol=0.01, rtol=0.01) + + # Run with logging turned on for code coverage + res3 = opt.solve_ocp( + sys, time, x0, cost, constraints, + basis=flat.BezierFamily(4, Tf), return_x=True, log=True) + assert res3.success + np.testing.assert_almost_equal(res3.inputs, res1.inputs, decimal=3) diff --git a/control/tests/phaseplot_test.py b/control/tests/phaseplot_test.py index 5b41615d7..8336ae975 100644 --- a/control/tests/phaseplot_test.py +++ b/control/tests/phaseplot_test.py @@ -1,27 +1,28 @@ -#!/usr/bin/env python -# -# phaseplot_test.py - test phase plot functions -# RMM, 17 24 2011 (based on TestMatlab from v0.4c) -# -# This test suite calls various phaseplot functions. Since the plots -# themselves can't be verified, this is mainly here to make sure all -# of the function arguments are handled correctly. If you run an -# individual test by itself and then type show(), it should pop open -# the figures so that you can check them visually. - -import unittest -import numpy as np -import scipy as sp +"""phaseplot_test.py - test phase plot functions + +RMM, 17 24 2011 (based on TestMatlab from v0.4c) + +This test suite calls various phaseplot functions. Since the plots +themselves can't be verified, this is mainly here to make sure all +of the function arguments are handled correctly. If you run an +individual test by itself and then type show(), it should pop open +the figures so that you can check them visually. +""" + + import matplotlib.pyplot as mpl -from control import phase_plot +import numpy as np from numpy import pi +import pytest +from control import phase_plot + -class TestPhasePlot(unittest.TestCase): - def setUp(self): - pass + +@pytest.mark.usefixtures("mplcleanup") +class TestPhasePlot: def testInvPendNoSims(self): - phase_plot(self.invpend_ode, (-6,6,10), (-6,6,10)) + phase_plot(self.invpend_ode, (-6,6,10), (-6,6,10)); def testInvPendSims(self): phase_plot(self.invpend_ode, (-6,6,10), (-6,6,10), @@ -74,12 +75,8 @@ def d1(x1x2,t): # Sample dynamical systems - inverted pendulum def invpend_ode(self, x, t, m=1., l=1., b=0, g=9.8): import numpy as np - return (x[1], -b/m*x[1] + (g*l/m)*np.sin(x[0])) + return (x[1], -b/m*x[1] + (g*l/m) * np.sin(x[0])) # Sample dynamical systems - oscillator def oscillator_ode(self, x, t, m=1., b=1, k=1, extra=None): return (x[1], -k/m*x[0] - b/m*x[1]) - - -if __name__ == '__main__': - unittest.main() diff --git a/control/tests/pzmap_test.py b/control/tests/pzmap_test.py old mode 100755 new mode 100644 diff --git a/control/tests/rlocus_test.py b/control/tests/rlocus_test.py index d4c03307d..aa25cd2b7 100644 --- a/control/tests/rlocus_test.py +++ b/control/tests/rlocus_test.py @@ -1,30 +1,34 @@ -#!/usr/bin/env python -# -# rlocus_test.py - unit test for root locus diagrams -# RMM, 1 Jul 2011 +"""rlocus_test.py - unit test for root locus diagrams + +RMM, 1 Jul 2011 +""" -import unittest import matplotlib.pyplot as plt import numpy as np from numpy.testing import assert_array_almost_equal +import pytest +import control as ct from control.rlocus import root_locus, _RLClickDispatcher from control.xferfcn import TransferFunction from control.statesp import StateSpace from control.bdalg import feedback -class TestRootLocus(unittest.TestCase): +class TestRootLocus: """These are tests for the feedback function in rlocus.py.""" - def setUp(self): - """This contains some random LTI systems and scalars for testing.""" - - # Two random SISO systems. - sys1 = TransferFunction([1, 2], [1, 2, 3]) - sys2 = StateSpace([[1., 4.], [3., 2.]], [[1.], [-4.]], - [[1., 0.]], [[0.]]) - self.systems = (sys1, sys2) + @pytest.fixture(params=[(TransferFunction, ([1, 2], [1, 2, 3])), + (StateSpace, ([[1., 4.], [3., 2.]], + [[1.], [-4.]], + [[1., 0.]], [[0.]]))], + ids=["tf", "ss"]) + def sys(self, request): + """Return some simple LTI system for testing""" + # avoid construction during collection time: prevent unfiltered + # deprecation warning + sysfn, args = request.param + return sysfn(*args) def check_cl_poles(self, sys, pole_list, k_list): for k, poles in zip(k_list, pole_list): @@ -32,19 +36,18 @@ def check_cl_poles(self, sys, pole_list, k_list): poles = np.sort(poles) np.testing.assert_array_almost_equal(poles, poles_expected) - def testRootLocus(self): + def testRootLocus(self, sys): """Basic root locus plot""" klist = [-1, 0, 1] - for sys in self.systems: - roots, k_out = root_locus(sys, klist, plot=False) - np.testing.assert_equal(len(roots), len(klist)) - np.testing.assert_array_equal(klist, k_out) - self.check_cl_poles(sys, roots, klist) - def test_without_gains(self): - for sys in self.systems: - roots, kvect = root_locus(sys, plot=False) - self.check_cl_poles(sys, roots, kvect) + roots, k_out = root_locus(sys, klist, plot=False) + np.testing.assert_equal(len(roots), len(klist)) + np.testing.assert_array_equal(klist, k_out) + self.check_cl_poles(sys, roots, klist) + + def test_without_gains(self, sys): + roots, kvect = root_locus(sys, plot=False) + self.check_cl_poles(sys, roots, kvect) def test_root_locus_zoom(self): """Check the zooming functionality of the Root locus plot""" @@ -54,7 +57,9 @@ def test_root_locus_zoom(self): fig = plt.gcf() ax_rlocus = fig.axes[0] - event = type('test', (object,), {'xdata': 14.7607954359, 'ydata': -35.6171379864, 'inaxes': ax_rlocus.axes})() + event = type('test', (object,), {'xdata': 14.7607954359, + 'ydata': -35.6171379864, + 'inaxes': ax_rlocus.axes})() ax_rlocus.set_xlim((-10.813628105112421, 14.760795435937652)) ax_rlocus.set_ylim((-35.61713798641108, 33.879716621220311)) plt.get_current_fig_manager().toolbar.mode = 'zoom rect' @@ -64,12 +69,31 @@ def test_root_locus_zoom(self): zoom_y = ax_rlocus.lines[-2].get_data()[1][0:5] zoom_y = [abs(y) for y in zoom_y] - zoom_x_valid = [-5. ,- 4.61281263, - 4.16689986, - 4.04122642, - 3.90736502] - zoom_y_valid = [0. ,0., 0., 0., 0.] - - assert_array_almost_equal(zoom_x,zoom_x_valid) - assert_array_almost_equal(zoom_y,zoom_y_valid) - + zoom_x_valid = [ + -5., - 4.61281263, - 4.16689986, - 4.04122642, - 3.90736502] + zoom_y_valid = [0., 0., 0., 0., 0.] + + assert_array_almost_equal(zoom_x, zoom_x_valid) + assert_array_almost_equal(zoom_y, zoom_y_valid) + + @pytest.mark.timeout(2) + def test_rlocus_default_wn(self): + """Check that default wn calculation works properly""" + # + # System that triggers use of y-axis as basis for wn (for coverage) + # + # This system generates a root locus plot that used to cause the + # creation (and subsequent deletion) of a large number of natural + # frequency contours within the `_default_wn` function in `rlocus.py`. + # This unit test makes sure that is fixed by generating a test case + # that will take a long time to do the calculation (minutes). + # + import scipy as sp + import signal + + # Define a system that exhibits this behavior + sys = ct.tf(*sp.signal.zpk2tf( + [-1e-2, 1-1e7j, 1+1e7j], [0, -1e7j, 1e7j], 1)) + + ct.root_locus(sys) -if __name__ == "__main__": - unittest.main() diff --git a/control/tests/robust_array_test.py b/control/tests/robust_array_test.py deleted file mode 100644 index beb44d2de..000000000 --- a/control/tests/robust_array_test.py +++ /dev/null @@ -1,393 +0,0 @@ -import unittest -import numpy as np -import control -import control.robust -from control.exception import slycot_check - -class TestHinf(unittest.TestCase): - def setUp(self): - # Use array instead of matrix (and save old value to restore at end) - control.use_numpy_matrix(False) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testHinfsyn(self): - """Test hinfsyn""" - p = control.ss(-1, [[1, 1]], [[1], [1]], [[0, 1], [1, 0]]) - k, cl, gam, rcond = control.robust.hinfsyn(p, 1, 1) - # from Octave, which also uses SB10AD: - # a= -1; b1= 1; b2= 1; c1= 1; c2= 1; d11= 0; d12= 1; d21= 1; d22= 0; - # g = ss(a,[b1,b2],[c1;c2],[d11,d12;d21,d22]); - # [k,cl] = hinfsyn(g,1,1); - np.testing.assert_array_almost_equal(k.A, [[-3]]) - np.testing.assert_array_almost_equal(k.B, [[1]]) - np.testing.assert_array_almost_equal(k.C, [[-1]]) - np.testing.assert_array_almost_equal(k.D, [[0]]) - np.testing.assert_array_almost_equal(cl.A, [[-1, -1], [1, -3]]) - np.testing.assert_array_almost_equal(cl.B, [[1], [1]]) - np.testing.assert_array_almost_equal(cl.C, [[1, -1]]) - np.testing.assert_array_almost_equal(cl.D, [[0]]) - - # TODO: add more interesting examples - - def tearDown(self): - control.config.reset_defaults() - - -class TestH2(unittest.TestCase): - def setUp(self): - # Use array instead of matrix (and save old value to restore at end) - control.use_numpy_matrix(False) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testH2syn(self): - """Test h2syn""" - p = control.ss(-1, [[1, 1]], [[1], [1]], [[0, 1], [1, 0]]) - k = control.robust.h2syn(p, 1, 1) - # from Octave, which also uses SB10HD for H-2 synthesis: - # a= -1; b1= 1; b2= 1; c1= 1; c2= 1; d11= 0; d12= 1; d21= 1; d22= 0; - # g = ss(a,[b1,b2],[c1;c2],[d11,d12;d21,d22]); - # k = h2syn(g,1,1); - # the solution is the same as for the hinfsyn test - np.testing.assert_array_almost_equal(k.A, [[-3]]) - np.testing.assert_array_almost_equal(k.B, [[1]]) - np.testing.assert_array_almost_equal(k.C, [[-1]]) - np.testing.assert_array_almost_equal(k.D, [[0]]) - - def tearDown(self): - control.config.reset_defaults() - - -class TestAugw(unittest.TestCase): - """Test control.robust.augw""" - def setUp(self): - # Use array instead of matrix (and save old value to restore at end) - control.use_numpy_matrix(False) - - # tolerance for system equality - TOL = 1e-8 - - def siso_almost_equal(self, g, h): - """siso_almost_equal(g,h) -> None - Raises AssertionError if g and h, two SISO LTI objects, are not almost equal""" - from control import tf, minreal - gmh = tf(minreal(g - h, verbose=False)) - if not (gmh.num[0][0] < self.TOL).all(): - maxnum = max(abs(gmh.num[0][0])) - raise AssertionError( - 'systems not approx equal; max num. coeff is {}\nsys 1:\n{}\nsys 2:\n{}'.format( - maxnum, g, h)) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testSisoW1(self): - """SISO plant with S weighting""" - from control import augw, ss - g = ss([-1.], [1.], [1.], [1.]) - w1 = ss([-2], [2.], [1.], [2.]) - p = augw(g, w1) - self.assertEqual(2, p.outputs) - self.assertEqual(2, p.inputs) - # w->z1 should be w1 - self.siso_almost_equal(w1, p[0, 0]) - # w->v should be 1 - self.siso_almost_equal(ss([], [], [], [1]), p[1, 0]) - # u->z1 should be -w1*g - self.siso_almost_equal(-w1 * g, p[0, 1]) - # u->v should be -g - self.siso_almost_equal(-g, p[1, 1]) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testSisoW2(self): - """SISO plant with KS weighting""" - from control import augw, ss - g = ss([-1.], [1.], [1.], [1.]) - w2 = ss([-2], [1.], [1.], [2.]) - p = augw(g, w2=w2) - self.assertEqual(2, p.outputs) - self.assertEqual(2, p.inputs) - # w->z2 should be 0 - self.siso_almost_equal(ss([], [], [], 0), p[0, 0]) - # w->v should be 1 - self.siso_almost_equal(ss([], [], [], [1]), p[1, 0]) - # u->z2 should be w2 - self.siso_almost_equal(w2, p[0, 1]) - # u->v should be -g - self.siso_almost_equal(-g, p[1, 1]) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testSisoW3(self): - """SISO plant with T weighting""" - from control import augw, ss - g = ss([-1.], [1.], [1.], [1.]) - w3 = ss([-2], [1.], [1.], [2.]) - p = augw(g, w3=w3) - self.assertEqual(2, p.outputs) - self.assertEqual(2, p.inputs) - # w->z3 should be 0 - self.siso_almost_equal(ss([], [], [], 0), p[0, 0]) - # w->v should be 1 - self.siso_almost_equal(ss([], [], [], [1]), p[1, 0]) - # u->z3 should be w3*g - self.siso_almost_equal(w3 * g, p[0, 1]) - # u->v should be -g - self.siso_almost_equal(-g, p[1, 1]) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testSisoW123(self): - """SISO plant with all weights""" - from control import augw, ss - g = ss([-1.], [1.], [1.], [1.]) - w1 = ss([-2.], [2.], [1.], [2.]) - w2 = ss([-3.], [3.], [1.], [3.]) - w3 = ss([-4.], [4.], [1.], [4.]) - p = augw(g, w1, w2, w3) - self.assertEqual(4, p.outputs) - self.assertEqual(2, p.inputs) - # w->z1 should be w1 - self.siso_almost_equal(w1, p[0, 0]) - # w->z2 should be 0 - self.siso_almost_equal(0, p[1, 0]) - # w->z3 should be 0 - self.siso_almost_equal(0, p[2, 0]) - # w->v should be 1 - self.siso_almost_equal(ss([], [], [], [1]), p[3, 0]) - # u->z1 should be -w1*g - self.siso_almost_equal(-w1 * g, p[0, 1]) - # u->z2 should be w2 - self.siso_almost_equal(w2, p[1, 1]) - # u->z3 should be w3*g - self.siso_almost_equal(w3 * g, p[2, 1]) - # u->v should be -g - self.siso_almost_equal(-g, p[3, 1]) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testMimoW1(self): - """MIMO plant with S weighting""" - from control import augw, ss - g = ss([[-1., -2], [-3, -4]], - [[1., 0.], [0., 1.]], - [[1., 0.], [0., 1.]], - [[1., 0.], [0., 1.]]) - w1 = ss([-2], [2.], [1.], [2.]) - p = augw(g, w1) - self.assertEqual(4, p.outputs) - self.assertEqual(4, p.inputs) - # w->z1 should be diag(w1,w1) - self.siso_almost_equal(w1, p[0, 0]) - self.siso_almost_equal(0, p[0, 1]) - self.siso_almost_equal(0, p[1, 0]) - self.siso_almost_equal(w1, p[1, 1]) - # w->v should be I - self.siso_almost_equal(1, p[2, 0]) - self.siso_almost_equal(0, p[2, 1]) - self.siso_almost_equal(0, p[3, 0]) - self.siso_almost_equal(1, p[3, 1]) - # u->z1 should be -w1*g - self.siso_almost_equal(-w1 * g[0, 0], p[0, 2]) - self.siso_almost_equal(-w1 * g[0, 1], p[0, 3]) - self.siso_almost_equal(-w1 * g[1, 0], p[1, 2]) - self.siso_almost_equal(-w1 * g[1, 1], p[1, 3]) - # # u->v should be -g - self.siso_almost_equal(-g[0, 0], p[2, 2]) - self.siso_almost_equal(-g[0, 1], p[2, 3]) - self.siso_almost_equal(-g[1, 0], p[3, 2]) - self.siso_almost_equal(-g[1, 1], p[3, 3]) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testMimoW2(self): - """MIMO plant with KS weighting""" - from control import augw, ss - g = ss([[-1., -2], [-3, -4]], - [[1., 0.], [0., 1.]], - [[1., 0.], [0., 1.]], - [[1., 0.], [0., 1.]]) - w2 = ss([-2], [2.], [1.], [2.]) - p = augw(g, w2=w2) - self.assertEqual(4, p.outputs) - self.assertEqual(4, p.inputs) - # w->z2 should be 0 - self.siso_almost_equal(0, p[0, 0]) - self.siso_almost_equal(0, p[0, 1]) - self.siso_almost_equal(0, p[1, 0]) - self.siso_almost_equal(0, p[1, 1]) - # w->v should be I - self.siso_almost_equal(1, p[2, 0]) - self.siso_almost_equal(0, p[2, 1]) - self.siso_almost_equal(0, p[3, 0]) - self.siso_almost_equal(1, p[3, 1]) - # u->z2 should be w2 - self.siso_almost_equal(w2, p[0, 2]) - self.siso_almost_equal(0, p[0, 3]) - self.siso_almost_equal(0, p[1, 2]) - self.siso_almost_equal(w2, p[1, 3]) - # # u->v should be -g - self.siso_almost_equal(-g[0, 0], p[2, 2]) - self.siso_almost_equal(-g[0, 1], p[2, 3]) - self.siso_almost_equal(-g[1, 0], p[3, 2]) - self.siso_almost_equal(-g[1, 1], p[3, 3]) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testMimoW3(self): - """MIMO plant with T weighting""" - from control import augw, ss - g = ss([[-1., -2], [-3, -4]], - [[1., 0.], [0., 1.]], - [[1., 0.], [0., 1.]], - [[1., 0.], [0., 1.]]) - w3 = ss([-2], [2.], [1.], [2.]) - p = augw(g, w3=w3) - self.assertEqual(4, p.outputs) - self.assertEqual(4, p.inputs) - # w->z3 should be 0 - self.siso_almost_equal(0, p[0, 0]) - self.siso_almost_equal(0, p[0, 1]) - self.siso_almost_equal(0, p[1, 0]) - self.siso_almost_equal(0, p[1, 1]) - # w->v should be I - self.siso_almost_equal(1, p[2, 0]) - self.siso_almost_equal(0, p[2, 1]) - self.siso_almost_equal(0, p[3, 0]) - self.siso_almost_equal(1, p[3, 1]) - # u->z3 should be w3*g - self.siso_almost_equal(w3 * g[0, 0], p[0, 2]) - self.siso_almost_equal(w3 * g[0, 1], p[0, 3]) - self.siso_almost_equal(w3 * g[1, 0], p[1, 2]) - self.siso_almost_equal(w3 * g[1, 1], p[1, 3]) - # # u->v should be -g - self.siso_almost_equal(-g[0, 0], p[2, 2]) - self.siso_almost_equal(-g[0, 1], p[2, 3]) - self.siso_almost_equal(-g[1, 0], p[3, 2]) - self.siso_almost_equal(-g[1, 1], p[3, 3]) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testMimoW123(self): - """MIMO plant with all weights""" - from control import augw, ss, append, minreal - g = ss([[-1., -2], [-3, -4]], - [[1., 0.], [0., 1.]], - [[1., 0.], [0., 1.]], - [[1., 0.], [0., 1.]]) - # this should be expaned to w1*I - w1 = ss([-2.], [2.], [1.], [2.]) - # diagonal weighting - w2 = append(ss([-3.], [3.], [1.], [3.]), ss([-4.], [4.], [1.], [4.])) - # full weighting - w3 = ss([[-4., -5], [-6, -7]], - [[2., 3.], [5., 7.]], - [[11., 13.], [17., 19.]], - [[23., 29.], [31., 37.]]) - p = augw(g, w1, w2, w3) - self.assertEqual(8, p.outputs) - self.assertEqual(4, p.inputs) - # w->z1 should be w1 - self.siso_almost_equal(w1, p[0, 0]) - self.siso_almost_equal(0, p[0, 1]) - self.siso_almost_equal(0, p[1, 0]) - self.siso_almost_equal(w1, p[1, 1]) - # w->z2 should be 0 - self.siso_almost_equal(0, p[2, 0]) - self.siso_almost_equal(0, p[2, 1]) - self.siso_almost_equal(0, p[3, 0]) - self.siso_almost_equal(0, p[3, 1]) - # w->z3 should be 0 - self.siso_almost_equal(0, p[4, 0]) - self.siso_almost_equal(0, p[4, 1]) - self.siso_almost_equal(0, p[5, 0]) - self.siso_almost_equal(0, p[5, 1]) - # w->v should be I - self.siso_almost_equal(1, p[6, 0]) - self.siso_almost_equal(0, p[6, 1]) - self.siso_almost_equal(0, p[7, 0]) - self.siso_almost_equal(1, p[7, 1]) - - # u->z1 should be -w1*g - self.siso_almost_equal(-w1 * g[0, 0], p[0, 2]) - self.siso_almost_equal(-w1 * g[0, 1], p[0, 3]) - self.siso_almost_equal(-w1 * g[1, 0], p[1, 2]) - self.siso_almost_equal(-w1 * g[1, 1], p[1, 3]) - # u->z2 should be w2 - self.siso_almost_equal(w2[0, 0], p[2, 2]) - self.siso_almost_equal(w2[0, 1], p[2, 3]) - self.siso_almost_equal(w2[1, 0], p[3, 2]) - self.siso_almost_equal(w2[1, 1], p[3, 3]) - # u->z3 should be w3*g - w3g = w3 * g; - self.siso_almost_equal(w3g[0, 0], minreal(p[4, 2])) - self.siso_almost_equal(w3g[0, 1], minreal(p[4, 3])) - self.siso_almost_equal(w3g[1, 0], minreal(p[5, 2])) - self.siso_almost_equal(w3g[1, 1], minreal(p[5, 3])) - # u->v should be -g - self.siso_almost_equal(-g[0, 0], p[6, 2]) - self.siso_almost_equal(-g[0, 1], p[6, 3]) - self.siso_almost_equal(-g[1, 0], p[7, 2]) - self.siso_almost_equal(-g[1, 1], p[7, 3]) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testErrors(self): - """Error cases handled""" - from control import augw, ss - # no weights - g1by1 = ss(-1, 1, 1, 0) - g2by2 = ss(-np.eye(2), np.eye(2), np.eye(2), np.zeros((2, 2))) - self.assertRaises(ValueError, augw, g1by1) - # mismatched size of weight and plant - self.assertRaises(ValueError, augw, g1by1, w1=g2by2) - self.assertRaises(ValueError, augw, g1by1, w2=g2by2) - self.assertRaises(ValueError, augw, g1by1, w3=g2by2) - - def tearDown(self): - control.config.reset_defaults() - - -class TestMixsyn(unittest.TestCase): - """Test control.robust.mixsyn""" - def setUp(self): - # Use array instead of matrix (and save old value to restore at end) - control.use_numpy_matrix(False) - - # it's a relatively simple wrapper; compare results with augw, hinfsyn - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testSiso(self): - """mixsyn with SISO system""" - from control import tf, augw, hinfsyn, mixsyn - from control import ss - # Skogestad+Postlethwaite, Multivariable Feedback Control, 1st Ed., Example 2.11 - s = tf([1, 0], 1) - # plant - g = 200 / (10 * s + 1) / (0.05 * s + 1) ** 2 - # sensitivity weighting - M = 1.5 - wb = 10 - A = 1e-4 - w1 = (s / M + wb) / (s + wb * A) - # KS weighting - w2 = tf(1, 1) - - p = augw(g, w1, w2) - kref, clref, gam, rcond = hinfsyn(p, 1, 1) - ktest, cltest, info = mixsyn(g, w1, w2) - # check similar to S+P's example - np.testing.assert_allclose(gam, 1.37, atol=1e-2) - - # mixsyn is a convenience wrapper around augw and hinfsyn, so - # results will be exactly the same. Given than, use the lazy - # but fragile testing option. - np.testing.assert_allclose(ktest.A, kref.A) - np.testing.assert_allclose(ktest.B, kref.B) - np.testing.assert_allclose(ktest.C, kref.C) - np.testing.assert_allclose(ktest.D, kref.D) - - np.testing.assert_allclose(cltest.A, clref.A) - np.testing.assert_allclose(cltest.B, clref.B) - np.testing.assert_allclose(cltest.C, clref.C) - np.testing.assert_allclose(cltest.D, clref.D) - - np.testing.assert_allclose(gam, info[0]) - - np.testing.assert_allclose(rcond, info[1]) - - def tearDown(self): - control.config.reset_defaults() - - -if __name__ == "__main__": - unittest.main() diff --git a/control/tests/robust_test.py b/control/tests/robust_test.py index b23f06c52..146ae9e41 100644 --- a/control/tests/robust_test.py +++ b/control/tests/robust_test.py @@ -1,16 +1,20 @@ -import unittest +"""robust_array_test.py""" + import numpy as np -import control -import control.robust -from control.exception import slycot_check +import pytest + +from control import append, minreal, ss, tf +from control.robust import augw, h2syn, hinfsyn, mixsyn +from control.tests.conftest import slycotonly -class TestHinf(unittest.TestCase): - @unittest.skipIf(not slycot_check(), "slycot not installed") +class TestHinf: + + @slycotonly def testHinfsyn(self): """Test hinfsyn""" - p = control.ss(-1, [1, 1], [[1], [1]], [[0, 1], [1, 0]]) - k, cl, gam, rcond = control.robust.hinfsyn(p, 1, 1) + p = ss(-1, [[1, 1]], [[1], [1]], [[0, 1], [1, 0]]) + k, cl, gam, rcond = hinfsyn(p, 1, 1) # from Octave, which also uses SB10AD: # a= -1; b1= 1; b2= 1; c1= 1; c2= 1; d11= 0; d12= 1; d21= 1; d22= 0; # g = ss(a,[b1,b2],[c1;c2],[d11,d12;d21,d22]); @@ -26,13 +30,13 @@ def testHinfsyn(self): # TODO: add more interesting examples +class TestH2: -class TestH2(unittest.TestCase): - @unittest.skipIf(not slycot_check(), "slycot not installed") + @slycotonly def testH2syn(self): """Test h2syn""" - p = control.ss(-1, [1, 1], [[1], [1]], [[0, 1], [1, 0]]) - k = control.robust.h2syn(p, 1, 1) + p = ss(-1, [[1, 1]], [[1], [1]], [[0, 1], [1, 0]]) + k = h2syn(p, 1, 1) # from Octave, which also uses SB10HD for H-2 synthesis: # a= -1; b1= 1; b2= 1; c1= 1; c2= 1; d11= 0; d12= 1; d21= 1; d22= 0; # g = ss(a,[b1,b2],[c1;c2],[d11,d12;d21,d22]); @@ -44,32 +48,36 @@ def testH2syn(self): np.testing.assert_array_almost_equal(k.D, [[0]]) -class TestAugw(unittest.TestCase): - """Test control.robust.augw""" +class TestAugw: # tolerance for system equality TOL = 1e-8 def siso_almost_equal(self, g, h): """siso_almost_equal(g,h) -> None - Raises AssertionError if g and h, two SISO LTI objects, are not almost equal""" - from control import tf, minreal + + Raises AssertionError if g and h, two SISO LTI objects, are not almost + equal + """ + # TODO: use pytest's assertion rewriting feature gmh = tf(minreal(g - h, verbose=False)) if not (gmh.num[0][0] < self.TOL).all(): maxnum = max(abs(gmh.num[0][0])) - raise AssertionError( - 'systems not approx equal; max num. coeff is {}\nsys 1:\n{}\nsys 2:\n{}'.format( - maxnum, g, h)) + raise AssertionError("systems not approx equal; " + "max num. coeff is {}\n" + "sys 1:\n" + "{}\n" + "sys 2:\n" + "{}".format(maxnum, g, h)) - @unittest.skipIf(not slycot_check(), "slycot not installed") + @slycotonly def testSisoW1(self): """SISO plant with S weighting""" - from control import augw, ss g = ss([-1.], [1.], [1.], [1.]) w1 = ss([-2], [2.], [1.], [2.]) p = augw(g, w1) - self.assertEqual(2, p.outputs) - self.assertEqual(2, p.inputs) + assert p.noutputs == 2 + assert p.ninputs == 2 # w->z1 should be w1 self.siso_almost_equal(w1, p[0, 0]) # w->v should be 1 @@ -79,15 +87,14 @@ def testSisoW1(self): # u->v should be -g self.siso_almost_equal(-g, p[1, 1]) - @unittest.skipIf(not slycot_check(), "slycot not installed") + @slycotonly def testSisoW2(self): """SISO plant with KS weighting""" - from control import augw, ss g = ss([-1.], [1.], [1.], [1.]) w2 = ss([-2], [1.], [1.], [2.]) p = augw(g, w2=w2) - self.assertEqual(2, p.outputs) - self.assertEqual(2, p.inputs) + assert p.noutputs == 2 + assert p.ninputs == 2 # w->z2 should be 0 self.siso_almost_equal(ss([], [], [], 0), p[0, 0]) # w->v should be 1 @@ -97,15 +104,14 @@ def testSisoW2(self): # u->v should be -g self.siso_almost_equal(-g, p[1, 1]) - @unittest.skipIf(not slycot_check(), "slycot not installed") + @slycotonly def testSisoW3(self): """SISO plant with T weighting""" - from control import augw, ss g = ss([-1.], [1.], [1.], [1.]) w3 = ss([-2], [1.], [1.], [2.]) p = augw(g, w3=w3) - self.assertEqual(2, p.outputs) - self.assertEqual(2, p.inputs) + assert p.noutputs == 2 + assert p.ninputs == 2 # w->z3 should be 0 self.siso_almost_equal(ss([], [], [], 0), p[0, 0]) # w->v should be 1 @@ -115,17 +121,16 @@ def testSisoW3(self): # u->v should be -g self.siso_almost_equal(-g, p[1, 1]) - @unittest.skipIf(not slycot_check(), "slycot not installed") + @slycotonly def testSisoW123(self): """SISO plant with all weights""" - from control import augw, ss g = ss([-1.], [1.], [1.], [1.]) w1 = ss([-2.], [2.], [1.], [2.]) w2 = ss([-3.], [3.], [1.], [3.]) w3 = ss([-4.], [4.], [1.], [4.]) p = augw(g, w1, w2, w3) - self.assertEqual(4, p.outputs) - self.assertEqual(2, p.inputs) + assert p.noutputs == 4 + assert p.ninputs == 2 # w->z1 should be w1 self.siso_almost_equal(w1, p[0, 0]) # w->z2 should be 0 @@ -143,18 +148,17 @@ def testSisoW123(self): # u->v should be -g self.siso_almost_equal(-g, p[3, 1]) - @unittest.skipIf(not slycot_check(), "slycot not installed") + @slycotonly def testMimoW1(self): """MIMO plant with S weighting""" - from control import augw, ss g = ss([[-1., -2], [-3, -4]], [[1., 0.], [0., 1.]], [[1., 0.], [0., 1.]], [[1., 0.], [0., 1.]]) w1 = ss([-2], [2.], [1.], [2.]) p = augw(g, w1) - self.assertEqual(4, p.outputs) - self.assertEqual(4, p.inputs) + assert p.noutputs == 4 + assert p.ninputs == 4 # w->z1 should be diag(w1,w1) self.siso_almost_equal(w1, p[0, 0]) self.siso_almost_equal(0, p[0, 1]) @@ -176,18 +180,17 @@ def testMimoW1(self): self.siso_almost_equal(-g[1, 0], p[3, 2]) self.siso_almost_equal(-g[1, 1], p[3, 3]) - @unittest.skipIf(not slycot_check(), "slycot not installed") + @slycotonly def testMimoW2(self): """MIMO plant with KS weighting""" - from control import augw, ss g = ss([[-1., -2], [-3, -4]], [[1., 0.], [0., 1.]], [[1., 0.], [0., 1.]], [[1., 0.], [0., 1.]]) w2 = ss([-2], [2.], [1.], [2.]) p = augw(g, w2=w2) - self.assertEqual(4, p.outputs) - self.assertEqual(4, p.inputs) + assert p.noutputs == 4 + assert p.ninputs == 4 # w->z2 should be 0 self.siso_almost_equal(0, p[0, 0]) self.siso_almost_equal(0, p[0, 1]) @@ -209,18 +212,17 @@ def testMimoW2(self): self.siso_almost_equal(-g[1, 0], p[3, 2]) self.siso_almost_equal(-g[1, 1], p[3, 3]) - @unittest.skipIf(not slycot_check(), "slycot not installed") + @slycotonly def testMimoW3(self): """MIMO plant with T weighting""" - from control import augw, ss g = ss([[-1., -2], [-3, -4]], [[1., 0.], [0., 1.]], [[1., 0.], [0., 1.]], [[1., 0.], [0., 1.]]) w3 = ss([-2], [2.], [1.], [2.]) p = augw(g, w3=w3) - self.assertEqual(4, p.outputs) - self.assertEqual(4, p.inputs) + assert p.noutputs == 4 + assert p.ninputs == 4 # w->z3 should be 0 self.siso_almost_equal(0, p[0, 0]) self.siso_almost_equal(0, p[0, 1]) @@ -242,10 +244,9 @@ def testMimoW3(self): self.siso_almost_equal(-g[1, 0], p[3, 2]) self.siso_almost_equal(-g[1, 1], p[3, 3]) - @unittest.skipIf(not slycot_check(), "slycot not installed") + @slycotonly def testMimoW123(self): """MIMO plant with all weights""" - from control import augw, ss, append, minreal g = ss([[-1., -2], [-3, -4]], [[1., 0.], [0., 1.]], [[1., 0.], [0., 1.]], @@ -260,8 +261,8 @@ def testMimoW123(self): [[11., 13.], [17., 19.]], [[23., 29.], [31., 37.]]) p = augw(g, w1, w2, w3) - self.assertEqual(8, p.outputs) - self.assertEqual(4, p.inputs) + assert p.noutputs == 8 + assert p.ninputs == 4 # w->z1 should be w1 self.siso_almost_equal(w1, p[0, 0]) self.siso_almost_equal(0, p[0, 1]) @@ -294,7 +295,7 @@ def testMimoW123(self): self.siso_almost_equal(w2[1, 0], p[3, 2]) self.siso_almost_equal(w2[1, 1], p[3, 3]) # u->z3 should be w3*g - w3g = w3 * g; + w3g = w3 * g self.siso_almost_equal(w3g[0, 0], minreal(p[4, 2])) self.siso_almost_equal(w3g[0, 1], minreal(p[4, 3])) self.siso_almost_equal(w3g[1, 0], minreal(p[5, 2])) @@ -305,29 +306,31 @@ def testMimoW123(self): self.siso_almost_equal(-g[1, 0], p[7, 2]) self.siso_almost_equal(-g[1, 1], p[7, 3]) - @unittest.skipIf(not slycot_check(), "slycot not installed") + @slycotonly def testErrors(self): """Error cases handled""" from control import augw, ss # no weights g1by1 = ss(-1, 1, 1, 0) g2by2 = ss(-np.eye(2), np.eye(2), np.eye(2), np.zeros((2, 2))) - self.assertRaises(ValueError, augw, g1by1) + with pytest.raises(ValueError): + augw(g1by1) # mismatched size of weight and plant - self.assertRaises(ValueError, augw, g1by1, w1=g2by2) - self.assertRaises(ValueError, augw, g1by1, w2=g2by2) - self.assertRaises(ValueError, augw, g1by1, w3=g2by2) + with pytest.raises(ValueError): + augw(g1by1, w1=g2by2) + with pytest.raises(ValueError): + augw(g1by1, w2=g2by2) + with pytest.raises(ValueError): + augw(g1by1, w3=g2by2) -class TestMixsyn(unittest.TestCase): +class TestMixsyn: """Test control.robust.mixsyn""" # it's a relatively simple wrapper; compare results with augw, hinfsyn - @unittest.skipIf(not slycot_check(), "slycot not installed") + @slycotonly def testSiso(self): """mixsyn with SISO system""" - from control import tf, augw, hinfsyn, mixsyn - from control import ss # Skogestad+Postlethwaite, Multivariable Feedback Control, 1st Ed., Example 2.11 s = tf([1, 0], 1) # plant @@ -362,7 +365,3 @@ def testSiso(self): np.testing.assert_allclose(gam, info[0]) np.testing.assert_allclose(rcond, info[1]) - - -if __name__ == "__main__": - unittest.main() diff --git a/control/tests/sisotool_test.py b/control/tests/sisotool_test.py index 5b627c22d..14e9692c1 100644 --- a/control/tests/sisotool_test.py +++ b/control/tests/sisotool_test.py @@ -1,29 +1,64 @@ -import unittest +"""sisotool_test.py""" + +from control.exception import ControlMIMONotImplemented import matplotlib.pyplot as plt import numpy as np from numpy.testing import assert_array_almost_equal +import pytest from control.sisotool import sisotool from control.rlocus import _RLClickDispatcher from control.xferfcn import TransferFunction +from control.statesp import StateSpace -class TestSisotool(unittest.TestCase): +@pytest.mark.usefixtures("mplcleanup") +class TestSisotool: """These are tests for the sisotool in sisotool.py.""" - def setUp(self): - # One random SISO system. - self.system = TransferFunction([1000], [1, 25, 100, 0]) + @pytest.fixture + def sys(self): + """Return a generic SISO transfer function""" + return TransferFunction([1000], [1, 25, 100, 0]) + + @pytest.fixture + def sysdt(self): + """Return a generic SISO transfer function""" + return TransferFunction([1000], [1, 25, 100, 0], True) + + @pytest.fixture + def sys222(self): + """2-states square system (2 inputs x 2 outputs)""" + A222 = [[4., 1.], + [2., -3]] + B222 = [[5., 2.], + [-3., -3.]] + C222 = [[2., -4], + [0., 1.]] + D222 = [[3., 2.], + [1., -1.]] + return StateSpace(A222, B222, C222, D222) - def test_sisotool(self): - sisotool(self.system, Hz=False) + @pytest.fixture + def sys221(self): + """2-states, 2 inputs x 1 output""" + A222 = [[4., 1.], + [2., -3]] + B222 = [[5., 2.], + [-3., -3.]] + C221 = [[0., 1.]] + D221 = [[1., -1.]] + return StateSpace(A222, B222, C221, D221) + + def test_sisotool(self, sys): + sisotool(sys, Hz=False) fig = plt.gcf() ax_mag, ax_rlocus, ax_phase, ax_step = fig.axes[:4] # Check the initial root locus plot points initial_point_0 = (np.array([-22.53155977]), np.array([0.])) initial_point_1 = (np.array([-1.23422011]), np.array([-6.54667031])) - initial_point_2 = (np.array([-1.23422011]), np.array([06.54667031])) + initial_point_2 = (np.array([-1.23422011]), np.array([6.54667031])) assert_array_almost_equal(ax_rlocus.lines[0].get_data(), initial_point_0, 4) assert_array_almost_equal(ax_rlocus.lines[1].get_data(), @@ -32,12 +67,9 @@ def test_sisotool(self): initial_point_2, 4) # Check the step response before moving the point - # new array needed because change in compute step response default time step_response_original = np.array( - [0. , 0.0069, 0.0448, 0.124 , 0.2427, 0.3933, 0.5653, 0.7473, - 0.928 , 1.0969]) - #old: np.array([0., 0.0217, 0.1281, 0.3237, 0.5797, 0.8566, 1.116, - # 1.3261, 1.4659, 1.526]) + [0. , 0.0216, 0.1271, 0.3215, 0.5762, 0.8522, 1.1114, 1.3221, + 1.4633, 1.5254]) assert_array_almost_equal( ax_step.lines[0].get_data()[1][:10], step_response_original, 4) @@ -57,7 +89,7 @@ def test_sisotool(self): event = type('test', (object,), {'xdata': 2.31206868287, 'ydata': 15.5983051046, 'inaxes': ax_rlocus.axes})() - _RLClickDispatcher(event=event, sys=self.system, fig=fig, + _RLClickDispatcher(event=event, sys=sys, fig=fig, ax_rlocus=ax_rlocus, sisotool=True, plotstr='-', bode_plot_params=bode_plot_params, tvect=None) @@ -74,21 +106,54 @@ def test_sisotool(self): # Check if the bode_mag line has moved bode_mag_moved = np.array( - [111.83321224, 92.29238035, 76.02822315, 62.46884113, 51.14108703, - 41.6554004, 33.69409534, 27.00237344, 21.38086717, 16.67791585]) + [674.0242, 667.8354, 661.7033, 655.6275, 649.6074, 643.6426, + 637.7324, 631.8765, 626.0742, 620.3252]) assert_array_almost_equal(ax_mag.lines[0].get_data()[1][10:20], bode_mag_moved, 4) # Check if the step response has changed - # new array needed because change in compute step response default time step_response_moved = np.array( - [0. , 0.0072, 0.0516, 0.1554, 0.3281, 0.5681, 0.8646, 1.1987, - 1.5452, 1.875 ]) - #old: array([0., 0.0239, 0.161 , 0.4547, 0.8903, 1.407, - # 1.9121, 2.2989, 2.4686, 2.353]) + [0. , 0.0237, 0.1596, 0.4511, 0.884 , 1.3985, 1.9031, 2.2922, + 2.4676, 2.3606]) assert_array_almost_equal( ax_step.lines[0].get_data()[1][:10], step_response_moved, 4) + def test_sisotool_tvect(self, sys): + # test supply tvect + tvect = np.linspace(0, 1, 10) + sisotool(sys, tvect=tvect) + fig = plt.gcf() + ax_rlocus, ax_step = fig.axes[1], fig.axes[3] + + # Move the rootlocus to another point and confirm same tvect + event = type('test', (object,), {'xdata': 2.31206868287, + 'ydata': 15.5983051046, + 'inaxes': ax_rlocus.axes})() + _RLClickDispatcher(event=event, sys=sys, fig=fig, + ax_rlocus=ax_rlocus, sisotool=True, plotstr='-', + bode_plot_params=dict(), tvect=tvect) + assert_array_almost_equal(tvect, ax_step.lines[0].get_data()[0]) + + def test_sisotool_tvect_dt(self, sysdt): + # test supply tvect + tvect = np.linspace(0, 1, 10) + sisotool(sysdt, tvect=tvect) + fig = plt.gcf() + ax_rlocus, ax_step = fig.axes[1], fig.axes[3] + + # Move the rootlocus to another point and confirm same tvect + event = type('test', (object,), {'xdata': 2.31206868287, + 'ydata': 15.5983051046, + 'inaxes': ax_rlocus.axes})() + _RLClickDispatcher(event=event, sys=sysdt, fig=fig, + ax_rlocus=ax_rlocus, sisotool=True, plotstr='-', + bode_plot_params=dict(), tvect=tvect) + assert_array_almost_equal(tvect, ax_step.lines[0].get_data()[0]) + + def test_sisotool_mimo(self, sys222, sys221): + # a 2x2 should not raise an error: + sisotool(sys222) -if __name__ == "__main__": - unittest.main() + # but 2 input, 1 output should + with pytest.raises(ControlMIMONotImplemented): + sisotool(sys221) diff --git a/control/tests/slycot_convert_test.py b/control/tests/slycot_convert_test.py index e13bcea8f..edd355b3b 100644 --- a/control/tests/slycot_convert_test.py +++ b/control/tests/slycot_convert_test.py @@ -1,197 +1,213 @@ -#!/usr/bin/env python -# -# slycot_convert_test.py - test SLICOT-based conversions -# RMM, 30 Mar 2011 (based on TestSlycot from v0.4a) +"""slycot_convert_test.py - test SLICOT-based conversions + +RMM, 30 Mar 2011 (based on TestSlycot from v0.4a) +""" -from __future__ import print_function -import unittest import numpy as np -from control import matlab -from control.exception import slycot_check - - -@unittest.skipIf(not slycot_check(), "slycot not installed") -class TestSlycot(unittest.TestCase): - """TestSlycot compares transfer function and state space conversions for - various numbers of inputs,outputs and states. - 1. Usually passes for SISO systems of any state dim, occasonally, - there will be a dimension mismatch if the original randomly - generated ss system is not minimal because td04ad returns a - minimal system. - - 2. For small systems with many inputs, n<5 and with 2 or more - outputs the conversion to statespace (td04ad) intermittently - results in an equivalent realization of higher order than the - original tf order. We think this has to do with minimu - realization tolerances in the Fortran. The algorithm doesn't - recognize that two denominators are identical and so it - creates a system with nearly duplicate eigenvalues and - double the state dimension. This should not be a problem in - the python-control usage because the common_den() method finds - repeated roots within a tolerance that we specify. - - Matlab: Matlab seems to force its statespace system output to - have order less than or equal to the order of denominators provided, - avoiding the problem of very large state dimension we describe in 3. - It does however, still have similar problems with pole/zero - cancellation such as we encounter in 2, where a statespace system - may have fewer states than the original order of transfer function. +import pytest + +from control import bode, rss, ss, tf +from control.tests.conftest import slycotonly + +numTests = 5 +maxStates = 10 +maxI = 1 +maxO = 1 + + +@pytest.fixture(scope="module") +def fixedseed(): + """Get consistent test results""" + np.random.seed(0) + + +@slycotonly +@pytest.mark.usefixtures("fixedseed") +class TestSlycot: + """Test Slycot system conversion + + TestSlycot compares transfer function and state space conversions for + various numbers of inputs,outputs and states. + 1. Usually passes for SISO systems of any state dim, occasonally, + there will be a dimension mismatch if the original randomly + generated ss system is not minimal because td04ad returns a + minimal system. + + 2. For small systems with many inputs, n<5 and with 2 or more + outputs the conversion to statespace (td04ad) intermittently + results in an equivalent realization of higher order than the + original tf order. We think this has to do with minimu + realization tolerances in the Fortran. The algorithm doesn't + recognize that two denominators are identical and so it + creates a system with nearly duplicate eigenvalues and + double the state dimension. This should not be a problem in + the python-control usage because the common_den() method finds + repeated roots within a tolerance that we specify. + + Matlab: Matlab seems to force its statespace system output to + have order less than or equal to the order of denominators provided, + avoiding the problem of very large state dimension we describe in 3. + It does however, still have similar problems with pole/zero + cancellation such as we encounter in 2, where a statespace system + may have fewer states than the original order of transfer function. """ - def setUp(self): - """Define some test parameters.""" - self.numTests = 5 - self.maxStates = 10 - self.maxI = 1 - self.maxO = 1 - - def testTF(self, verbose=False): - """ Directly tests the functions tb04ad and td04ad through direct - comparison of transfer function coefficients. - Similar to convert_test, but tests at a lower level. + + @pytest.fixture + def verbose(self): + """Set to True and switch off pytest stdout capture to print info""" + return False + + @pytest.mark.parametrize("testNum", np.arange(numTests) + 1) + @pytest.mark.parametrize("inputs", np.arange(maxI) + 1) + @pytest.mark.parametrize("outputs", np.arange(maxO) + 1) + @pytest.mark.parametrize("states", np.arange(maxStates) + 1) + def testTF(self, states, outputs, inputs, testNum, verbose): + """Test transfer function conversion. + + Directly tests the functions tb04ad and td04ad through direct + comparison of transfer function coefficients. + Similar to convert_test, but tests at a lower level. """ from slycot import tb04ad, td04ad - for states in range(1, self.maxStates): - for inputs in range(1, self.maxI+1): - for outputs in range(1, self.maxO+1): - for testNum in range(self.numTests): - ssOriginal = matlab.rss(states, outputs, inputs) - if (verbose): - print('====== Original SS ==========') - print(ssOriginal) - print('states=', states) - print('inputs=', inputs) - print('outputs=', outputs) - - tfOriginal_Actrb, tfOriginal_Bctrb, tfOriginal_Cctrb,\ - tfOrigingal_nctrb, tfOriginal_index,\ - tfOriginal_dcoeff, tfOriginal_ucoeff =\ - tb04ad(states, inputs, outputs, - ssOriginal.A, ssOriginal.B, - ssOriginal.C, ssOriginal.D, tol1=0.0) - - ssTransformed_nr, ssTransformed_A, ssTransformed_B,\ - ssTransformed_C, ssTransformed_D\ - = td04ad('R', inputs, outputs, tfOriginal_index, - tfOriginal_dcoeff, tfOriginal_ucoeff, - tol=0.0) - - tfTransformed_Actrb, tfTransformed_Bctrb,\ - tfTransformed_Cctrb, tfTransformed_nctrb,\ - tfTransformed_index, tfTransformed_dcoeff,\ - tfTransformed_ucoeff = tb04ad( - ssTransformed_nr, inputs, outputs, - ssTransformed_A, ssTransformed_B, - ssTransformed_C, ssTransformed_D, tol1=0.0) - # print('size(Trans_A)=',ssTransformed_A.shape) - if (verbose): - print('===== Transformed SS ==========') - print(matlab.ss(ssTransformed_A, ssTransformed_B, - ssTransformed_C, ssTransformed_D)) - # print('Trans_nr=',ssTransformed_nr - # print('tfOrig_index=',tfOriginal_index) - # print('tfOrig_ucoeff=',tfOriginal_ucoeff) - # print('tfOrig_dcoeff=',tfOriginal_dcoeff) - # print('tfTrans_index=',tfTransformed_index) - # print('tfTrans_ucoeff=',tfTransformed_ucoeff) - # print('tfTrans_dcoeff=',tfTransformed_dcoeff) - # Compare the TF directly, must match - # numerators - # TODO test failing! - # np.testing.assert_array_almost_equal( - # tfOriginal_ucoeff, tfTransformed_ucoeff, decimal=3) - # denominators - # np.testing.assert_array_almost_equal( - # tfOriginal_dcoeff, tfTransformed_dcoeff, decimal=3) - - def testFreqResp(self): - """Compare the bode reponses of the SS systems and TF systems to the original SS - They generally are different realizations but have same freq resp. - Currently this test may only be applied to SISO systems. + + ssOriginal = rss(states, outputs, inputs) + if (verbose): + print('====== Original SS ==========') + print(ssOriginal) + print('states=', states) + print('inputs=', inputs) + print('outputs=', outputs) + + tfOriginal_Actrb, tfOriginal_Bctrb, tfOriginal_Cctrb,\ + tfOrigingal_nctrb, tfOriginal_index,\ + tfOriginal_dcoeff, tfOriginal_ucoeff =\ + tb04ad(states, inputs, outputs, + ssOriginal.A, ssOriginal.B, + ssOriginal.C, ssOriginal.D, tol1=0.0) + + ssTransformed_nr, ssTransformed_A, ssTransformed_B,\ + ssTransformed_C, ssTransformed_D\ + = td04ad('R', inputs, outputs, tfOriginal_index, + tfOriginal_dcoeff, tfOriginal_ucoeff, + tol=0.0) + + tfTransformed_Actrb, tfTransformed_Bctrb,\ + tfTransformed_Cctrb, tfTransformed_nctrb,\ + tfTransformed_index, tfTransformed_dcoeff,\ + tfTransformed_ucoeff = tb04ad( + ssTransformed_nr, inputs, outputs, + ssTransformed_A, ssTransformed_B, + ssTransformed_C, ssTransformed_D, tol1=0.0) + # print('size(Trans_A)=',ssTransformed_A.shape) + if (verbose): + print('===== Transformed SS ==========') + print(ss(ssTransformed_A, ssTransformed_B, + ssTransformed_C, ssTransformed_D)) + # print('Trans_nr=',ssTransformed_nr + # print('tfOrig_index=',tfOriginal_index) + # print('tfOrig_ucoeff=',tfOriginal_ucoeff) + # print('tfOrig_dcoeff=',tfOriginal_dcoeff) + # print('tfTrans_index=',tfTransformed_index) + # print('tfTrans_ucoeff=',tfTransformed_ucoeff) + # print('tfTrans_dcoeff=',tfTransformed_dcoeff) + # Compare the TF directly, must match + # numerators + # TODO test failing! + # np.testing.assert_array_almost_equal( + # tfOriginal_ucoeff, tfTransformed_ucoeff, decimal=3) + # denominators + # np.testing.assert_array_almost_equal( + # tfOriginal_dcoeff, tfTransformed_dcoeff, decimal=3) + + @pytest.mark.parametrize("testNum", np.arange(numTests) + 1) + @pytest.mark.parametrize("inputs", np.arange(1) + 1) # SISO only + @pytest.mark.parametrize("outputs", np.arange(1) + 1) # SISO only + @pytest.mark.parametrize("states", np.arange(maxStates) + 1) + def testFreqResp(self, states, outputs, inputs, testNum, verbose): + """Compare bode responses. + + Compare the bode reponses of the SS systems and TF systems to the + original SS. They generally are different realizations but have same + freq resp. Currently this test may only be applied to SISO systems. """ from slycot import tb04ad, td04ad - for states in range(1, self.maxStates): - for testNum in range(self.numTests): - for inputs in range(1, 1): - for outputs in range(1, 1): - ssOriginal = matlab.rss(states, outputs, inputs) - - tfOriginal_Actrb, tfOriginal_Bctrb, tfOriginal_Cctrb,\ - tfOrigingal_nctrb, tfOriginal_index,\ - tfOriginal_dcoeff, tfOriginal_ucoeff = tb04ad( - states, inputs, outputs, ssOriginal.A, - ssOriginal.B, ssOriginal.C, ssOriginal.D, - tol1=0.0) - - ssTransformed_nr, ssTransformed_A, ssTransformed_B,\ - ssTransformed_C, ssTransformed_D\ - = td04ad('R', inputs, outputs, tfOriginal_index, - tfOriginal_dcoeff, tfOriginal_ucoeff, - tol=0.0) - - tfTransformed_Actrb, tfTransformed_Bctrb,\ - tfTransformed_Cctrb, tfTransformed_nctrb,\ - tfTransformed_index, tfTransformed_dcoeff,\ - tfTransformed_ucoeff = tb04ad( - ssTransformed_nr, inputs, outputs, - ssTransformed_A, ssTransformed_B, - ssTransformed_C, ssTransformed_D, - tol1=0.0) - - numTransformed = np.array(tfTransformed_ucoeff) - denTransformed = np.array(tfTransformed_dcoeff) - numOriginal = np.array(tfOriginal_ucoeff) - denOriginal = np.array(tfOriginal_dcoeff) - - ssTransformed = matlab.ss(ssTransformed_A, - ssTransformed_B, - ssTransformed_C, - ssTransformed_D) - for inputNum in range(inputs): - for outputNum in range(outputs): - [ssOriginalMag, ssOriginalPhase, freq] =\ - matlab.bode(ssOriginal, plot=False) - [tfOriginalMag, tfOriginalPhase, freq] =\ - matlab.bode(matlab.tf( - numOriginal[outputNum][inputNum], - denOriginal[outputNum]), plot=False) - [ssTransformedMag, ssTransformedPhase, freq] =\ - matlab.bode(ssTransformed, - freq, plot=False) - [tfTransformedMag, tfTransformedPhase, freq] =\ - matlab.bode(matlab.tf( - numTransformed[outputNum][inputNum], - denTransformed[outputNum]), - freq, plot=False) - # print('numOrig=', - # numOriginal[outputNum][inputNum]) - # print('denOrig=', - # denOriginal[outputNum]) - # print('numTrans=', - # numTransformed[outputNum][inputNum]) - # print('denTrans=', - # denTransformed[outputNum]) - np.testing.assert_array_almost_equal( - ssOriginalMag, tfOriginalMag, decimal=3) - np.testing.assert_array_almost_equal( - ssOriginalPhase, tfOriginalPhase, - decimal=3) - np.testing.assert_array_almost_equal( - ssOriginalMag, ssTransformedMag, decimal=3) - np.testing.assert_array_almost_equal( - ssOriginalPhase, ssTransformedPhase, - decimal=3) - np.testing.assert_array_almost_equal( - tfOriginalMag, tfTransformedMag, decimal=3) - np.testing.assert_array_almost_equal( - tfOriginalPhase, tfTransformedPhase, - decimal=2) - - -if __name__ == '__main__': - unittest.main() + + ssOriginal = rss(states, outputs, inputs) + + tfOriginal_Actrb, tfOriginal_Bctrb, tfOriginal_Cctrb,\ + tfOrigingal_nctrb, tfOriginal_index,\ + tfOriginal_dcoeff, tfOriginal_ucoeff = tb04ad( + states, inputs, outputs, ssOriginal.A, + ssOriginal.B, ssOriginal.C, ssOriginal.D, + tol1=0.0) + + ssTransformed_nr, ssTransformed_A, ssTransformed_B,\ + ssTransformed_C, ssTransformed_D\ + = td04ad('R', inputs, outputs, tfOriginal_index, + tfOriginal_dcoeff, tfOriginal_ucoeff, + tol=0.0) + + tfTransformed_Actrb, tfTransformed_Bctrb,\ + tfTransformed_Cctrb, tfTransformed_nctrb,\ + tfTransformed_index, tfTransformed_dcoeff,\ + tfTransformed_ucoeff = tb04ad( + ssTransformed_nr, inputs, outputs, + ssTransformed_A, ssTransformed_B, + ssTransformed_C, ssTransformed_D, + tol1=0.0) + + numTransformed = np.array(tfTransformed_ucoeff) + denTransformed = np.array(tfTransformed_dcoeff) + numOriginal = np.array(tfOriginal_ucoeff) + denOriginal = np.array(tfOriginal_dcoeff) + + ssTransformed = ss(ssTransformed_A, + ssTransformed_B, + ssTransformed_C, + ssTransformed_D) + for inputNum in range(inputs): + for outputNum in range(outputs): + [ssOriginalMag, ssOriginalPhase, freq] =\ + bode(ssOriginal, plot=False) + [tfOriginalMag, tfOriginalPhase, freq] =\ + bode(tf(numOriginal[outputNum][inputNum], + denOriginal[outputNum]), + plot=False) + [ssTransformedMag, ssTransformedPhase, freq] =\ + bode(ssTransformed, + freq, + plot=False) + [tfTransformedMag, tfTransformedPhase, freq] =\ + bode(tf(numTransformed[outputNum][inputNum], + denTransformed[outputNum]), + freq, + plot=False) + # print('numOrig=', + # numOriginal[outputNum][inputNum]) + # print('denOrig=', + # denOriginal[outputNum]) + # print('numTrans=', + # numTransformed[outputNum][inputNum]) + # print('denTrans=', + # denTransformed[outputNum]) + np.testing.assert_array_almost_equal( + ssOriginalMag, tfOriginalMag, decimal=3) + np.testing.assert_array_almost_equal( + ssOriginalPhase, tfOriginalPhase, + decimal=3) + np.testing.assert_array_almost_equal( + ssOriginalMag, ssTransformedMag, decimal=3) + np.testing.assert_array_almost_equal( + ssOriginalPhase, ssTransformedPhase, + decimal=3) + np.testing.assert_array_almost_equal( + tfOriginalMag, tfTransformedMag, decimal=3) + np.testing.assert_array_almost_equal( + tfOriginalPhase, tfTransformedPhase, + decimal=2) diff --git a/control/tests/statefbk_array_test.py b/control/tests/statefbk_array_test.py deleted file mode 100644 index 10f450186..000000000 --- a/control/tests/statefbk_array_test.py +++ /dev/null @@ -1,413 +0,0 @@ -#!/usr/bin/env python -# -# statefbk_test.py - test state feedback functions -# RMM, 30 Mar 2011 (based on TestStatefbk from v0.4a) - -from __future__ import print_function -import unittest -import sys as pysys -import numpy as np -import warnings -from control.statefbk import ctrb, obsv, place, place_varga, lqr, gram, acker -from control.matlab import * -from control.exception import slycot_check, ControlDimension -from control.mateqn import care, dare -from control.config import use_numpy_matrix, reset_defaults - -class TestStatefbk(unittest.TestCase): - """Test state feedback functions""" - - def setUp(self): - # Use array instead of matrix (and save old value to restore at end) - use_numpy_matrix(False) - - # Maximum number of states to test + 1 - self.maxStates = 5 - # Maximum number of inputs and outputs to test + 1 - self.maxTries = 4 - # Set to True to print systems to the output. - self.debug = False - # get consistent test results - np.random.seed(0) - - def testCtrbSISO(self): - A = np.array([[1., 2.], [3., 4.]]) - B = np.array([[5.], [7.]]) - Wctrue = np.array([[5., 19.], [7., 43.]]) - - Wc = ctrb(A, B) - np.testing.assert_array_almost_equal(Wc, Wctrue) - self.assertTrue(isinstance(Wc, np.ndarray)) - self.assertFalse(isinstance(Wc, np.matrix)) - - # This test only works in Python 3 due to a conflict with the same - # warning type in other test modules (frd_test.py). See - # https://bugs.python.org/issue4180 for more details - @unittest.skipIf(pysys.version_info < (3, 0), "test requires Python 3+") - def test_ctrb_siso_deprecated(self): - A = np.array([[1., 2.], [3., 4.]]) - B = np.array([[5.], [7.]]) - - # Check that default using np.matrix generates a warning - # TODO: remove this check with matrix type is deprecated - warnings.resetwarnings() - with warnings.catch_warnings(record=True) as w: - use_numpy_matrix(True) - self.assertTrue(issubclass(w[-1].category, UserWarning)) - - Wc = ctrb(A, B) - self.assertTrue(isinstance(Wc, np.matrix)) - self.assertTrue(issubclass(w[-1].category, - PendingDeprecationWarning)) - use_numpy_matrix(False) - - def testCtrbMIMO(self): - A = np.array([[1., 2.], [3., 4.]]) - B = np.array([[5., 6.], [7., 8.]]) - Wctrue = np.array([[5., 6., 19., 22.], [7., 8., 43., 50.]]) - Wc = ctrb(A, B) - np.testing.assert_array_almost_equal(Wc, Wctrue) - - # Make sure default type values are correct - self.assertTrue(isinstance(Wc, np.ndarray)) - - def testObsvSISO(self): - A = np.array([[1., 2.], [3., 4.]]) - C = np.array([[5., 7.]]) - Wotrue = np.array([[5., 7.], [26., 38.]]) - Wo = obsv(A, C) - np.testing.assert_array_almost_equal(Wo, Wotrue) - - # Make sure default type values are correct - self.assertTrue(isinstance(Wo, np.ndarray)) - - # This test only works in Python 3 due to a conflict with the same - # warning type in other test modules (frd_test.py). See - # https://bugs.python.org/issue4180 for more details - @unittest.skipIf(pysys.version_info < (3, 0), "test requires Python 3+") - def test_obsv_siso_deprecated(self): - A = np.array([[1., 2.], [3., 4.]]) - C = np.array([[5., 7.]]) - - # Check that default type generates a warning - # TODO: remove this check with matrix type is deprecated - with warnings.catch_warnings(record=True) as w: - use_numpy_matrix(True, warn=False) # warnings off - self.assertEqual(len(w), 0) - - Wo = obsv(A, C) - self.assertTrue(isinstance(Wo, np.matrix)) - use_numpy_matrix(False) - - def testObsvMIMO(self): - A = np.array([[1., 2.], [3., 4.]]) - C = np.array([[5., 6.], [7., 8.]]) - Wotrue = np.array([[5., 6.], [7., 8.], [23., 34.], [31., 46.]]) - Wo = obsv(A, C) - np.testing.assert_array_almost_equal(Wo, Wotrue) - - def testCtrbObsvDuality(self): - A = np.array([[1.2, -2.3], [3.4, -4.5]]) - B = np.array([[5.8, 6.9], [8., 9.1]]) - Wc = ctrb(A, B) - A = np.transpose(A) - C = np.transpose(B) - Wo = np.transpose(obsv(A, C)); - np.testing.assert_array_almost_equal(Wc,Wo) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testGramWc(self): - A = np.array([[1., -2.], [3., -4.]]) - B = np.array([[5., 6.], [7., 8.]]) - C = np.array([[4., 5.], [6., 7.]]) - D = np.array([[13., 14.], [15., 16.]]) - sys = ss(A, B, C, D) - Wctrue = np.array([[18.5, 24.5], [24.5, 32.5]]) - Wc = gram(sys, 'c') - np.testing.assert_array_almost_equal(Wc, Wctrue) - - # This test only works in Python 3 due to a conflict with the same - # warning type in other test modules (frd_test.py). See - # https://bugs.python.org/issue4180 for more details - @unittest.skipIf(pysys.version_info < (3, 0) or not slycot_check(), - "test requires Python 3+ and slycot") - def test_gram_wc_deprecated(self): - A = np.array([[1., -2.], [3., -4.]]) - B = np.array([[5., 6.], [7., 8.]]) - C = np.array([[4., 5.], [6., 7.]]) - D = np.array([[13., 14.], [15., 16.]]) - sys = ss(A, B, C, D) - - # Check that default type generates a warning - # TODO: remove this check with matrix type is deprecated - with warnings.catch_warnings(record=True) as w: - use_numpy_matrix(True) - self.assertTrue(issubclass(w[-1].category, UserWarning)) - - Wc = gram(sys, 'c') - self.assertTrue(isinstance(Wc, np.ndarray)) - use_numpy_matrix(False) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testGramRc(self): - A = np.array([[1., -2.], [3., -4.]]) - B = np.array([[5., 6.], [7., 8.]]) - C = np.array([[4., 5.], [6., 7.]]) - D = np.array([[13., 14.], [15., 16.]]) - sys = ss(A, B, C, D) - Rctrue = np.array([[4.30116263, 5.6961343], [0., 0.23249528]]) - Rc = gram(sys, 'cf') - np.testing.assert_array_almost_equal(Rc, Rctrue) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testGramWo(self): - A = np.array([[1., -2.], [3., -4.]]) - B = np.array([[5., 6.], [7., 8.]]) - C = np.array([[4., 5.], [6., 7.]]) - D = np.array([[13., 14.], [15., 16.]]) - sys = ss(A, B, C, D) - Wotrue = np.array([[257.5, -94.5], [-94.5, 56.5]]) - Wo = gram(sys, 'o') - np.testing.assert_array_almost_equal(Wo, Wotrue) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testGramWo2(self): - A = np.array([[1., -2.], [3., -4.]]) - B = np.array([[5.], [7.]]) - C = np.array([[6., 8.]]) - D = np.array([[9.]]) - sys = ss(A,B,C,D) - Wotrue = np.array([[198., -72.], [-72., 44.]]) - Wo = gram(sys, 'o') - np.testing.assert_array_almost_equal(Wo, Wotrue) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testGramRo(self): - A = np.array([[1., -2.], [3., -4.]]) - B = np.array([[5., 6.], [7., 8.]]) - C = np.array([[4., 5.], [6., 7.]]) - D = np.array([[13., 14.], [15., 16.]]) - sys = ss(A, B, C, D) - Rotrue = np.array([[16.04680654, -5.8890222], [0., 4.67112593]]) - Ro = gram(sys, 'of') - np.testing.assert_array_almost_equal(Ro, Rotrue) - - def testGramsys(self): - num =[1.] - den = [1., 1., 1.] - sys = tf(num,den) - self.assertRaises(ValueError, gram, sys, 'o') - self.assertRaises(ValueError, gram, sys, 'c') - - def testAcker(self): - for states in range(1, self.maxStates): - for i in range(self.maxTries): - # start with a random SS system and transform to TF then - # back to SS, check that the matrices are the same. - sys = rss(states, 1, 1) - if (self.debug): - print(sys) - - # Make sure the system is not degenerate - Cmat = ctrb(sys.A, sys.B) - if np.linalg.matrix_rank(Cmat) != states: - if (self.debug): - print(" skipping (not reachable or ill conditioned)") - continue - - # Place the poles at random locations - des = rss(states, 1, 1); - poles = pole(des) - - # Now place the poles using acker - K = acker(sys.A, sys.B, poles) - new = ss(sys.A - sys.B * K, sys.B, sys.C, sys.D) - placed = pole(new) - - # Debugging code - # diff = np.sort(poles) - np.sort(placed) - # if not all(diff < 0.001): - # print("Found a problem:") - # print(sys) - # print("desired = ", poles) - - np.testing.assert_array_almost_equal(np.sort(poles), - np.sort(placed), decimal=4) - - def testPlace(self): - # Matrices shamelessly stolen from scipy example code. - A = np.array([[1.380, -0.2077, 6.715, -5.676], - [-0.5814, -4.290, 0, 0.6750], - [1.067, 4.273, -6.654, 5.893], - [0.0480, 4.273, 1.343, -2.104]]) - - B = np.array([[0, 5.679], - [1.136, 1.136], - [0, 0,], - [-3.146, 0]]) - P = np.array([-0.5+1j, -0.5-1j, -5.0566, -8.6659]) - K = place(A, B, P) - P_placed = np.linalg.eigvals(A - B.dot(K)) - # No guarantee of the ordering, so sort them - P.sort() - P_placed.sort() - np.testing.assert_array_almost_equal(P, P_placed) - - # Test that the dimension checks work. - np.testing.assert_raises(ControlDimension, place, A[1:, :], B, P) - np.testing.assert_raises(ControlDimension, place, A, B[1:, :], P) - - # Check that we get an error if we ask for too many poles in the same - # location. Here, rank(B) = 2, so lets place three at the same spot. - P_repeated = np.array([-0.5, -0.5, -0.5, -8.6659]) - np.testing.assert_raises(ValueError, place, A, B, P_repeated) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testPlace_varga_continuous(self): - """ - Check that we can place eigenvalues for dtime=False - """ - A = np.array([[1., -2.], [3., -4.]]) - B = np.array([[5.], [7.]]) - - P = np.array([-2., -2.]) - K = place_varga(A, B, P) - P_placed = np.linalg.eigvals(A - B.dot(K)) - # No guarantee of the ordering, so sort them - P.sort() - P_placed.sort() - np.testing.assert_array_almost_equal(P, P_placed) - - # Test that the dimension checks work. - np.testing.assert_raises(ControlDimension, place, A[1:, :], B, P) - np.testing.assert_raises(ControlDimension, place, A, B[1:, :], P) - - # Regression test against bug #177 - # https://github.com/python-control/python-control/issues/177 - A = np.array([[0, 1], [100, 0]]) - B = np.array([[0], [1]]) - P = np.array([-20 + 10*1j, -20 - 10*1j]) - K = place_varga(A, B, P) - P_placed = np.linalg.eigvals(A - B.dot(K)) - - # No guarantee of the ordering, so sort them - P.sort() - P_placed.sort() - np.testing.assert_array_almost_equal(P, P_placed) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testPlace_varga_continuous_partial_eigs(self): - """ - Check that we are able to use the alpha parameter to only place - a subset of the eigenvalues, for the continous time case. - """ - # A matrix has eigenvalues at s=-1, and s=-2. Choose alpha = -1.5 - # and check that eigenvalue at s=-2 stays put. - A = np.array([[1., -2.], [3., -4.]]) - B = np.array([[5.], [7.]]) - - P = np.array([-3.]) - P_expected = np.array([-2.0, -3.0]) - alpha = -1.5 - K = place_varga(A, B, P, alpha=alpha) - - P_placed = np.linalg.eigvals(A - B.dot(K)) - # No guarantee of the ordering, so sort them - P_expected.sort() - P_placed.sort() - np.testing.assert_array_almost_equal(P_expected, P_placed) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testPlace_varga_discrete(self): - """ - Check that we can place poles using dtime=True (discrete time) - """ - A = np.array([[1., 0], [0, 0.5]]) - B = np.array([[5.], [7.]]) - - P = np.array([0.5, 0.5]) - K = place_varga(A, B, P, dtime=True) - P_placed = np.linalg.eigvals(A - B.dot(K)) - # No guarantee of the ordering, so sort them - P.sort() - P_placed.sort() - np.testing.assert_array_almost_equal(P, P_placed) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testPlace_varga_discrete_partial_eigs(self): - """" - Check that we can only assign a single eigenvalue in the discrete - time case. - """ - # A matrix has eigenvalues at 1.0 and 0.5. Set alpha = 0.51, and - # check that the eigenvalue at 0.5 is not moved. - A = np.array([[1., 0], [0, 0.5]]) - B = np.array([[5.], [7.]]) - P = np.array([0.2, 0.6]) - P_expected = np.array([0.5, 0.6]) - alpha = 0.51 - K = place_varga(A, B, P, dtime=True, alpha=alpha) - P_placed = np.linalg.eigvals(A - B.dot(K)) - P_expected.sort() - P_placed.sort() - np.testing.assert_array_almost_equal(P_expected, P_placed) - - - def check_LQR(self, K, S, poles, Q, R): - S_expected = np.array(np.sqrt(Q * R)) - K_expected = S_expected / R - poles_expected = np.array([-K_expected]) - np.testing.assert_array_almost_equal(S, S_expected) - np.testing.assert_array_almost_equal(K, K_expected) - np.testing.assert_array_almost_equal(poles, poles_expected) - - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def test_LQR_integrator(self): - A, B, Q, R = 0., 1., 10., 2. - K, S, poles = lqr(A, B, Q, R) - self.check_LQR(K, S, poles, Q, R) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def test_LQR_3args(self): - sys = ss(0., 1., 1., 0.) - Q, R = 10., 2. - K, S, poles = lqr(sys, Q, R) - self.check_LQR(K, S, poles, Q, R) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def test_care(self): - #unit test for stabilizing and anti-stabilizing feedbacks - #continuous-time - - A = np.diag([1,-1]) - B = np.identity(2) - Q = np.identity(2) - R = np.identity(2) - S = 0 * B - E = np.identity(2) - X, L , G = care(A, B, Q, R, S, E, stabilizing=True) - assert np.all(np.real(L) < 0) - X, L , G = care(A, B, Q, R, S, E, stabilizing=False) - assert np.all(np.real(L) > 0) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def test_dare(self): - #discrete-time - A = np.diag([0.5,2]) - B = np.identity(2) - Q = np.identity(2) - R = np.identity(2) - S = 0 * B - E = np.identity(2) - X, L , G = dare(A, B, Q, R, S, E, stabilizing=True) - assert np.all(np.abs(L) < 1) - X, L , G = dare(A, B, Q, R, S, E, stabilizing=False) - assert np.all(np.abs(L) > 1) - - def tearDown(self): - reset_defaults() - - -if __name__ == '__main__': - unittest.main() diff --git a/control/tests/statefbk_test.py b/control/tests/statefbk_test.py index 3be70d643..1dca98659 100644 --- a/control/tests/statefbk_test.py +++ b/control/tests/statefbk_test.py @@ -1,129 +1,152 @@ -#!/usr/bin/env python -# -# statefbk_test.py - test state feedback functions -# RMM, 30 Mar 2011 (based on TestStatefbk from v0.4a) +"""statefbk_test.py - test state feedback functions + +RMM, 30 Mar 2011 (based on TestStatefbk from v0.4a) +""" -from __future__ import print_function -import unittest import numpy as np -from control.statefbk import ctrb, obsv, place, place_varga, lqr, lqe, gram, acker -from control.matlab import * -from control.exception import slycot_check, ControlDimension +import pytest + +from control import lqe, pole, rss, ss, tf +from control.exception import ControlDimension from control.mateqn import care, dare +from control.statefbk import ctrb, obsv, place, place_varga, lqr, gram, acker +from control.tests.conftest import (slycotonly, check_deprecated_matrix, + ismatarrayout, asmatarrayout) + + +@pytest.fixture +def fixedseed(): + """Get consistent test results""" + np.random.seed(0) + -class TestStatefbk(unittest.TestCase): +class TestStatefbk: """Test state feedback functions""" - def setUp(self): - # Maximum number of states to test + 1 - self.maxStates = 5 - # Maximum number of inputs and outputs to test + 1 - self.maxTries = 4 - # Set to True to print systems to the output. - self.debug = False - # get consistent test results - np.random.seed(0) - - def testCtrbSISO(self): - A = np.matrix("1. 2.; 3. 4.") - B = np.matrix("5.; 7.") - Wctrue = np.matrix("5. 19.; 7. 43.") - Wc = ctrb(A,B) + # Maximum number of states to test + 1 + maxStates = 5 + # Maximum number of inputs and outputs to test + 1 + maxTries = 4 + # Set to True to print systems to the output. + debug = False + + def testCtrbSISO(self, matarrayin, matarrayout): + A = matarrayin([[1., 2.], [3., 4.]]) + B = matarrayin([[5.], [7.]]) + Wctrue = np.array([[5., 19.], [7., 43.]]) + + with check_deprecated_matrix(): + Wc = ctrb(A, B) + assert ismatarrayout(Wc) + np.testing.assert_array_almost_equal(Wc, Wctrue) - def testCtrbMIMO(self): - A = np.matrix("1. 2.; 3. 4.") - B = np.matrix("5. 6.; 7. 8.") - Wctrue = np.matrix("5. 6. 19. 22.; 7. 8. 43. 50.") - Wc = ctrb(A,B) + def testCtrbMIMO(self, matarrayin): + A = matarrayin([[1., 2.], [3., 4.]]) + B = matarrayin([[5., 6.], [7., 8.]]) + Wctrue = np.array([[5., 6., 19., 22.], [7., 8., 43., 50.]]) + Wc = ctrb(A, B) np.testing.assert_array_almost_equal(Wc, Wctrue) - def testObsvSISO(self): - A = np.matrix("1. 2.; 3. 4.") - C = np.matrix("5. 7.") - Wotrue = np.matrix("5. 7.; 26. 38.") - Wo = obsv(A,C) + # Make sure default type values are correct + assert ismatarrayout(Wc) + + def testObsvSISO(self, matarrayin): + A = matarrayin([[1., 2.], [3., 4.]]) + C = matarrayin([[5., 7.]]) + Wotrue = np.array([[5., 7.], [26., 38.]]) + Wo = obsv(A, C) np.testing.assert_array_almost_equal(Wo, Wotrue) - def testObsvMIMO(self): - A = np.matrix("1. 2.; 3. 4.") - C = np.matrix("5. 6.; 7. 8.") - Wotrue = np.matrix("5. 6.; 7. 8.; 23. 34.; 31. 46.") - Wo = obsv(A,C) + # Make sure default type values are correct + assert ismatarrayout(Wo) + + + def testObsvMIMO(self, matarrayin): + A = matarrayin([[1., 2.], [3., 4.]]) + C = matarrayin([[5., 6.], [7., 8.]]) + Wotrue = np.array([[5., 6.], [7., 8.], [23., 34.], [31., 46.]]) + Wo = obsv(A, C) np.testing.assert_array_almost_equal(Wo, Wotrue) - def testCtrbObsvDuality(self): - A = np.matrix("1.2 -2.3; 3.4 -4.5") - B = np.matrix("5.8 6.9; 8. 9.1") - Wc = ctrb(A,B); + def testCtrbObsvDuality(self, matarrayin): + A = matarrayin([[1.2, -2.3], [3.4, -4.5]]) + B = matarrayin([[5.8, 6.9], [8., 9.1]]) + Wc = ctrb(A, B) A = np.transpose(A) C = np.transpose(B) - Wo = np.transpose(obsv(A,C)); + Wo = np.transpose(obsv(A, C)); np.testing.assert_array_almost_equal(Wc,Wo) - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testGramWc(self): - A = np.matrix("1. -2.; 3. -4.") - B = np.matrix("5. 6.; 7. 8.") - C = np.matrix("4. 5.; 6. 7.") - D = np.matrix("13. 14.; 15. 16.") + @slycotonly + def testGramWc(self, matarrayin, matarrayout): + A = matarrayin([[1., -2.], [3., -4.]]) + B = matarrayin([[5., 6.], [7., 8.]]) + C = matarrayin([[4., 5.], [6., 7.]]) + D = matarrayin([[13., 14.], [15., 16.]]) sys = ss(A, B, C, D) - Wctrue = np.matrix("18.5 24.5; 24.5 32.5") - Wc = gram(sys,'c') + Wctrue = np.array([[18.5, 24.5], [24.5, 32.5]]) + + with check_deprecated_matrix(): + Wc = gram(sys, 'c') + + assert ismatarrayout(Wc) np.testing.assert_array_almost_equal(Wc, Wctrue) - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testGramRc(self): - A = np.matrix("1. -2.; 3. -4.") - B = np.matrix("5. 6.; 7. 8.") - C = np.matrix("4. 5.; 6. 7.") - D = np.matrix("13. 14.; 15. 16.") + @slycotonly + def testGramRc(self, matarrayin): + A = matarrayin([[1., -2.], [3., -4.]]) + B = matarrayin([[5., 6.], [7., 8.]]) + C = matarrayin([[4., 5.], [6., 7.]]) + D = matarrayin([[13., 14.], [15., 16.]]) sys = ss(A, B, C, D) - Rctrue = np.matrix("4.30116263 5.6961343; 0. 0.23249528") - Rc = gram(sys,'cf') + Rctrue = np.array([[4.30116263, 5.6961343], [0., 0.23249528]]) + Rc = gram(sys, 'cf') np.testing.assert_array_almost_equal(Rc, Rctrue) - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testGramWo(self): - A = np.matrix("1. -2.; 3. -4.") - B = np.matrix("5. 6.; 7. 8.") - C = np.matrix("4. 5.; 6. 7.") - D = np.matrix("13. 14.; 15. 16.") + @slycotonly + def testGramWo(self, matarrayin): + A = matarrayin([[1., -2.], [3., -4.]]) + B = matarrayin([[5., 6.], [7., 8.]]) + C = matarrayin([[4., 5.], [6., 7.]]) + D = matarrayin([[13., 14.], [15., 16.]]) sys = ss(A, B, C, D) - Wotrue = np.matrix("257.5 -94.5; -94.5 56.5") - Wo = gram(sys,'o') + Wotrue = np.array([[257.5, -94.5], [-94.5, 56.5]]) + Wo = gram(sys, 'o') np.testing.assert_array_almost_equal(Wo, Wotrue) - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testGramWo2(self): - A = np.matrix("1. -2.; 3. -4.") - B = np.matrix("5.; 7.") - C = np.matrix("6. 8.") - D = np.matrix("9.") + @slycotonly + def testGramWo2(self, matarrayin): + A = matarrayin([[1., -2.], [3., -4.]]) + B = matarrayin([[5.], [7.]]) + C = matarrayin([[6., 8.]]) + D = matarrayin([[9.]]) sys = ss(A,B,C,D) - Wotrue = np.matrix("198. -72.; -72. 44.") - Wo = gram(sys,'o') + Wotrue = np.array([[198., -72.], [-72., 44.]]) + Wo = gram(sys, 'o') np.testing.assert_array_almost_equal(Wo, Wotrue) - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testGramRo(self): - A = np.matrix("1. -2.; 3. -4.") - B = np.matrix("5. 6.; 7. 8.") - C = np.matrix("4. 5.; 6. 7.") - D = np.matrix("13. 14.; 15. 16.") + @slycotonly + def testGramRo(self, matarrayin): + A = matarrayin([[1., -2.], [3., -4.]]) + B = matarrayin([[5., 6.], [7., 8.]]) + C = matarrayin([[4., 5.], [6., 7.]]) + D = matarrayin([[13., 14.], [15., 16.]]) sys = ss(A, B, C, D) - Rotrue = np.matrix("16.04680654 -5.8890222; 0. 4.67112593") - Ro = gram(sys,'of') + Rotrue = np.array([[16.04680654, -5.8890222], [0., 4.67112593]]) + Ro = gram(sys, 'of') np.testing.assert_array_almost_equal(Ro, Rotrue) def testGramsys(self): num =[1.] den = [1., 1., 1.] sys = tf(num,den) - self.assertRaises(ValueError, gram, sys, 'o') - self.assertRaises(ValueError, gram, sys, 'c') + with pytest.raises(ValueError): + gram(sys, 'o') + with pytest.raises(ValueError): + gram(sys, 'c') - def testAcker(self): + def testAcker(self, fixedseed): for states in range(1, self.maxStates): for i in range(self.maxTries): # start with a random SS system and transform to TF then @@ -158,49 +181,54 @@ def testAcker(self): np.testing.assert_array_almost_equal(np.sort(poles), np.sort(placed), decimal=4) - def testPlace(self): + def checkPlaced(self, P_expected, P_placed): + """Check that placed poles are correct""" + # No guarantee of the ordering, so sort them + P_expected = np.squeeze(np.asarray(P_expected)) + P_expected.sort() + P_placed.sort() + np.testing.assert_array_almost_equal(P_expected, P_placed) + + def testPlace(self, matarrayin): # Matrices shamelessly stolen from scipy example code. - A = np.array([[1.380, -0.2077, 6.715, -5.676], - [-0.5814, -4.290, 0, 0.6750], - [1.067, 4.273, -6.654, 5.893], - [0.0480, 4.273, 1.343, -2.104]]) - - B = np.array([[0, 5.679], - [1.136, 1.136], - [0, 0,], - [-3.146, 0]]) - P = np.array([-0.5+1j, -0.5-1j, -5.0566, -8.6659]) + A = matarrayin([[1.380, -0.2077, 6.715, -5.676], + [-0.5814, -4.290, 0, 0.6750], + [1.067, 4.273, -6.654, 5.893], + [0.0480, 4.273, 1.343, -2.104]]) + B = matarrayin([[0, 5.679], + [1.136, 1.136], + [0, 0], + [-3.146, 0]]) + P = matarrayin([-0.5 + 1j, -0.5 - 1j, -5.0566, -8.6659]) K = place(A, B, P) + assert ismatarrayout(K) P_placed = np.linalg.eigvals(A - B.dot(K)) - # No guarantee of the ordering, so sort them - P.sort() - P_placed.sort() - np.testing.assert_array_almost_equal(P, P_placed) + self.checkPlaced(P, P_placed) # Test that the dimension checks work. - np.testing.assert_raises(ControlDimension, place, A[1:, :], B, P) - np.testing.assert_raises(ControlDimension, place, A, B[1:, :], P) + with pytest.raises(ControlDimension): + place(A[1:, :], B, P) + with pytest.raises(ControlDimension): + place(A, B[1:, :], P) # Check that we get an error if we ask for too many poles in the same # location. Here, rank(B) = 2, so lets place three at the same spot. - P_repeated = np.array([-0.5, -0.5, -0.5, -8.6659]) - np.testing.assert_raises(ValueError, place, A, B, P_repeated) + P_repeated = matarrayin([-0.5, -0.5, -0.5, -8.6659]) + with pytest.raises(ValueError): + place(A, B, P_repeated) - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testPlace_varga_continuous(self): + @slycotonly + def testPlace_varga_continuous(self, matarrayin): """ Check that we can place eigenvalues for dtime=False """ - A = np.array([[1., -2.], [3., -4.]]) - B = np.array([[5.], [7.]]) + A = matarrayin([[1., -2.], [3., -4.]]) + B = matarrayin([[5.], [7.]]) - P = np.array([-2., -2.]) + P = [-2., -2.] K = place_varga(A, B, P) P_placed = np.linalg.eigvals(A - B.dot(K)) - # No guarantee of the ordering, so sort them - P.sort() - P_placed.sort() - np.testing.assert_array_almost_equal(P, P_placed) + self.checkPlaced(P, P_placed) # Test that the dimension checks work. np.testing.assert_raises(ControlDimension, place, A[1:, :], B, P) @@ -208,141 +236,146 @@ def testPlace_varga_continuous(self): # Regression test against bug #177 # https://github.com/python-control/python-control/issues/177 - A = np.array([[0, 1], [100, 0]]) - B = np.array([[0], [1]]) - P = np.array([-20 + 10*1j, -20 - 10*1j]) + A = matarrayin([[0, 1], [100, 0]]) + B = matarrayin([[0], [1]]) + P = matarrayin([-20 + 10*1j, -20 - 10*1j]) K = place_varga(A, B, P) P_placed = np.linalg.eigvals(A - B.dot(K)) + self.checkPlaced(P, P_placed) - # No guarantee of the ordering, so sort them - P.sort() - P_placed.sort() - np.testing.assert_array_almost_equal(P, P_placed) - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testPlace_varga_continuous_partial_eigs(self): + @slycotonly + def testPlace_varga_continuous_partial_eigs(self, matarrayin): """ Check that we are able to use the alpha parameter to only place a subset of the eigenvalues, for the continous time case. """ # A matrix has eigenvalues at s=-1, and s=-2. Choose alpha = -1.5 # and check that eigenvalue at s=-2 stays put. - A = np.array([[1., -2.], [3., -4.]]) - B = np.array([[5.], [7.]]) + A = matarrayin([[1., -2.], [3., -4.]]) + B = matarrayin([[5.], [7.]]) - P = np.array([-3.]) + P = matarrayin([-3.]) P_expected = np.array([-2.0, -3.0]) alpha = -1.5 K = place_varga(A, B, P, alpha=alpha) P_placed = np.linalg.eigvals(A - B.dot(K)) # No guarantee of the ordering, so sort them - P_expected.sort() - P_placed.sort() - np.testing.assert_array_almost_equal(P_expected, P_placed) + self.checkPlaced(P_expected, P_placed) - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testPlace_varga_discrete(self): + @slycotonly + def testPlace_varga_discrete(self, matarrayin): """ Check that we can place poles using dtime=True (discrete time) """ - A = np.array([[1., 0], [0, 0.5]]) - B = np.array([[5.], [7.]]) + A = matarrayin([[1., 0], [0, 0.5]]) + B = matarrayin([[5.], [7.]]) - P = np.array([0.5, 0.5]) + P = matarrayin([0.5, 0.5]) K = place_varga(A, B, P, dtime=True) P_placed = np.linalg.eigvals(A - B.dot(K)) # No guarantee of the ordering, so sort them - P.sort() - P_placed.sort() - np.testing.assert_array_almost_equal(P, P_placed) + self.checkPlaced(P, P_placed) - @unittest.skipIf(not slycot_check(), "slycot not installed") - def testPlace_varga_discrete_partial_eigs(self): + @slycotonly + def testPlace_varga_discrete_partial_eigs(self, matarrayin): """" Check that we can only assign a single eigenvalue in the discrete time case. """ # A matrix has eigenvalues at 1.0 and 0.5. Set alpha = 0.51, and # check that the eigenvalue at 0.5 is not moved. - A = np.array([[1., 0], [0, 0.5]]) - B = np.array([[5.], [7.]]) - P = np.array([0.2, 0.6]) + A = matarrayin([[1., 0], [0, 0.5]]) + B = matarrayin([[5.], [7.]]) + P = matarrayin([0.2, 0.6]) P_expected = np.array([0.5, 0.6]) alpha = 0.51 K = place_varga(A, B, P, dtime=True, alpha=alpha) P_placed = np.linalg.eigvals(A - B.dot(K)) - P_expected.sort() - P_placed.sort() - np.testing.assert_array_almost_equal(P_expected, P_placed) + self.checkPlaced(P_expected, P_placed) def check_LQR(self, K, S, poles, Q, R): - S_expected = np.array(np.sqrt(Q * R)) - K_expected = S_expected / R - poles_expected = np.array([-K_expected]) + S_expected = asmatarrayout(np.sqrt(Q.dot(R))) + K_expected = asmatarrayout(S_expected / R) + poles_expected = -np.squeeze(np.asarray(K_expected)) np.testing.assert_array_almost_equal(S, S_expected) np.testing.assert_array_almost_equal(K, K_expected) np.testing.assert_array_almost_equal(poles, poles_expected) - @unittest.skipIf(not slycot_check(), "slycot not installed") - def test_LQR_integrator(self): - A, B, Q, R = 0., 1., 10., 2. + @slycotonly + def test_LQR_integrator(self, matarrayin, matarrayout): + A, B, Q, R = (matarrayin([[X]]) for X in [0., 1., 10., 2.]) K, S, poles = lqr(A, B, Q, R) self.check_LQR(K, S, poles, Q, R) - @unittest.skipIf(not slycot_check(), "slycot not installed") - def test_LQR_3args(self): + @slycotonly + def test_LQR_3args(self, matarrayin, matarrayout): sys = ss(0., 1., 1., 0.) - Q, R = 10., 2. + Q, R = (matarrayin([[X]]) for X in [10., 2.]) K, S, poles = lqr(sys, Q, R) self.check_LQR(K, S, poles, Q, R) + @slycotonly + @pytest.mark.xfail(reason="warning not implemented") + def testLQR_warning(self): + """Test lqr() + + Make sure we get a warning if [Q N;N' R] is not positive semi-definite + """ + # from matlab_test siso.ss2 (testLQR); probably not referenced before + # not yet implemented check + A = np.array([[-2, 3, 1], + [-1, 0, 0], + [0, 1, 0]]) + B = np.array([[-1, 0, 0]]).T + Q = np.eye(3) + R = np.eye(1) + N = np.array([[1, 1, 2]]).T + # assert any(np.linalg.eigvals(np.block([[Q, N], [N.T, R]])) < 0) + with pytest.warns(UserWarning): + (K, S, E) = lqr(A, B, Q, R, N) + def check_LQE(self, L, P, poles, G, QN, RN): - P_expected = np.array(np.sqrt(G*QN*G * RN)) - L_expected = P_expected / RN - poles_expected = np.array([-L_expected]) + P_expected = asmatarrayout(np.sqrt(G.dot(QN.dot(G).dot(RN)))) + L_expected = asmatarrayout(P_expected / RN) + poles_expected = -np.squeeze(np.asarray(L_expected)) np.testing.assert_array_almost_equal(P, P_expected) np.testing.assert_array_almost_equal(L, L_expected) np.testing.assert_array_almost_equal(poles, poles_expected) - @unittest.skipIf(not slycot_check(), "slycot not installed") - def test_LQE(self): - A, G, C, QN, RN = 0., .1, 1., 10., 2. + @slycotonly + def test_LQE(self, matarrayin): + A, G, C, QN, RN = (matarrayin([[X]]) for X in [0., .1, 1., 10., 2.]) L, P, poles = lqe(A, G, C, QN, RN) self.check_LQE(L, P, poles, G, QN, RN) - @unittest.skipIf(not slycot_check(), "slycot not installed") - def test_care(self): - #unit test for stabilizing and anti-stabilizing feedbacks - #continuous-time - - A = np.diag([1,-1]) - B = np.identity(2) - Q = np.identity(2) - R = np.identity(2) - S = 0 * B - E = np.identity(2) - X, L , G = care(A, B, Q, R, S, E, stabilizing=True) + @slycotonly + def test_care(self, matarrayin): + """Test stabilizing and anti-stabilizing feedbacks, continuous""" + A = matarrayin(np.diag([1, -1])) + B = matarrayin(np.identity(2)) + Q = matarrayin(np.identity(2)) + R = matarrayin(np.identity(2)) + S = matarrayin(np.zeros((2, 2))) + E = matarrayin(np.identity(2)) + X, L, G = care(A, B, Q, R, S, E, stabilizing=True) assert np.all(np.real(L) < 0) - X, L , G = care(A, B, Q, R, S, E, stabilizing=False) + X, L, G = care(A, B, Q, R, S, E, stabilizing=False) assert np.all(np.real(L) > 0) - @unittest.skipIf(not slycot_check(), "slycot not installed") - def test_dare(self): - #discrete-time - A = np.diag([0.5,2]) - B = np.identity(2) - Q = np.identity(2) - R = np.identity(2) - S = 0 * B - E = np.identity(2) - X, L , G = dare(A, B, Q, R, S, E, stabilizing=True) + @slycotonly + def test_dare(self, matarrayin): + """Test stabilizing and anti-stabilizing feedbacks, discrete""" + A = matarrayin(np.diag([0.5, 2])) + B = matarrayin(np.identity(2)) + Q = matarrayin(np.identity(2)) + R = matarrayin(np.identity(2)) + S = matarrayin(np.zeros((2, 2))) + E = matarrayin(np.identity(2)) + X, L, G = dare(A, B, Q, R, S, E, stabilizing=True) assert np.all(np.abs(L) < 1) - X, L , G = dare(A, B, Q, R, S, E, stabilizing=False) + X, L, G = dare(A, B, Q, R, S, E, stabilizing=False) assert np.all(np.abs(L) > 1) - - -if __name__ == '__main__': - unittest.main() diff --git a/control/tests/statesp_array_test.py b/control/tests/statesp_array_test.py deleted file mode 100644 index f0574cf24..000000000 --- a/control/tests/statesp_array_test.py +++ /dev/null @@ -1,639 +0,0 @@ -#!/usr/bin/env python -# -# statesp_test.py - test state space class with use_numpy_matrix(False) -# RMM, 14 Jun 2019 (coverted from statesp_test.py) - -import unittest -import numpy as np -from numpy.linalg import solve -from scipy.linalg import eigvals, block_diag -from control import matlab -from control.statesp import StateSpace, _convertToStateSpace, tf2ss -from control.xferfcn import TransferFunction, ss2tf -from control.lti import evalfr -from control.exception import slycot_check -from control.config import use_numpy_matrix, reset_defaults -from control.config import defaults - -class TestStateSpace(unittest.TestCase): - """Tests for the StateSpace class.""" - - def setUp(self): - """Set up a MIMO system to test operations on.""" - use_numpy_matrix(False) - - # sys1: 3-states square system (2 inputs x 2 outputs) - A322 = [[-3., 4., 2.], - [-1., -3., 0.], - [2., 5., 3.]] - B322 = [[1., 4.], - [-3., -3.], - [-2., 1.]] - C322 = [[4., 2., -3.], - [1., 4., 3.]] - D322 = [[-2., 4.], - [0., 1.]] - self.sys322 = StateSpace(A322, B322, C322, D322) - - # sys1: 2-states square system (2 inputs x 2 outputs) - A222 = [[4., 1.], - [2., -3]] - B222 = [[5., 2.], - [-3., -3.]] - C222 = [[2., -4], - [0., 1.]] - D222 = [[3., 2.], - [1., -1.]] - self.sys222 = StateSpace(A222, B222, C222, D222) - - # sys3: 6 states non square system (2 inputs x 3 outputs) - A623 = np.array([[1, 0, 0, 0, 0, 0], - [0, 1, 0, 0, 0, 0], - [0, 0, 3, 0, 0, 0], - [0, 0, 0, -4, 0, 0], - [0, 0, 0, 0, -1, 0], - [0, 0, 0, 0, 0, 3]]) - B623 = np.array([[0, -1], - [-1, 0], - [1, -1], - [0, 0], - [0, 1], - [-1, -1]]) - C623 = np.array([[1, 0, 0, 1, 0, 0], - [0, 1, 0, 1, 0, 1], - [0, 0, 1, 0, 0, 1]]) - D623 = np.zeros((3, 2)) - self.sys623 = StateSpace(A623, B623, C623, D623) - - def test_matlab_style_constructor(self): - # Use (deprecated?) matrix-style construction string (w/ warnings off) - import warnings - warnings.filterwarnings("ignore") # turn off warnings - sys = StateSpace("-1 1; 0 2", "0; 1", "1, 0", "0") - warnings.resetwarnings() # put things back to original state - self.assertEqual(sys.A.shape, (2, 2)) - self.assertEqual(sys.B.shape, (2, 1)) - self.assertEqual(sys.C.shape, (1, 2)) - self.assertEqual(sys.D.shape, (1, 1)) - if defaults['statesp.use_numpy_matrix']: - for X in [sys.A, sys.B, sys.C, sys.D]: - self.assertTrue(isinstance(X, np.matrix)) - else: - for X in [sys.A, sys.B, sys.C, sys.D]: - self.assertTrue(isinstance(X, np.ndarray)) - - def test_pole(self): - """Evaluate the poles of a MIMO system.""" - - p = np.sort(self.sys322.pole()) - true_p = np.sort([3.34747678408874, - -3.17373839204437 + 1.47492908003839j, - -3.17373839204437 - 1.47492908003839j]) - - np.testing.assert_array_almost_equal(p, true_p) - - def test_zero_empty(self): - """Test to make sure zero() works with no zeros in system.""" - sys = _convertToStateSpace(TransferFunction([1], [1, 2, 1])) - np.testing.assert_array_equal(sys.zero(), np.array([])) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def test_zero_siso(self): - """Evaluate the zeros of a SISO system.""" - # extract only first input / first output system of sys222. This system is denoted sys111 - # or tf111 - tf111 = ss2tf(self.sys222) - sys111 = tf2ss(tf111[0, 0]) - - # compute zeros as root of the characteristic polynomial at the numerator of tf111 - # this method is simple and assumed as valid in this test - true_z = np.sort(tf111[0, 0].zero()) - # Compute the zeros through ab08nd, which is tested here - z = np.sort(sys111.zero()) - - np.testing.assert_almost_equal(true_z, z) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def test_zero_mimo_sys322_square(self): - """Evaluate the zeros of a square MIMO system.""" - - z = np.sort(self.sys322.zero()) - true_z = np.sort([44.41465, -0.490252, -5.924398]) - np.testing.assert_array_almost_equal(z, true_z) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def test_zero_mimo_sys222_square(self): - """Evaluate the zeros of a square MIMO system.""" - - z = np.sort(self.sys222.zero()) - true_z = np.sort([-10.568501, 3.368501]) - np.testing.assert_array_almost_equal(z, true_z) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def test_zero_mimo_sys623_non_square(self): - """Evaluate the zeros of a non square MIMO system.""" - - z = np.sort(self.sys623.zero()) - true_z = np.sort([2., -1.]) - np.testing.assert_array_almost_equal(z, true_z) - - def test_add_ss(self): - """Add two MIMO systems.""" - - A = [[-3., 4., 2., 0., 0.], [-1., -3., 0., 0., 0.], - [2., 5., 3., 0., 0.], [0., 0., 0., 4., 1.], [0., 0., 0., 2., -3.]] - B = [[1., 4.], [-3., -3.], [-2., 1.], [5., 2.], [-3., -3.]] - C = [[4., 2., -3., 2., -4.], [1., 4., 3., 0., 1.]] - D = [[1., 6.], [1., 0.]] - - sys = self.sys322 + self.sys222 - - np.testing.assert_array_almost_equal(sys.A, A) - np.testing.assert_array_almost_equal(sys.B, B) - np.testing.assert_array_almost_equal(sys.C, C) - np.testing.assert_array_almost_equal(sys.D, D) - - def test_subtract_ss(self): - """Subtract two MIMO systems.""" - - A = [[-3., 4., 2., 0., 0.], [-1., -3., 0., 0., 0.], - [2., 5., 3., 0., 0.], [0., 0., 0., 4., 1.], [0., 0., 0., 2., -3.]] - B = [[1., 4.], [-3., -3.], [-2., 1.], [5., 2.], [-3., -3.]] - C = [[4., 2., -3., -2., 4.], [1., 4., 3., 0., -1.]] - D = [[-5., 2.], [-1., 2.]] - - sys = self.sys322 - self.sys222 - - np.testing.assert_array_almost_equal(sys.A, A) - np.testing.assert_array_almost_equal(sys.B, B) - np.testing.assert_array_almost_equal(sys.C, C) - np.testing.assert_array_almost_equal(sys.D, D) - - def test_multiply_ss(self): - """Multiply two MIMO systems.""" - - A = [[4., 1., 0., 0., 0.], [2., -3., 0., 0., 0.], [2., 0., -3., 4., 2.], - [-6., 9., -1., -3., 0.], [-4., 9., 2., 5., 3.]] - B = [[5., 2.], [-3., -3.], [7., -2.], [-12., -3.], [-5., -5.]] - C = [[-4., 12., 4., 2., -3.], [0., 1., 1., 4., 3.]] - D = [[-2., -8.], [1., -1.]] - - sys = self.sys322 * self.sys222 - - np.testing.assert_array_almost_equal(sys.A, A) - np.testing.assert_array_almost_equal(sys.B, B) - np.testing.assert_array_almost_equal(sys.C, C) - np.testing.assert_array_almost_equal(sys.D, D) - - def test_evalfr(self): - """Evaluate the frequency response at one frequency.""" - - A = [[-2, 0.5], [0.5, -0.3]] - B = [[0.3, -1.3], [0.1, 0.]] - C = [[0., 0.1], [-0.3, -0.2]] - D = [[0., -0.8], [-0.3, 0.]] - sys = StateSpace(A, B, C, D) - - resp = [[4.37636761487965e-05 - 0.0152297592997812j, - -0.792603938730853 + 0.0261706783369803j], - [-0.331544857768052 + 0.0576105032822757j, - 0.128919037199125 - 0.143824945295405j]] - - # Correct versions of the call - np.testing.assert_almost_equal(evalfr(sys, 1j), resp) - np.testing.assert_almost_equal(sys._evalfr(1.), resp) - - # Deprecated version of the call (should generate warning) - import warnings - with warnings.catch_warnings(record=True) as w: - # Set up warnings filter to only show warnings in control module - warnings.filterwarnings("ignore") - warnings.filterwarnings("always", module="control") - - # Make sure that we get a pending deprecation warning - sys.evalfr(1.) - assert len(w) == 1 - assert issubclass(w[-1].category, PendingDeprecationWarning) - - # Leave the warnings filter like we found it - warnings.resetwarnings() - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def test_freq_resp(self): - """Evaluate the frequency response at multiple frequencies.""" - - A = [[-2, 0.5], [0.5, -0.3]] - B = [[0.3, -1.3], [0.1, 0.]] - C = [[0., 0.1], [-0.3, -0.2]] - D = [[0., -0.8], [-0.3, 0.]] - sys = StateSpace(A, B, C, D) - - true_mag = [[[0.0852992637230322, 0.00103596611395218], - [0.935374692849736, 0.799380720864549]], - [[0.55656854563842, 0.301542699860857], - [0.609178071542849, 0.0382108097985257]]] - true_phase = [[[-0.566195599644593, -1.68063565332582], - [3.0465958317514, 3.14141384339534]], - [[2.90457947657161, 3.10601268291914], - [-0.438157380501337, -1.40720969147217]]] - true_omega = [0.1, 10.] - - mag, phase, omega = sys.freqresp(true_omega) - - np.testing.assert_almost_equal(mag, true_mag) - np.testing.assert_almost_equal(phase, true_phase) - np.testing.assert_equal(omega, true_omega) - - @unittest.skipIf(not slycot_check(), "slycot not installed") - def test_minreal(self): - """Test a minreal model reduction.""" - # A = [-2, 0.5, 0; 0.5, -0.3, 0; 0, 0, -0.1] - A = [[-2, 0.5, 0], [0.5, -0.3, 0], [0, 0, -0.1]] - # B = [0.3, -1.3; 0.1, 0; 1, 0] - B = [[0.3, -1.3], [0.1, 0.], [1.0, 0.0]] - # C = [0, 0.1, 0; -0.3, -0.2, 0] - C = [[0., 0.1, 0.0], [-0.3, -0.2, 0.0]] - # D = [0 -0.8; -0.3 0] - D = [[0., -0.8], [-0.3, 0.]] - # sys = ss(A, B, C, D) - - sys = StateSpace(A, B, C, D) - sysr = sys.minreal() - self.assertEqual(sysr.states, 2) - self.assertEqual(sysr.inputs, sys.inputs) - self.assertEqual(sysr.outputs, sys.outputs) - np.testing.assert_array_almost_equal( - eigvals(sysr.A), [-2.136154, -0.1638459]) - - def test_append_ss(self): - """Test appending two state-space systems.""" - A1 = [[-2, 0.5, 0], [0.5, -0.3, 0], [0, 0, -0.1]] - B1 = [[0.3, -1.3], [0.1, 0.], [1.0, 0.0]] - C1 = [[0., 0.1, 0.0], [-0.3, -0.2, 0.0]] - D1 = [[0., -0.8], [-0.3, 0.]] - A2 = [[-1.]] - B2 = [[1.2]] - C2 = [[0.5]] - D2 = [[0.4]] - A3 = [[-2, 0.5, 0, 0], [0.5, -0.3, 0, 0], [0, 0, -0.1, 0], - [0, 0, 0., -1.]] - B3 = [[0.3, -1.3, 0], [0.1, 0., 0], [1.0, 0.0, 0], [0., 0, 1.2]] - C3 = [[0., 0.1, 0.0, 0.0], [-0.3, -0.2, 0.0, 0.0], [0., 0., 0., 0.5]] - D3 = [[0., -0.8, 0.], [-0.3, 0., 0.], [0., 0., 0.4]] - sys1 = StateSpace(A1, B1, C1, D1) - sys2 = StateSpace(A2, B2, C2, D2) - sys3 = StateSpace(A3, B3, C3, D3) - sys3c = sys1.append(sys2) - np.testing.assert_array_almost_equal(sys3.A, sys3c.A) - np.testing.assert_array_almost_equal(sys3.B, sys3c.B) - np.testing.assert_array_almost_equal(sys3.C, sys3c.C) - np.testing.assert_array_almost_equal(sys3.D, sys3c.D) - - def test_append_tf(self): - """Test appending a state-space system with a tf""" - A1 = [[-2, 0.5, 0], [0.5, -0.3, 0], [0, 0, -0.1]] - B1 = [[0.3, -1.3], [0.1, 0.], [1.0, 0.0]] - C1 = [[0., 0.1, 0.0], [-0.3, -0.2, 0.0]] - D1 = [[0., -0.8], [-0.3, 0.]] - s = TransferFunction([1, 0], [1]) - h = 1 / (s + 1) / (s + 2) - sys1 = StateSpace(A1, B1, C1, D1) - sys2 = _convertToStateSpace(h) - sys3c = sys1.append(sys2) - np.testing.assert_array_almost_equal(sys1.A, sys3c.A[:3, :3]) - np.testing.assert_array_almost_equal(sys1.B, sys3c.B[:3, :2]) - np.testing.assert_array_almost_equal(sys1.C, sys3c.C[:2, :3]) - np.testing.assert_array_almost_equal(sys1.D, sys3c.D[:2, :2]) - np.testing.assert_array_almost_equal(sys2.A, sys3c.A[3:, 3:]) - np.testing.assert_array_almost_equal(sys2.B, sys3c.B[3:, 2:]) - np.testing.assert_array_almost_equal(sys2.C, sys3c.C[2:, 3:]) - np.testing.assert_array_almost_equal(sys2.D, sys3c.D[2:, 2:]) - np.testing.assert_array_almost_equal(sys3c.A[:3, 3:], np.zeros((3, 2))) - np.testing.assert_array_almost_equal(sys3c.A[3:, :3], np.zeros((2, 3))) - - def test_array_access_ss(self): - - sys1 = StateSpace([[1., 2.], [3., 4.]], - [[5., 6.], [6., 8.]], - [[9., 10.], [11., 12.]], - [[13., 14.], [15., 16.]], 1) - - sys1_11 = sys1[0, 1] - np.testing.assert_array_almost_equal(sys1_11.A, - sys1.A) - np.testing.assert_array_almost_equal(sys1_11.B, - sys1.B[:, [1]]) - np.testing.assert_array_almost_equal(sys1_11.C, - sys1.C[[0], :]) - np.testing.assert_array_almost_equal(sys1_11.D, sys1.D[0,1]) - - assert sys1.dt == sys1_11.dt - - def test_dc_gain_cont(self): - """Test DC gain for continuous-time state-space systems.""" - sys = StateSpace(-2., 6., 5., 0) - np.testing.assert_equal(sys.dcgain(), 15.) - - sys2 = StateSpace(-2, [[6., 4.]], [[5.], [7.], [11]], np.zeros((3, 2))) - expected = np.array([[15., 10.], [21., 14.], [33., 22.]]) - np.testing.assert_array_equal(sys2.dcgain(), expected) - - sys3 = StateSpace(0., 1., 1., 0.) - np.testing.assert_equal(sys3.dcgain(), np.nan) - - def test_dc_gain_discr(self): - """Test DC gain for discrete-time state-space systems.""" - # static gain - sys = StateSpace([], [], [], 2, True) - np.testing.assert_equal(sys.dcgain(), 2) - - # averaging filter - sys = StateSpace(0.5, 0.5, 1, 0, True) - np.testing.assert_almost_equal(sys.dcgain(), 1) - - # differencer - sys = StateSpace(0, 1, -1, 1, True) - np.testing.assert_equal(sys.dcgain(), 0) - - # summer - sys = StateSpace(1, 1, 1, 0, True) - np.testing.assert_equal(sys.dcgain(), np.nan) - - def test_dc_gain_integrator(self): - """DC gain when eigenvalue at DC returns appropriately sized array of nan.""" - # the SISO case is also tested in test_dc_gain_{cont,discr} - import itertools - # iterate over input and output sizes, and continuous (dt=None) and discrete (dt=True) time - for inputs, outputs, dt in itertools.product(range(1, 6), range(1, 6), [None, True]): - states = max(inputs, outputs) - - # a matrix that is singular at DC, and has no "useless" states as in - # _remove_useless_states - a = np.triu(np.tile(2, (states, states))) - # eigenvalues all +2, except for ... - a[0, 0] = 0 if dt is None else 1 - b = np.eye(max(inputs, states))[:states, :inputs] - c = np.eye(max(outputs, states))[:outputs, :states] - d = np.zeros((outputs, inputs)) - sys = StateSpace(a, b, c, d, dt) - dc = np.squeeze(np.tile(np.nan, (outputs, inputs))) - np.testing.assert_array_equal(dc, sys.dcgain()) - - def test_scalar_static_gain(self): - """Regression: can we create a scalar static gain?""" - g1 = StateSpace([], [], [], [2]) - g2 = StateSpace([], [], [], [3]) - - # make sure StateSpace internals, specifically ABC matrix - # sizes, are OK for LTI operations - g3 = g1 * g2 - self.assertEqual(6, g3.D[0, 0]) - g4 = g1 + g2 - self.assertEqual(5, g4.D[0, 0]) - g5 = g1.feedback(g2) - np.testing.assert_array_almost_equal(2. / 7, g5.D[0, 0]) - g6 = g1.append(g2) - np.testing.assert_array_equal(np.diag([2, 3]), g6.D) - - def test_matrix_static_gain(self): - """Regression: can we create matrix static gains?""" - d1 = np.array([[1, 2, 3], [4, 5, 6]]) - d2 = np.array([[7, 8], [9, 10], [11, 12]]) - g1 = StateSpace([], [], [], d1) - - # _remove_useless_states was making A = [[0]] - self.assertEqual((0, 0), g1.A.shape) - - g2 = StateSpace([], [], [], d2) - g3 = StateSpace([], [], [], d2.T) - - h1 = g1 * g2 - np.testing.assert_array_equal(np.dot(d1, d2), h1.D) - h2 = g1 + g3 - np.testing.assert_array_equal(d1 + d2.T, h2.D) - h3 = g1.feedback(g2) - np.testing.assert_array_almost_equal( - solve(np.eye(2) + np.dot(d1, d2), d1), h3.D) - h4 = g1.append(g2) - np.testing.assert_array_equal(block_diag(d1, d2), h4.D) - - def test_remove_useless_states(self): - """Regression: _remove_useless_states gives correct ABC sizes.""" - g1 = StateSpace(np.zeros((3, 3)), - np.zeros((3, 4)), - np.zeros((5, 3)), - np.zeros((5, 4))) - self.assertEqual((0, 0), g1.A.shape) - self.assertEqual((0, 4), g1.B.shape) - self.assertEqual((5, 0), g1.C.shape) - self.assertEqual((5, 4), g1.D.shape) - self.assertEqual(0, g1.states) - - def test_bad_empty_matrices(self): - """Mismatched ABCD matrices when some are empty.""" - self.assertRaises(ValueError, StateSpace, [1], [], [], [1]) - self.assertRaises(ValueError, StateSpace, [1], [1], [], [1]) - self.assertRaises(ValueError, StateSpace, [1], [], [1], [1]) - self.assertRaises(ValueError, StateSpace, [], [1], [], [1]) - self.assertRaises(ValueError, StateSpace, [], [1], [1], [1]) - self.assertRaises(ValueError, StateSpace, [], [], [1], [1]) - self.assertRaises(ValueError, StateSpace, [1], [1], [1], []) - - def test_minreal_static_gain(self): - """Regression: minreal on static gain was failing.""" - g1 = StateSpace([], [], [], [1]) - g2 = g1.minreal() - np.testing.assert_array_equal(g1.A, g2.A) - np.testing.assert_array_equal(g1.B, g2.B) - np.testing.assert_array_equal(g1.C, g2.C) - np.testing.assert_array_equal(g1.D, g2.D) - - def test_empty(self): - """Regression: can we create an empty StateSpace object?""" - g1 = StateSpace([], [], [], []) - self.assertEqual(0, g1.states) - self.assertEqual(0, g1.inputs) - self.assertEqual(0, g1.outputs) - - def test_matrix_to_state_space(self): - """_convertToStateSpace(matrix) gives ss([],[],[],D)""" - D = np.array([[1, 2, 3], [4, 5, 6]]) - g = _convertToStateSpace(D) - - def empty(shape): - m = np.array([]) - m.shape = shape - return m - np.testing.assert_array_equal(empty((0, 0)), g.A) - np.testing.assert_array_equal(empty((0, D.shape[1])), g.B) - np.testing.assert_array_equal(empty((D.shape[0], 0)), g.C) - np.testing.assert_array_equal(D, g.D) - - def test_lft(self): - """ test lft function with result obtained from matlab implementation""" - # test case - A = [[1, 2, 3], - [1, 4, 5], - [2, 3, 4]] - B = [[0, 2], - [5, 6], - [5, 2]] - C = [[1, 4, 5], - [2, 3, 0]] - D = [[0, 0], - [3, 0]] - P = StateSpace(A, B, C, D) - Ak = [[0, 2, 3], - [2, 3, 5], - [2, 1, 9]] - Bk = [[1, 1], - [2, 3], - [9, 4]] - Ck = [[1, 4, 5], - [2, 3, 6]] - Dk = [[0, 2], - [0, 0]] - K = StateSpace(Ak, Bk, Ck, Dk) - - # case 1 - pk = P.lft(K, 2, 1) - Amatlab = [1, 2, 3, 4, 6, 12, 1, 4, 5, 17, 38, 61, 2, 3, 4, 9, 26, 37, 2, 3, 0, 3, 14, 18, 4, 6, 0, 8, 27, 35, 18, 27, 0, 29, 109, 144] - Bmatlab = [0, 10, 10, 7, 15, 58] - Cmatlab = [1, 4, 5, 0, 0, 0] - Dmatlab = [0] - np.testing.assert_allclose(np.array(pk.A).reshape(-1), Amatlab) - np.testing.assert_allclose(np.array(pk.B).reshape(-1), Bmatlab) - np.testing.assert_allclose(np.array(pk.C).reshape(-1), Cmatlab) - np.testing.assert_allclose(np.array(pk.D).reshape(-1), Dmatlab) - - # case 2 - pk = P.lft(K) - Amatlab = [1, 2, 3, 4, 6, 12, -3, -2, 5, 11, 14, 31, -2, -3, 4, 3, 2, 7, 0.6, 3.4, 5, -0.6, -0.4, 0, 0.8, 6.2, 10, 0.2, -4.2, -4, 7.4, 33.6, 45, -0.4, -8.6, -3] - Bmatlab = [] - Cmatlab = [] - Dmatlab = [] - np.testing.assert_allclose(np.array(pk.A).reshape(-1), Amatlab) - np.testing.assert_allclose(np.array(pk.B).reshape(-1), Bmatlab) - np.testing.assert_allclose(np.array(pk.C).reshape(-1), Cmatlab) - np.testing.assert_allclose(np.array(pk.D).reshape(-1), Dmatlab) - - def test_horner(self): - """Test horner() function""" - # Make sure we can compute the transfer function at a complex value - self.sys322.horner(1.+1.j) - - # Make sure result agrees with frequency response - mag, phase, omega = self.sys322.freqresp([1]) - np.testing.assert_array_almost_equal( - self.sys322.horner(1.j), - mag[:,:,0] * np.exp(1.j * phase[:,:,0])) - - def tearDown(self): - reset_defaults() # reset configuration defaults - - -class TestRss(unittest.TestCase): - """These are tests for the proper functionality of statesp.rss.""" - - def setUp(self): - use_numpy_matrix(False) - - # Number of times to run each of the randomized tests. - self.numTests = 100 - # Maxmimum number of states to test + 1 - self.maxStates = 10 - # Maximum number of inputs and outputs to test + 1 - self.maxIO = 5 - - def test_shape(self): - """Test that rss outputs have the right state, input, and output size.""" - - for states in range(1, self.maxStates): - for inputs in range(1, self.maxIO): - for outputs in range(1, self.maxIO): - sys = matlab.rss(states, outputs, inputs) - self.assertEqual(sys.states, states) - self.assertEqual(sys.inputs, inputs) - self.assertEqual(sys.outputs, outputs) - - def test_pole(self): - """Test that the poles of rss outputs have a negative real part.""" - - for states in range(1, self.maxStates): - for inputs in range(1, self.maxIO): - for outputs in range(1, self.maxIO): - sys = matlab.rss(states, outputs, inputs) - p = sys.pole() - for z in p: - self.assertTrue(z.real < 0) - - def tearDown(self): - reset_defaults() # reset configuration defaults - - -class TestDrss(unittest.TestCase): - """These are tests for the proper functionality of statesp.drss.""" - - def setUp(self): - use_numpy_matrix(False) - - # Number of times to run each of the randomized tests. - self.numTests = 100 - # Maximum number of states to test + 1 - self.maxStates = 10 - # Maximum number of inputs and outputs to test + 1 - self.maxIO = 5 - - def test_shape(self): - """Test that drss outputs have the right state, input, and output size.""" - - for states in range(1, self.maxStates): - for inputs in range(1, self.maxIO): - for outputs in range(1, self.maxIO): - sys = matlab.drss(states, outputs, inputs) - self.assertEqual(sys.states, states) - self.assertEqual(sys.inputs, inputs) - self.assertEqual(sys.outputs, outputs) - - def test_pole(self): - """Test that the poles of drss outputs have less than unit magnitude.""" - - for states in range(1, self.maxStates): - for inputs in range(1, self.maxIO): - for outputs in range(1, self.maxIO): - sys = matlab.drss(states, outputs, inputs) - p = sys.pole() - for z in p: - self.assertTrue(abs(z) < 1) - - def test_pole_static(self): - """Regression: pole() of static gain is empty array.""" - np.testing.assert_array_equal(np.array([]), - StateSpace([], [], [], [[1]]).pole()) - - def test_copy_constructor(self): - # Create a set of matrices for a simple linear system - A = np.array([[-1]]) - B = np.array([[1]]) - C = np.array([[1]]) - D = np.array([[0]]) - - # Create the first linear system and a copy - linsys = StateSpace(A, B, C, D) - cpysys = StateSpace(linsys) - - # Change the original A matrix - A[0, 0] = -2 - np.testing.assert_array_equal(linsys.A, [[-1]]) # original value - np.testing.assert_array_equal(cpysys.A, [[-1]]) # original value - - # Change the A matrix for the original system - linsys.A[0, 0] = -3 - np.testing.assert_array_equal(cpysys.A, [[-1]]) # original value - - def tearDown(self): - reset_defaults() # reset configuration defaults - - -if __name__ == "__main__": - unittest.main() diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index 34a17f992..67cf950e7 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -1,26 +1,36 @@ -#!/usr/bin/env python -# -# statesp_test.py - test state space class -# RMM, 30 Mar 2011 (based on TestStateSp from v0.4a) +"""statesp_test.py - test state space class + +RMM, 30 Mar 2011 based on TestStateSp from v0.4a) +RMM, 14 Jun 2019 statesp_array_test.py coverted from statesp_test.py to test + with use_numpy_matrix(False) +BG, 26 Jul 2020 merge statesp_array_test.py differences into statesp_test.py + convert to pytest +""" -import unittest import numpy as np +from numpy.testing import assert_array_almost_equal +import pytest +import operator from numpy.linalg import solve -from scipy.linalg import eigvals, block_diag -from control import matlab -from control.statesp import StateSpace, _convertToStateSpace, tf2ss -from control.xferfcn import TransferFunction, ss2tf +from scipy.linalg import block_diag, eigvals + +import control as ct +from control.config import defaults +from control.dtime import sample_system from control.lti import evalfr -from control.exception import slycot_check +from control.statesp import (StateSpace, _convert_to_statespace, drss, + rss, ss, tf2ss, _statesp_defaults) +from control.tests.conftest import ismatarrayout, slycotonly +from control.xferfcn import TransferFunction, ss2tf +from .conftest import editsdefaults -class TestStateSpace(unittest.TestCase): +class TestStateSpace: """Tests for the StateSpace class.""" - def setUp(self): - """Set up a MIMO system to test operations on.""" - - # sys1: 3-states square system (2 inputs x 2 outputs) + @pytest.fixture + def sys322ABCD(self): + """Matrices for sys322""" A322 = [[-3., 4., 2.], [-1., -3., 0.], [2., 5., 3.]] @@ -31,9 +41,27 @@ def setUp(self): [1., 4., 3.]] D322 = [[-2., 4.], [0., 1.]] - self.sys322 = StateSpace(A322, B322, C322, D322) + return (A322, B322, C322, D322) - # sys1: 2-states square system (2 inputs x 2 outputs) + @pytest.fixture + def sys322(self, sys322ABCD): + """3-states square system (2 inputs x 2 outputs)""" + return StateSpace(*sys322ABCD) + + @pytest.fixture + def sys121(self): + """2 state, 1 input, 1 output (siso) system""" + A121 = [[4., 1.], + [2., -3]] + B121 = [[5.], + [-3.]] + C121 = [[2., -4]] + D121 = [[3.]] + return StateSpace(A121, B121, C121, D121) + + @pytest.fixture + def sys222(self): + """2-states square system (2 inputs x 2 outputs)""" A222 = [[4., 1.], [2., -3]] B222 = [[5., 2.], @@ -42,9 +70,11 @@ def setUp(self): [0., 1.]] D222 = [[3., 2.], [1., -1.]] - self.sys222 = StateSpace(A222, B222, C222, D222) + return StateSpace(A222, B222, C222, D222) - # sys3: 6 states non square system (2 inputs x 3 outputs) + @pytest.fixture + def sys623(self): + """sys3: 6 states non square system (2 inputs x 3 outputs)""" A623 = np.array([[1, 0, 0, 0, 0, 0], [0, 1, 0, 0, 0, 0], [0, 0, 3, 0, 0, 0], @@ -61,16 +91,126 @@ def setUp(self): [0, 1, 0, 1, 0, 1], [0, 0, 1, 0, 0, 1]]) D623 = np.zeros((3, 2)) - self.sys623 = StateSpace(A623, B623, C623, D623) + return StateSpace(A623, B623, C623, D623) + + @pytest.mark.parametrize( + "dt", + [(), (None, ), (0, ), (1, ), (0.1, ), (True, )], + ids=lambda i: "dt " + ("unspec" if len(i) == 0 else str(i[0]))) + @pytest.mark.parametrize( + "argfun", + [pytest.param( + lambda ABCDdt: (ABCDdt, {}), + id="A, B, C, D[, dt]"), + pytest.param( + lambda ABCDdt: (ABCDdt[:4], {'dt': dt_ for dt_ in ABCDdt[4:]}), + id="A, B, C, D[, dt=dt]"), + pytest.param( + lambda ABCDdt: ((StateSpace(*ABCDdt), ), {}), + id="sys") + ]) + def test_constructor(self, sys322ABCD, dt, argfun): + """Test different ways to call the StateSpace() constructor""" + args, kwargs = argfun(sys322ABCD + dt) + sys = StateSpace(*args, **kwargs) + + dtref = defaults['control.default_dt'] if len(dt) == 0 else dt[0] + np.testing.assert_almost_equal(sys.A, sys322ABCD[0]) + np.testing.assert_almost_equal(sys.B, sys322ABCD[1]) + np.testing.assert_almost_equal(sys.C, sys322ABCD[2]) + np.testing.assert_almost_equal(sys.D, sys322ABCD[3]) + assert sys.dt == dtref + + @pytest.mark.parametrize("args, exc, errmsg", + [((True, ), TypeError, + "(can only take in|sys must be) a StateSpace"), + ((1, 2), ValueError, "1, 4, or 5 arguments"), + ((np.ones((3, 2)), np.ones((3, 2)), + np.ones((2, 2)), np.ones((2, 2))), + ValueError, "A must be square"), + ((np.ones((3, 3)), np.ones((2, 2)), + np.ones((2, 3)), np.ones((2, 2))), + ValueError, "A and B"), + ((np.ones((3, 3)), np.ones((3, 2)), + np.ones((2, 2)), np.ones((2, 2))), + ValueError, "A and C"), + ((np.ones((3, 3)), np.ones((3, 2)), + np.ones((2, 3)), np.ones((2, 3))), + ValueError, "B and D"), + ((np.ones((3, 3)), np.ones((3, 2)), + np.ones((2, 3)), np.ones((3, 2))), + ValueError, "C and D"), + ]) + def test_constructor_invalid(self, args, exc, errmsg): + """Test invalid input to StateSpace() constructor""" + with pytest.raises(exc, match=errmsg): + StateSpace(*args) + with pytest.raises(exc, match=errmsg): + ss(*args) + + def test_constructor_warns(self, sys322ABCD): + """Test ambiguos input to StateSpace() constructor""" + with pytest.warns(UserWarning, match="received multiple dt"): + sys = StateSpace(*(sys322ABCD + (0.1, )), dt=0.2) + np.testing.assert_almost_equal(sys.A, sys322ABCD[0]) + np.testing.assert_almost_equal(sys.B, sys322ABCD[1]) + np.testing.assert_almost_equal(sys.C, sys322ABCD[2]) + np.testing.assert_almost_equal(sys.D, sys322ABCD[3]) + assert sys.dt == 0.1 - def test_D_broadcast(self): + def test_copy_constructor(self): + """Test the copy constructor""" + # Create a set of matrices for a simple linear system + A = np.array([[-1]]) + B = np.array([[1]]) + C = np.array([[1]]) + D = np.array([[0]]) + + # Create the first linear system and a copy + linsys = StateSpace(A, B, C, D) + cpysys = StateSpace(linsys) + + # Change the original A matrix + A[0, 0] = -2 + np.testing.assert_array_equal(linsys.A, [[-1]]) # original value + np.testing.assert_array_equal(cpysys.A, [[-1]]) # original value + + # Change the A matrix for the original system + linsys.A[0, 0] = -3 + np.testing.assert_array_equal(cpysys.A, [[-1]]) # original value + + def test_copy_constructor_nodt(self, sys322): + """Test the copy constructor when an object without dt is passed""" + sysin = sample_system(sys322, 1.) + del sysin.dt + sys = StateSpace(sysin) + assert sys.dt == defaults['control.default_dt'] + + # test for static gain + sysin = StateSpace([], [], [], [[1, 2], [3, 4]], 1.) + del sysin.dt + sys = StateSpace(sysin) + assert sys.dt is None + + def test_matlab_style_constructor(self): + """Use (deprecated) matrix-style construction string""" + with pytest.deprecated_call(): + sys = StateSpace("-1 1; 0 2", "0; 1", "1, 0", "0") + assert sys.A.shape == (2, 2) + assert sys.B.shape == (2, 1) + assert sys.C.shape == (1, 2) + assert sys.D.shape == (1, 1) + for X in [sys.A, sys.B, sys.C, sys.D]: + assert ismatarrayout(X) + + def test_D_broadcast(self, sys623): """Test broadcast of D=0 to the right shape""" # Giving D as a scalar 0 should broadcast to the right shape - sys = StateSpace(self.sys623.A, self.sys623.B, self.sys623.C, 0) - np.testing.assert_array_equal(self.sys623.D, sys.D) + sys = StateSpace(sys623.A, sys623.B, sys623.C, 0) + np.testing.assert_array_equal(sys623.D, sys.D) # Giving D as a matrix of the wrong size should generate an error - with self.assertRaises(ValueError): + with pytest.raises(ValueError): sys = StateSpace(sys.A, sys.B, sys.C, np.array([[0]])) # Make sure that empty systems still work @@ -86,10 +226,10 @@ def test_D_broadcast(self): sys = StateSpace([], [], [], 0) np.testing.assert_array_equal(sys.D, [[0]]) - def test_pole(self): + def test_pole(self, sys322): """Evaluate the poles of a MIMO system.""" - p = np.sort(self.sys322.pole()) + p = np.sort(sys322.pole()) true_p = np.sort([3.34747678408874, -3.17373839204437 + 1.47492908003839j, -3.17373839204437 - 1.47492908003839j]) @@ -98,15 +238,15 @@ def test_pole(self): def test_zero_empty(self): """Test to make sure zero() works with no zeros in system.""" - sys = _convertToStateSpace(TransferFunction([1], [1, 2, 1])) + sys = _convert_to_statespace(TransferFunction([1], [1, 2, 1])) np.testing.assert_array_equal(sys.zero(), np.array([])) - @unittest.skipIf(not slycot_check(), "slycot not installed") - def test_zero_siso(self): + @slycotonly + def test_zero_siso(self, sys222): """Evaluate the zeros of a SISO system.""" # extract only first input / first output system of sys222. This system is denoted sys111 # or tf111 - tf111 = ss2tf(self.sys222) + tf111 = ss2tf(sys222) sys111 = tf2ss(tf111[0, 0]) # compute zeros as root of the characteristic polynomial at the numerator of tf111 @@ -117,31 +257,31 @@ def test_zero_siso(self): np.testing.assert_almost_equal(true_z, z) - @unittest.skipIf(not slycot_check(), "slycot not installed") - def test_zero_mimo_sys322_square(self): + @slycotonly + def test_zero_mimo_sys322_square(self, sys322): """Evaluate the zeros of a square MIMO system.""" - z = np.sort(self.sys322.zero()) + z = np.sort(sys322.zero()) true_z = np.sort([44.41465, -0.490252, -5.924398]) np.testing.assert_array_almost_equal(z, true_z) - @unittest.skipIf(not slycot_check(), "slycot not installed") - def test_zero_mimo_sys222_square(self): + @slycotonly + def test_zero_mimo_sys222_square(self, sys222): """Evaluate the zeros of a square MIMO system.""" - z = np.sort(self.sys222.zero()) + z = np.sort(sys222.zero()) true_z = np.sort([-10.568501, 3.368501]) np.testing.assert_array_almost_equal(z, true_z) - @unittest.skipIf(not slycot_check(), "slycot not installed") - def test_zero_mimo_sys623_non_square(self): + @slycotonly + def test_zero_mimo_sys623_non_square(self, sys623): """Evaluate the zeros of a non square MIMO system.""" - z = np.sort(self.sys623.zero()) + z = np.sort(sys623.zero()) true_z = np.sort([2., -1.]) np.testing.assert_array_almost_equal(z, true_z) - def test_add_ss(self): + def test_add_ss(self, sys222, sys322): """Add two MIMO systems.""" A = [[-3., 4., 2., 0., 0.], [-1., -3., 0., 0., 0.], @@ -150,14 +290,14 @@ def test_add_ss(self): C = [[4., 2., -3., 2., -4.], [1., 4., 3., 0., 1.]] D = [[1., 6.], [1., 0.]] - sys = self.sys322 + self.sys222 + sys = sys322 + sys222 np.testing.assert_array_almost_equal(sys.A, A) np.testing.assert_array_almost_equal(sys.B, B) np.testing.assert_array_almost_equal(sys.C, C) np.testing.assert_array_almost_equal(sys.D, D) - def test_subtract_ss(self): + def test_subtract_ss(self, sys222, sys322): """Subtract two MIMO systems.""" A = [[-3., 4., 2., 0., 0.], [-1., -3., 0., 0., 0.], @@ -166,14 +306,14 @@ def test_subtract_ss(self): C = [[4., 2., -3., -2., 4.], [1., 4., 3., 0., -1.]] D = [[-5., 2.], [-1., 2.]] - sys = self.sys322 - self.sys222 + sys = sys322 - sys222 np.testing.assert_array_almost_equal(sys.A, A) np.testing.assert_array_almost_equal(sys.B, B) np.testing.assert_array_almost_equal(sys.C, C) np.testing.assert_array_almost_equal(sys.D, D) - def test_multiply_ss(self): + def test_multiply_ss(self, sys222, sys322): """Multiply two MIMO systems.""" A = [[4., 1., 0., 0., 0.], [2., -3., 0., 0., 0.], [2., 0., -3., 4., 2.], @@ -182,44 +322,49 @@ def test_multiply_ss(self): C = [[-4., 12., 4., 2., -3.], [0., 1., 1., 4., 3.]] D = [[-2., -8.], [1., -1.]] - sys = self.sys322 * self.sys222 + sys = sys322 * sys222 np.testing.assert_array_almost_equal(sys.A, A) np.testing.assert_array_almost_equal(sys.B, B) np.testing.assert_array_almost_equal(sys.C, C) np.testing.assert_array_almost_equal(sys.D, D) - def test_evalfr(self): - """Evaluate the frequency response at one frequency.""" - + @pytest.mark.parametrize("omega, resp", + [(1., + np.array([[ 4.37636761e-05-0.01522976j, + -7.92603939e-01+0.02617068j], + [-3.31544858e-01+0.0576105j, + 1.28919037e-01-0.14382495j]])), + (32, + np.array([[-1.16548243e-05-3.13444825e-04j, + -7.99936828e-01+4.54201816e-06j], + [-3.00137118e-01+3.42881660e-03j, + 6.32015038e-04-1.21462255e-02j]]))]) + @pytest.mark.parametrize("dt", [None, 0, 1e-3]) + def test_call(self, dt, omega, resp): + """Evaluate the frequency response at single frequencies""" A = [[-2, 0.5], [0.5, -0.3]] B = [[0.3, -1.3], [0.1, 0.]] C = [[0., 0.1], [-0.3, -0.2]] D = [[0., -0.8], [-0.3, 0.]] sys = StateSpace(A, B, C, D) - resp = [[4.37636761487965e-05 - 0.0152297592997812j, - -0.792603938730853 + 0.0261706783369803j], - [-0.331544857768052 + 0.0576105032822757j, - 0.128919037199125 - 0.143824945295405j]] + if dt: + sys = sample_system(sys, dt) + s = np.exp(omega * 1j * dt) + else: + s = omega * 1j # Correct versions of the call - np.testing.assert_almost_equal(evalfr(sys, 1j), resp) - np.testing.assert_almost_equal(sys._evalfr(1.), resp) - - # Deprecated version of the call (should generate warning) - import warnings - with warnings.catch_warnings(record=True) as w: - # Set up warnings filter to only show warnings in control module - warnings.filterwarnings("ignore") - warnings.filterwarnings("always", module="control") - - # Make sure that we get a pending deprecation warning - sys.evalfr(1.) - assert len(w) == 1 - assert issubclass(w[-1].category, PendingDeprecationWarning) - - @unittest.skipIf(not slycot_check(), "slycot not installed") + np.testing.assert_allclose(evalfr(sys, s), resp, atol=1e-3) + np.testing.assert_allclose(sys(s), resp, atol=1e-3) + + # Deprecated name of the call (should generate error) + with pytest.raises(AttributeError): + sys.evalfr(omega) + + + @slycotonly def test_freq_resp(self): """Evaluate the frequency response at multiple frequencies.""" @@ -239,13 +384,37 @@ def test_freq_resp(self): [-0.438157380501337, -1.40720969147217]]] true_omega = [0.1, 10.] - mag, phase, omega = sys.freqresp(true_omega) + mag, phase, omega = sys.frequency_response(true_omega) np.testing.assert_almost_equal(mag, true_mag) np.testing.assert_almost_equal(phase, true_phase) np.testing.assert_equal(omega, true_omega) - @unittest.skipIf(not slycot_check(), "slycot not installed") + # Deprecated version of the call (should return warning) + with pytest.warns(DeprecationWarning, match="will be removed"): + mag, phase, omega = sys.freqresp(true_omega) + np.testing.assert_almost_equal(mag, true_mag) + + def test__isstatic(self): + A0 = np.zeros((2,2)) + A1 = A0.copy() + A1[0,1] = 1.1 + B0 = np.zeros((2,1)) + B1 = B0.copy() + B1[0,0] = 1.3 + C0 = A0 + C1 = np.eye(2) + D0 = 0 + D1 = np.ones((2,1)) + assert StateSpace(A0, B0, C1, D1)._isstatic() + assert not StateSpace(A1, B0, C1, D1)._isstatic() + assert not StateSpace(A0, B1, C1, D1)._isstatic() + assert not StateSpace(A1, B1, C1, D1)._isstatic() + assert StateSpace(A0, B0, C0, D0)._isstatic() + assert StateSpace(A0, B0, C0, D1)._isstatic() + assert StateSpace(A0, B0, C1, D0)._isstatic() + + @slycotonly def test_minreal(self): """Test a minreal model reduction.""" # A = [-2, 0.5, 0; 0.5, -0.3, 0; 0, 0, -0.1] @@ -260,9 +429,9 @@ def test_minreal(self): sys = StateSpace(A, B, C, D) sysr = sys.minreal() - self.assertEqual(sysr.states, 2) - self.assertEqual(sysr.inputs, sys.inputs) - self.assertEqual(sysr.outputs, sys.outputs) + assert sysr.nstates == 2 + assert sysr.ninputs == sys.ninputs + assert sysr.noutputs == sys.noutputs np.testing.assert_array_almost_equal( eigvals(sysr.A), [-2.136154, -0.1638459]) @@ -299,7 +468,7 @@ def test_append_tf(self): s = TransferFunction([1, 0], [1]) h = 1 / (s + 1) / (s + 2) sys1 = StateSpace(A1, B1, C1, D1) - sys2 = _convertToStateSpace(h) + sys2 = _convert_to_statespace(h) sys3c = sys1.append(sys2) np.testing.assert_array_almost_equal(sys1.A, sys3c.A[:3, :3]) np.testing.assert_array_almost_equal(sys1.B, sys3c.B[:3, :2]) @@ -334,14 +503,14 @@ def test_array_access_ss(self): def test_dc_gain_cont(self): """Test DC gain for continuous-time state-space systems.""" sys = StateSpace(-2., 6., 5., 0) - np.testing.assert_equal(sys.dcgain(), 15.) + np.testing.assert_allclose(sys.dcgain(), 15.) sys2 = StateSpace(-2, [6., 4.], [[5.], [7.], [11]], np.zeros((3, 2))) expected = np.array([[15., 10.], [21., 14.], [33., 22.]]) - np.testing.assert_array_equal(sys2.dcgain(), expected) + np.testing.assert_allclose(sys2.dcgain(), expected) sys3 = StateSpace(0., 1., 1., 0.) - np.testing.assert_equal(sys3.dcgain(), np.nan) + np.testing.assert_equal(sys3.dcgain(), np.inf) def test_dc_gain_discr(self): """Test DC gain for discrete-time state-space systems.""" @@ -351,7 +520,7 @@ def test_dc_gain_discr(self): # averaging filter sys = StateSpace(0.5, 0.5, 1, 0, True) - np.testing.assert_almost_equal(sys.dcgain(), 1) + np.testing.assert_allclose(sys.dcgain(), 1) # differencer sys = StateSpace(0, 1, -1, 1, True) @@ -359,87 +528,103 @@ def test_dc_gain_discr(self): # summer sys = StateSpace(1, 1, 1, 0, True) - np.testing.assert_equal(sys.dcgain(), np.nan) - - def test_dc_gain_integrator(self): - """DC gain when eigenvalue at DC returns appropriately sized array of nan.""" - # the SISO case is also tested in test_dc_gain_{cont,discr} - import itertools - # iterate over input and output sizes, and continuous (dt=None) and discrete (dt=True) time - for inputs, outputs, dt in itertools.product(range(1, 6), range(1, 6), [None, True]): - states = max(inputs, outputs) - - # a matrix that is singular at DC, and has no "useless" states as in - # _remove_useless_states - a = np.triu(np.tile(2, (states, states))) - # eigenvalues all +2, except for ... - a[0, 0] = 0 if dt is None else 1 - b = np.eye(max(inputs, states))[:states, :inputs] - c = np.eye(max(outputs, states))[:outputs, :states] - d = np.zeros((outputs, inputs)) - sys = StateSpace(a, b, c, d, dt) - dc = np.squeeze(np.tile(np.nan, (outputs, inputs))) + np.testing.assert_equal(sys.dcgain(), np.inf) + + @pytest.mark.parametrize("outputs", range(1, 6)) + @pytest.mark.parametrize("inputs", range(1, 6)) + @pytest.mark.parametrize("dt", [None, 0, 1, True], + ids=["dtNone", "c", "dt1", "dtTrue"]) + def test_dc_gain_integrator(self, outputs, inputs, dt): + """DC gain w/ pole at origin returns appropriately sized array of inf. + + the SISO case is also tested in test_dc_gain_{cont,discr} + time systems (dt=0) + """ + states = max(inputs, outputs) + + # a matrix that is singular at DC, and has no "useless" states as in + # _remove_useless_states + a = np.triu(np.tile(2, (states, states))) + # eigenvalues all +2, except for ... + a[0, 0] = 0 if dt in [0, None] else 1 + b = np.eye(max(inputs, states))[:states, :inputs] + c = np.eye(max(outputs, states))[:outputs, :states] + d = np.zeros((outputs, inputs)) + sys = StateSpace(a, b, c, d, dt) + dc = np.full_like(d, np.inf, dtype=float) + if sys.issiso(): + dc = dc.squeeze() + + try: np.testing.assert_array_equal(dc, sys.dcgain()) + except NotImplementedError: + # Skip MIMO tests if there is no slycot + pytest.skip("slycot required for MIMO dcgain") def test_scalar_static_gain(self): - """Regression: can we create a scalar static gain?""" + """Regression: can we create a scalar static gain? + + make sure StateSpace internals, specifically ABC matrix + sizes, are OK for LTI operations + """ g1 = StateSpace([], [], [], [2]) g2 = StateSpace([], [], [], [3]) - # make sure StateSpace internals, specifically ABC matrix - # sizes, are OK for LTI operations g3 = g1 * g2 - self.assertEqual(6, g3.D[0, 0]) + assert 6 == g3.D[0, 0] g4 = g1 + g2 - self.assertEqual(5, g4.D[0, 0]) + assert 5 == g4.D[0, 0] g5 = g1.feedback(g2) - self.assertAlmostEqual(2. / 7, g5.D[0, 0]) + np.testing.assert_allclose(2. / 7, g5.D[0, 0]) g6 = g1.append(g2) - np.testing.assert_array_equal(np.diag([2, 3]), g6.D) + np.testing.assert_allclose(np.diag([2, 3]), g6.D) def test_matrix_static_gain(self): """Regression: can we create matrix static gains?""" - d1 = np.matrix([[1, 2, 3], [4, 5, 6]]) - d2 = np.matrix([[7, 8], [9, 10], [11, 12]]) + d1 = np.array([[1, 2, 3], [4, 5, 6]]) + d2 = np.array([[7, 8], [9, 10], [11, 12]]) g1 = StateSpace([], [], [], d1) # _remove_useless_states was making A = [[0]] - self.assertEqual((0, 0), g1.A.shape) + assert (0, 0) == g1.A.shape g2 = StateSpace([], [], [], d2) g3 = StateSpace([], [], [], d2.T) h1 = g1 * g2 - np.testing.assert_array_equal(d1 * d2, h1.D) + np.testing.assert_array_equal(np.dot(d1, d2), h1.D) h2 = g1 + g3 np.testing.assert_array_equal(d1 + d2.T, h2.D) h3 = g1.feedback(g2) np.testing.assert_array_almost_equal( - solve(np.eye(2) + d1 * d2, d1), h3.D) + solve(np.eye(2) + np.dot(d1, d2), d1), h3.D) h4 = g1.append(g2) np.testing.assert_array_equal(block_diag(d1, d2), h4.D) def test_remove_useless_states(self): """Regression: _remove_useless_states gives correct ABC sizes.""" - g1 = StateSpace(np.zeros((3, 3)), - np.zeros((3, 4)), - np.zeros((5, 3)), - np.zeros((5, 4))) - self.assertEqual((0, 0), g1.A.shape) - self.assertEqual((0, 4), g1.B.shape) - self.assertEqual((5, 0), g1.C.shape) - self.assertEqual((5, 4), g1.D.shape) - self.assertEqual(0, g1.states) - - def test_bad_empty_matrices(self): + g1 = StateSpace(np.zeros((3, 3)), np.zeros((3, 4)), + np.zeros((5, 3)), np.zeros((5, 4)), + remove_useless_states=True) + assert (0, 0) == g1.A.shape + assert (0, 4) == g1.B.shape + assert (5, 0) == g1.C.shape + assert (5, 4) == g1.D.shape + assert 0 == g1.nstates + + @pytest.mark.parametrize("A, B, C, D", + [([1], [], [], [1]), + ([1], [1], [], [1]), + ([1], [], [1], [1]), + ([], [1], [], [1]), + ([], [1], [1], [1]), + ([], [], [1], [1]), + ([1], [1], [1], [])]) + def test_bad_empty_matrices(self, A, B, C, D): """Mismatched ABCD matrices when some are empty.""" - self.assertRaises(ValueError, StateSpace, [1], [], [], [1]) - self.assertRaises(ValueError, StateSpace, [1], [1], [], [1]) - self.assertRaises(ValueError, StateSpace, [1], [], [1], [1]) - self.assertRaises(ValueError, StateSpace, [], [1], [], [1]) - self.assertRaises(ValueError, StateSpace, [], [1], [1], [1]) - self.assertRaises(ValueError, StateSpace, [], [], [1], [1]) - self.assertRaises(ValueError, StateSpace, [1], [1], [1], []) + with pytest.raises(ValueError): + StateSpace(A, B, C, D) + def test_minreal_static_gain(self): """Regression: minreal on static gain was failing.""" @@ -453,22 +638,19 @@ def test_minreal_static_gain(self): def test_empty(self): """Regression: can we create an empty StateSpace object?""" g1 = StateSpace([], [], [], []) - self.assertEqual(0, g1.states) - self.assertEqual(0, g1.inputs) - self.assertEqual(0, g1.outputs) + assert 0 == g1.nstates + assert 0 == g1.ninputs + assert 0 == g1.noutputs def test_matrix_to_state_space(self): - """_convertToStateSpace(matrix) gives ss([],[],[],D)""" - D = np.matrix([[1, 2, 3], [4, 5, 6]]) - g = _convertToStateSpace(D) - - def empty(shape): - m = np.matrix([]) - m.shape = shape - return m - np.testing.assert_array_equal(empty((0, 0)), g.A) - np.testing.assert_array_equal(empty((0, D.shape[1])), g.B) - np.testing.assert_array_equal(empty((D.shape[0], 0)), g.C) + """_convert_to_statespace(matrix) gives ss([],[],[],D)""" + with pytest.deprecated_call(): + D = np.matrix([[1, 2, 3], [4, 5, 6]]) + g = _convert_to_statespace(D) + + np.testing.assert_array_equal(np.empty((0, 0)), g.A) + np.testing.assert_array_equal(np.empty((0, D.shape[1])), g.B) + np.testing.assert_array_equal(np.empty((D.shape[0], 0)), g.C) np.testing.assert_array_equal(D, g.D) def test_lft(self): @@ -499,7 +681,9 @@ def test_lft(self): # case 1 pk = P.lft(K, 2, 1) - Amatlab = [1, 2, 3, 4, 6, 12, 1, 4, 5, 17, 38, 61, 2, 3, 4, 9, 26, 37, 2, 3, 0, 3, 14, 18, 4, 6, 0, 8, 27, 35, 18, 27, 0, 29, 109, 144] + Amatlab = [1, 2, 3, 4, 6, 12, 1, 4, 5, 17, 38, 61, 2, 3, 4, 9, 26, 37, + 2, 3, 0, 3, 14, 18, 4, 6, 0, 8, 27, 35, 18, 27, 0, 29, 109, + 144] Bmatlab = [0, 10, 10, 7, 15, 58] Cmatlab = [1, 4, 5, 0, 0, 0] Dmatlab = [0] @@ -510,7 +694,9 @@ def test_lft(self): # case 2 pk = P.lft(K) - Amatlab = [1, 2, 3, 4, 6, 12, -3, -2, 5, 11, 14, 31, -2, -3, 4, 3, 2, 7, 0.6, 3.4, 5, -0.6, -0.4, 0, 0.8, 6.2, 10, 0.2, -4.2, -4, 7.4, 33.6, 45, -0.4, -8.6, -3] + Amatlab = [1, 2, 3, 4, 6, 12, -3, -2, 5, 11, 14, 31, -2, -3, 4, 3, 2, + 7, 0.6, 3.4, 5, -0.6, -0.4, 0, 0.8, 6.2, 10, 0.2, -4.2, + -4, 7.4, 33.6, 45, -0.4, -8.6, -3] Bmatlab = [] Cmatlab = [] Dmatlab = [] @@ -519,28 +705,29 @@ def test_lft(self): np.testing.assert_allclose(np.array(pk.C).reshape(-1), Cmatlab) np.testing.assert_allclose(np.array(pk.D).reshape(-1), Dmatlab) - def test_repr(self): - ref322 = """StateSpace(array([[-3., 4., 2.], - [-1., -3., 0.], - [ 2., 5., 3.]]), array([[ 1., 4.], - [-3., -3.], - [-2., 1.]]), array([[ 4., 2., -3.], - [ 1., 4., 3.]]), array([[-2., 4.], - [ 0., 1.]]){dt})""" - self.assertEqual(repr(self.sys322), ref322.format(dt='')) - sysd = StateSpace(self.sys322.A, self.sys322.B, - self.sys322.C, self.sys322.D, 0.4) - self.assertEqual(repr(sysd), ref322.format(dt=", 0.4")) - array = np.array + def test_repr(self, sys322): + """Test string representation""" + ref322 = "\n".join(["StateSpace(array([[-3., 4., 2.],", + " [-1., -3., 0.],", + " [ 2., 5., 3.]]), array([[ 1., 4.],", + " [-3., -3.],", + " [-2., 1.]]), array([[ 4., 2., -3.],", + " [ 1., 4., 3.]]), array([[-2., 4.],", + " [ 0., 1.]]){dt})"]) + assert repr(sys322) == ref322.format(dt='') + sysd = StateSpace(sys322.A, sys322.B, + sys322.C, sys322.D, 0.4) + assert repr(sysd), ref322.format(dt=" == 0.4") + array = np.array # noqa sysd2 = eval(repr(sysd)) np.testing.assert_allclose(sysd.A, sysd2.A) np.testing.assert_allclose(sysd.B, sysd2.B) np.testing.assert_allclose(sysd.C, sysd2.C) np.testing.assert_allclose(sysd.D, sysd2.D) - def test_str(self): + def test_str(self, sys322): """Test that printing the system works""" - tsys = self.sys322 + tsys = sys322 tref = ("A = [[-3. 4. 2.]\n" " [-1. -3. 0.]\n" " [ 2. 5. 3.]]\n" @@ -560,118 +747,285 @@ def test_str(self): sysdt1 = StateSpace(tsys.A, tsys.B, tsys.C, tsys.D, 1.) assert str(sysdt1) == tref + "\ndt = 1.0\n" + def test_pole_static(self): + """Regression: pole() of static gain is empty array.""" + np.testing.assert_array_equal(np.array([]), + StateSpace([], [], [], [[1]]).pole()) + + def test_horner(self, sys322): + """Test horner() function""" + # Make sure we can compute the transfer function at a complex value + sys322.horner(1. + 1.j) -class TestRss(unittest.TestCase): + # Make sure result agrees with frequency response + mag, phase, omega = sys322.frequency_response([1]) + np.testing.assert_array_almost_equal( + np.squeeze(sys322.horner(1.j)), + mag[:, :, 0] * np.exp(1.j * phase[:, :, 0])) + + @pytest.mark.parametrize('x', + [[1, 1], [[1], [1]], np.atleast_2d([1,1]).T]) + @pytest.mark.parametrize('u', [0, 1, np.atleast_1d(2)]) + def test_dynamics_and_output_siso(self, x, u, sys121): + assert_array_almost_equal( + sys121.dynamics(0, x, u), + sys121.A.dot(x).reshape((-1,)) + sys121.B.dot(u).reshape((-1,))) + assert_array_almost_equal( + sys121.output(0, x, u), + sys121.C.dot(x).reshape((-1,)) + sys121.D.dot(u).reshape((-1,))) + assert_array_almost_equal( + sys121.dynamics(0, x), + sys121.A.dot(x).reshape((-1,))) + assert_array_almost_equal( + sys121.output(0, x), + sys121.C.dot(x).reshape((-1,))) + + # too few and too many states and inputs + @pytest.mark.parametrize('x', [0, 1, [], [1, 2, 3], np.atleast_1d(2)]) + def test_error_x_dynamics_and_output_siso(self, x, sys121): + with pytest.raises(ValueError): + sys121.dynamics(0, x) + with pytest.raises(ValueError): + sys121.output(0, x) + @pytest.mark.parametrize('u', [[1, 1], np.atleast_1d((2, 2))]) + def test_error_u_dynamics_output_siso(self, u, sys121): + with pytest.raises(ValueError): + sys121.dynamics(0, 1, u) + with pytest.raises(ValueError): + sys121.output(0, 1, u) + + @pytest.mark.parametrize('x', + [[1, 1], [[1], [1]], np.atleast_2d([1,1]).T]) + @pytest.mark.parametrize('u', + [[1, 1], [[1], [1]], np.atleast_2d([1,1]).T]) + def test_dynamics_and_output_mimo(self, x, u, sys222): + assert_array_almost_equal( + sys222.dynamics(0, x, u), + sys222.A.dot(x).reshape((-1,)) + sys222.B.dot(u).reshape((-1,))) + assert_array_almost_equal( + sys222.output(0, x, u), + sys222.C.dot(x).reshape((-1,)) + sys222.D.dot(u).reshape((-1,))) + assert_array_almost_equal( + sys222.dynamics(0, x), + sys222.A.dot(x).reshape((-1,))) + assert_array_almost_equal( + sys222.output(0, x), + sys222.C.dot(x).reshape((-1,))) + + # too few and too many states and inputs + @pytest.mark.parametrize('x', [0, 1, [1, 1, 1]]) + def test_error_x_dynamics_mimo(self, x, sys222): + with pytest.raises(ValueError): + sys222.dynamics(0, x) + with pytest.raises(ValueError): + sys222.output(0, x) + @pytest.mark.parametrize('u', [1, [1, 1, 1]]) + def test_error_u_dynamics_mimo(self, u, sys222): + with pytest.raises(ValueError): + sys222.dynamics(0, (1, 1), u) + with pytest.raises(ValueError): + sys222.output(0, (1, 1), u) + + +class TestRss: """These are tests for the proper functionality of statesp.rss.""" - def setUp(self): - # Number of times to run each of the randomized tests. - self.numTests = 100 - # Maxmimum number of states to test + 1 - self.maxStates = 10 - # Maximum number of inputs and outputs to test + 1 - self.maxIO = 5 + # Maxmimum number of states to test + 1 + maxStates = 10 + # Maximum number of inputs and outputs to test + 1 + maxIO = 5 - def test_shape(self): + @pytest.mark.parametrize('states', range(1, maxStates)) + @pytest.mark.parametrize('outputs', range(1, maxIO)) + @pytest.mark.parametrize('inputs', range(1, maxIO)) + def test_shape(self, states, outputs, inputs): """Test that rss outputs have the right state, input, and output size.""" - - for states in range(1, self.maxStates): - for inputs in range(1, self.maxIO): - for outputs in range(1, self.maxIO): - sys = matlab.rss(states, outputs, inputs) - self.assertEqual(sys.states, states) - self.assertEqual(sys.inputs, inputs) - self.assertEqual(sys.outputs, outputs) - - def test_pole(self): + sys = rss(states, outputs, inputs) + assert sys.nstates == states + assert sys.ninputs == inputs + assert sys.noutputs == outputs + + @pytest.mark.parametrize('states', range(1, maxStates)) + @pytest.mark.parametrize('outputs', range(1, maxIO)) + @pytest.mark.parametrize('inputs', range(1, maxIO)) + def test_pole(self, states, outputs, inputs): """Test that the poles of rss outputs have a negative real part.""" - - for states in range(1, self.maxStates): - for inputs in range(1, self.maxIO): - for outputs in range(1, self.maxIO): - sys = matlab.rss(states, outputs, inputs) - p = sys.pole() - for z in p: - self.assertTrue(z.real < 0) + sys = rss(states, outputs, inputs) + p = sys.pole() + for z in p: + assert z.real < 0 -class TestDrss(unittest.TestCase): +class TestDrss: """These are tests for the proper functionality of statesp.drss.""" - def setUp(self): - # Number of times to run each of the randomized tests. - self.numTests = 100 - # Maximum number of states to test + 1 - self.maxStates = 10 - # Maximum number of inputs and outputs to test + 1 - self.maxIO = 5 + # Maximum number of states to test + 1 + maxStates = 10 + # Maximum number of inputs and outputs to test + 1 + maxIO = 5 - def test_shape(self): + @pytest.mark.parametrize('states', range(1, maxStates)) + @pytest.mark.parametrize('outputs', range(1, maxIO)) + @pytest.mark.parametrize('inputs', range(1, maxIO)) + def test_shape(self, states, outputs, inputs): """Test that drss outputs have the right state, input, and output size.""" - - for states in range(1, self.maxStates): - for inputs in range(1, self.maxIO): - for outputs in range(1, self.maxIO): - sys = matlab.drss(states, outputs, inputs) - self.assertEqual(sys.states, states) - self.assertEqual(sys.inputs, inputs) - self.assertEqual(sys.outputs, outputs) - - def test_pole(self): + sys = drss(states, outputs, inputs) + assert sys.nstates == states + assert sys.ninputs == inputs + assert sys.noutputs == outputs + + @pytest.mark.parametrize('states', range(1, maxStates)) + @pytest.mark.parametrize('outputs', range(1, maxIO)) + @pytest.mark.parametrize('inputs', range(1, maxIO)) + def test_pole(self, states, outputs, inputs): """Test that the poles of drss outputs have less than unit magnitude.""" - - for states in range(1, self.maxStates): - for inputs in range(1, self.maxIO): - for outputs in range(1, self.maxIO): - sys = matlab.drss(states, outputs, inputs) - p = sys.pole() - for z in p: - self.assertTrue(abs(z) < 1) - - def test_pole_static(self): - """Regression: pole() of static gain is empty array.""" - np.testing.assert_array_equal(np.array([]), - StateSpace([], [], [], [[1]]).pole()) - - def test_copy_constructor(self): - # Create a set of matrices for a simple linear system - A = np.array([[-1]]) - B = np.array([[1]]) - C = np.array([[1]]) - D = np.array([[0]]) - - # Create the first linear system and a copy - linsys = StateSpace(A, B, C, D) - cpysys = StateSpace(linsys) - - # Change the original A matrix - A[0, 0] = -2 - np.testing.assert_array_equal(linsys.A, [[-1]]) # original value - np.testing.assert_array_equal(cpysys.A, [[-1]]) # original value - - # Change the A matrix for the original system - linsys.A[0, 0] = -3 - np.testing.assert_array_equal(cpysys.A, [[-1]]) # original value - - def test_sample_system_prewarping(self): - """test that prewarping works when converting from cont to discrete time system""" - A = np.array([ - [ 0.00000000e+00, 1.00000000e+00, 0.00000000e+00, 0.00000000e+00], - [-3.81097561e+01, -1.12500000e+00, 0.00000000e+00, 0.00000000e+00], - [ 0.00000000e+00, 0.00000000e+00, 0.00000000e+00, 1.00000000e+00], - [ 0.00000000e+00, 0.00000000e+00, -1.66356135e+04, -1.34748470e+01]]) - B = np.array([ - [ 0. ], [ 38.1097561 ],[ 0. ],[16635.61352143]]) - C = np.array([[0.90909091, 0. , 0.09090909, 0. ],]) - wwarp = 50 - Ts = 0.025 - plant = StateSpace(A,B,C,0) - plant_d_warped = plant.sample(Ts, 'bilinear', prewarp_frequency=wwarp) - np.testing.assert_array_almost_equal( - evalfr(plant, wwarp*1j), - evalfr(plant_d_warped, np.exp(wwarp*1j*Ts)), - decimal=4) - - -if __name__ == "__main__": - unittest.main() + sys = drss(states, outputs, inputs) + p = sys.pole() + for z in p: + assert abs(z) < 1 + + +class TestLTIConverter: + """Test returnScipySignalLTI method""" + + @pytest.fixture + def mimoss(self, request): + """Test system with various dt values""" + n = 5 + m = 3 + p = 2 + bx, bu = np.mgrid[1:n + 1, 1:m + 1] + cy, cx = np.mgrid[1:p + 1, 1:n + 1] + dy, du = np.mgrid[1:p + 1, 1:m + 1] + return StateSpace(np.eye(5) + np.eye(5, 5, 1), + bx * bu, + cy * cx, + dy * du, + request.param) + + @pytest.mark.parametrize("mimoss", + [None, + 0, + 0.1, + 1, + True], + indirect=True) + def test_returnScipySignalLTI(self, mimoss): + """Test returnScipySignalLTI method with strict=False""" + sslti = mimoss.returnScipySignalLTI(strict=False) + for i in range(mimoss.noutputs): + for j in range(mimoss.ninputs): + np.testing.assert_allclose(sslti[i][j].A, mimoss.A) + np.testing.assert_allclose(sslti[i][j].B, mimoss.B[:, + j:j + 1]) + np.testing.assert_allclose(sslti[i][j].C, mimoss.C[i:i + 1, + :]) + np.testing.assert_allclose(sslti[i][j].D, mimoss.D[i:i + 1, + j:j + 1]) + if mimoss.dt == 0: + assert sslti[i][j].dt is None + else: + assert sslti[i][j].dt == mimoss.dt + + @pytest.mark.parametrize("mimoss", [None], indirect=True) + def test_returnScipySignalLTI_error(self, mimoss): + """Test returnScipySignalLTI method with dt=None and strict=True""" + with pytest.raises(ValueError): + mimoss.returnScipySignalLTI() + with pytest.raises(ValueError): + mimoss.returnScipySignalLTI(strict=True) + + +class TestStateSpaceConfig: + """Test the configuration of the StateSpace module""" + + @pytest.fixture + def matarrayout(self): + """Override autoused global fixture within this class""" + pass + + def test_statespace_defaults(self, matarrayout): + """Make sure the tests are run with the configured defaults""" + for k, v in _statesp_defaults.items(): + assert defaults[k] == v, \ + "{} is {} but expected {}".format(k, defaults[k], v) + + +# test data for test_latex_repr below +LTX_G1 = ([[np.pi, 1e100], [-1.23456789, 5e-23]], + [[0], [1]], + [[987654321, 0.001234]], + [[5]]) + +LTX_G2 = ([], + [], + [], + [[1.2345, -2e-200], [-1, 0]]) + +LTX_G1_REF = { + 'p3_p' : '\\[\n\\left(\n\\begin{array}{rllrll|rll}\n3.&\\hspace{-1em}14&\\hspace{-1em}\\phantom{\\cdot}&1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{100}&0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n-1.&\\hspace{-1em}23&\\hspace{-1em}\\phantom{\\cdot}&5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-23}&1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\hline\n9.&\\hspace{-1em}88&\\hspace{-1em}\\cdot10^{8}&0.&\\hspace{-1em}00123&\\hspace{-1em}\\phantom{\\cdot}&5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\]', + + 'p5_p' : '\\[\n\\left(\n\\begin{array}{rllrll|rll}\n3.&\\hspace{-1em}1416&\\hspace{-1em}\\phantom{\\cdot}&1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{100}&0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n-1.&\\hspace{-1em}2346&\\hspace{-1em}\\phantom{\\cdot}&5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-23}&1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\hline\n9.&\\hspace{-1em}8765&\\hspace{-1em}\\cdot10^{8}&0.&\\hspace{-1em}001234&\\hspace{-1em}\\phantom{\\cdot}&5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\]', + + 'p3_s' : '\\[\n\\begin{array}{ll}\nA = \\left(\\begin{array}{rllrll}\n3.&\\hspace{-1em}14&\\hspace{-1em}\\phantom{\\cdot}&1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{100}\\\\\n-1.&\\hspace{-1em}23&\\hspace{-1em}\\phantom{\\cdot}&5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-23}\\\\\n\\end{array}\\right)\n&\nB = \\left(\\begin{array}{rll}\n0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\\\\nC = \\left(\\begin{array}{rllrll}\n9.&\\hspace{-1em}88&\\hspace{-1em}\\cdot10^{8}&0.&\\hspace{-1em}00123&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n&\nD = \\left(\\begin{array}{rll}\n5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\end{array}\n\\]', + + 'p5_s' : '\\[\n\\begin{array}{ll}\nA = \\left(\\begin{array}{rllrll}\n3.&\\hspace{-1em}1416&\\hspace{-1em}\\phantom{\\cdot}&1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{100}\\\\\n-1.&\\hspace{-1em}2346&\\hspace{-1em}\\phantom{\\cdot}&5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-23}\\\\\n\\end{array}\\right)\n&\nB = \\left(\\begin{array}{rll}\n0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\\\\nC = \\left(\\begin{array}{rllrll}\n9.&\\hspace{-1em}8765&\\hspace{-1em}\\cdot10^{8}&0.&\\hspace{-1em}001234&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n&\nD = \\left(\\begin{array}{rll}\n5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\end{array}\n\\]', +} + +LTX_G2_REF = { + 'p3_p' : '\\[\n\\left(\n\\begin{array}{rllrll}\n1.&\\hspace{-1em}23&\\hspace{-1em}\\phantom{\\cdot}&-2\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-200}\\\\\n-1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}&0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\]', + + 'p5_p' : '\\[\n\\left(\n\\begin{array}{rllrll}\n1.&\\hspace{-1em}2345&\\hspace{-1em}\\phantom{\\cdot}&-2\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-200}\\\\\n-1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}&0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\]', + + 'p3_s' : '\\[\n\\begin{array}{ll}\nD = \\left(\\begin{array}{rllrll}\n1.&\\hspace{-1em}23&\\hspace{-1em}\\phantom{\\cdot}&-2\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-200}\\\\\n-1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}&0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\end{array}\n\\]', + + 'p5_s' : '\\[\n\\begin{array}{ll}\nD = \\left(\\begin{array}{rllrll}\n1.&\\hspace{-1em}2345&\\hspace{-1em}\\phantom{\\cdot}&-2\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-200}\\\\\n-1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}&0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\end{array}\n\\]', +} + +refkey_n = {None: 'p3', '.3g': 'p3', '.5g': 'p5'} +refkey_r = {None: 'p', 'partitioned': 'p', 'separate': 's'} + +@pytest.mark.parametrize(" gmats, ref", + [(LTX_G1, LTX_G1_REF), + (LTX_G2, LTX_G2_REF)]) +@pytest.mark.parametrize("repr_type", [None, "partitioned", "separate"]) +@pytest.mark.parametrize("num_format", [None, ".3g", ".5g"]) +def test_latex_repr(gmats, ref, repr_type, num_format, editsdefaults): + """Test `._latex_repr_` with different config values + + This is a 'gold image' test, so if you change behaviour, + you'll need to regenerate the reference results. + Try something like: + control.reset_defaults() + print(f'p3_p : {g1._repr_latex_()!r}') + """ + from control import set_defaults + if num_format is not None: + set_defaults('statesp', latex_num_format=num_format) + + if repr_type is not None: + set_defaults('statesp', latex_repr_type=repr_type) + + g = StateSpace(*gmats) + refkey = "{}_{}".format(refkey_n[num_format], refkey_r[repr_type]) + assert g._repr_latex_() == ref[refkey] + + +@pytest.mark.parametrize( + "op", + [pytest.param(getattr(operator, s), id=s) for s in ('add', 'sub', 'mul')]) +@pytest.mark.parametrize( + "tf, arr", + [pytest.param(ct.tf([1], [0.5, 1]), np.array(2.), id="0D scalar"), + pytest.param(ct.tf([1], [0.5, 1]), np.array([2.]), id="1D scalar"), + pytest.param(ct.tf([1], [0.5, 1]), np.array([[2.]]), id="2D scalar")]) +def test_xferfcn_ndarray_precedence(op, tf, arr): + # Apply the operator to the transfer function and array + ss = ct.tf2ss(tf) + result = op(ss, arr) + assert isinstance(result, ct.StateSpace) + + # Apply the operator to the array and transfer function + ss = ct.tf2ss(tf) + result = op(arr, ss) + assert isinstance(result, ct.StateSpace) diff --git a/control/tests/timeresp_test.py b/control/tests/timeresp_test.py index b33dd5969..a576d0903 100644 --- a/control/tests/timeresp_test.py +++ b/control/tests/timeresp_test.py @@ -1,123 +1,254 @@ -#!/usr/bin/env python -# -# timeresp_test.py - test time response functions -# RMM, 17 Jun 2011 (based on TestMatlab from v0.4c) -# -# This test suite just goes through and calls all of the MATLAB -# functions using different systems and arguments to make sure that -# nothing crashes. It doesn't test actual functionality; the module -# specific unit tests will do that. - -import unittest +"""timeresp_test.py - test time response functions + +RMM, 17 Jun 2011 (based on TestMatlab from v0.4c) + +This test suite just goes through and calls all of the MATLAB +functions using different systems and arguments to make sure that +nothing crashes. It doesn't test actual functionality; the module +specific unit tests will do that. +""" + +from copy import copy +from distutils.version import StrictVersion + import numpy as np -from control.timeresp import * -from control.timeresp import _ideal_tfinal_and_dt, _default_time_vector -from control.statesp import * -from control.xferfcn import TransferFunction, _convert_to_transfer_function -from control.dtime import c2d +import pytest +import scipy as sp + +import control as ct +from control import StateSpace, TransferFunction, c2d, isctime, ss2tf, tf2ss from control.exception import slycot_check +from control.tests.conftest import slycotonly +from control.timeresp import (_default_time_vector, _ideal_tfinal_and_dt, + forced_response, impulse_response, + initial_response, step_info, step_response) -class TestTimeresp(unittest.TestCase): - def setUp(self): - """Set up some systems for testing out MATLAB functions""" - A = np.matrix("1. -2.; 3. -4.") - B = np.matrix("5.; 7.") - C = np.matrix("6. 8.") - D = np.matrix("9.") - self.siso_ss1 = StateSpace(A, B, C, D) - # Create some transfer functions - self.siso_tf1 = TransferFunction([1], [1, 2, 1]) - self.siso_tf2 = _convert_to_transfer_function(self.siso_ss1) - - # tests for pole cancellation - self.pole_cancellation = TransferFunction([1.067e+05, 5.791e+04], - [10.67, 1.067e+05, 5.791e+04]) - self.no_pole_cancellation = TransferFunction([1.881e+06], - [188.1, 1.881e+06]) +class TSys: + """Struct of test system""" + def __init__(self, sys=None, call_kwargs=None): + self.sys = sys + self.kwargs = call_kwargs if call_kwargs else {} - # Create MIMO system, contains ``siso_ss1`` twice - A = np.matrix("1. -2. 0. 0.;" - "3. -4. 0. 0.;" - "0. 0. 1. -2.;" - "0. 0. 3. -4. ") - B = np.matrix("5. 0.;" - "7. 0.;" - "0. 5.;" - "0. 7. ") - C = np.matrix("6. 8. 0. 0.;" - "0. 0. 6. 8. ") - D = np.matrix("9. 0.;" - "0. 9. ") - self.mimo_ss1 = StateSpace(A, B, C, D) - - # Create discrete time systems - self.siso_dtf1 = TransferFunction([1], [1, 1, 0.25], True) - self.siso_dtf2 = TransferFunction([1], [1, 1, 0.25], 0.2) - self.siso_dss1 = tf2ss(self.siso_dtf1) - self.siso_dss2 = tf2ss(self.siso_dtf2) - self.mimo_dss1 = StateSpace(A, B, C, D, True) - self.mimo_dss2 = c2d(self.mimo_ss1, 0.2) - - def test_step_response(self): - # Test SISO system - sys = self.siso_ss1 - t = np.linspace(0, 1, 10) - youttrue = np.array([9., 17.6457, 24.7072, 30.4855, 35.2234, 39.1165, - 42.3227, 44.9694, 47.1599, 48.9776]) + def __repr__(self): + """Show system when debugging""" + return self.sys.__repr__() - # SISO call - tout, yout = step_response(sys, T=t) - np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) - np.testing.assert_array_almost_equal(tout, t) - # Play with arguments - tout, yout = step_response(sys, T=t, X0=0) - np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) - np.testing.assert_array_almost_equal(tout, t) +class TestTimeresp: - X0 = np.array([0, 0]) - tout, yout = step_response(sys, T=t, X0=X0) - np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) - np.testing.assert_array_almost_equal(tout, t) + @pytest.fixture + def siso_ss1(self): - tout, yout, xout = step_response(sys, T=t, X0=0, return_x=True) - np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) - np.testing.assert_array_almost_equal(tout, t) + A = np.array([[1., -2.], [3., -4.]]) + B = np.array([[5.], [7.]]) + C = np.array([[6., 8.]]) + D = np.array([[9.]]) + T = TSys(StateSpace(A, B, C, D, 0)) - # Test MIMO system, which contains ``siso_ss1`` twice - sys = self.mimo_ss1 - _t, y_00 = step_response(sys, T=t, input=0, output=0) - _t, y_11 = step_response(sys, T=t, input=1, output=1) - np.testing.assert_array_almost_equal(y_00, youttrue, decimal=4) - np.testing.assert_array_almost_equal(y_11, youttrue, decimal=4) + T.t = np.linspace(0, 1, 10) + T.ystep = np.array([9., 17.6457, 24.7072, 30.4855, 35.2234, + 39.1165, 42.3227, 44.9694, 47.1599, 48.9776]) - # Make sure continuous and discrete time use same return conventions - sysc = self.mimo_ss1 - sysd = c2d(sysc, 1) # discrete time system - Tvec = np.linspace(0, 10, 11) # make sure to use integer times 0..10 - Tc, youtc = step_response(sysc, Tvec, input=0) - Td, youtd = step_response(sysd, Tvec, input=0) - np.testing.assert_array_equal(Tc.shape, Td.shape) - np.testing.assert_array_equal(youtc.shape, youtd.shape) + T.yinitial = np.array([11., 8.1494, 5.9361, 4.2258, 2.9118, + 1.9092, 1.1508, 0.5833, 0.1645, -0.1391]) + return T - # Recreate issue #374 ("Bug in step_response()") - def test_step_nostates(self): - # Continuous time, constant system - sys = TransferFunction([1], [1]) - t, y = step_response(sys) - np.testing.assert_array_equal(y, np.ones(len(t))) + @pytest.fixture + def siso_ss2(self, siso_ss1): + """System siso_ss2 with D=0""" + ss1 = siso_ss1.sys + T = TSys(StateSpace(ss1.A, ss1.B, ss1.C, 0, 0)) + T.t = siso_ss1.t + T.ystep = siso_ss1.ystep - 9 + T.initial = siso_ss1.yinitial - 9 + T.yimpulse = np.array([86., 70.1808, 57.3753, 46.9975, 38.5766, + 31.7344, 26.1668, 21.6292, 17.9245, 14.8945]) - # Discrete time, constant system - sys = TransferFunction([1], [1], 1) - t, y = step_response(sys) - np.testing.assert_array_equal(y, np.ones(len(t))) + return T + + @pytest.fixture + def siso_tf1(self): + # Create some transfer functions + return TSys(TransferFunction([1], [1, 2, 1], 0)) + + @pytest.fixture + def siso_tf2(self, siso_ss1): + T = copy(siso_ss1) + T.sys = ss2tf(siso_ss1.sys) + return T - def test_step_info(self): - # From matlab docs: - sys = TransferFunction([1, 5, 5], [1, 1.65, 5, 6.5, 2]) - Strue = { + @pytest.fixture + def mimo_ss1(self, siso_ss1): + # Create MIMO system, contains ``siso_ss1`` twice + A = np.zeros((4, 4)) + A[:2, :2] = siso_ss1.sys.A + A[2:, 2:] = siso_ss1.sys.A + B = np.zeros((4, 2)) + B[:2, :1] = siso_ss1.sys.B + B[2:, 1:] = siso_ss1.sys.B + C = np.zeros((2, 4)) + C[:1, :2] = siso_ss1.sys.C + C[1:, 2:] = siso_ss1.sys.C + D = np.zeros((2, 2)) + D[:1, :1] = siso_ss1.sys.D + D[1:, 1:] = siso_ss1.sys.D + T = copy(siso_ss1) + T.sys = StateSpace(A, B, C, D) + return T + + @pytest.fixture + def mimo_ss2(self, siso_ss2): + # Create MIMO system, contains ``siso_ss2`` twice + A = np.zeros((4, 4)) + A[:2, :2] = siso_ss2.sys.A + A[2:, 2:] = siso_ss2.sys.A + B = np.zeros((4, 2)) + B[:2, :1] = siso_ss2.sys.B + B[2:, 1:] = siso_ss2.sys.B + C = np.zeros((2, 4)) + C[:1, :2] = siso_ss2.sys.C + C[1:, 2:] = siso_ss2.sys.C + D = np.zeros((2, 2)) + T = copy(siso_ss2) + T.sys = StateSpace(A, B, C, D, 0) + return T + + # Create discrete time systems + + @pytest.fixture + def siso_dtf0(self): + T = TSys(TransferFunction([1.], [1., 0.], 1.)) + T.t = np.arange(4) + T.yimpulse = [0., 1., 0., 0.] + return T + + @pytest.fixture + def siso_dtf1(self): + T = TSys(TransferFunction([1], [1, 1, 0.25], True)) + T.t = np.arange(0, 5, 1) + return T + + @pytest.fixture + def siso_dtf2(self): + T = TSys(TransferFunction([1], [1, 1, 0.25], 0.2)) + T.t = np.arange(0, 5, 0.2) + return T + + @pytest.fixture + def siso_dss1(self, siso_dtf1): + T = copy(siso_dtf1) + T.sys = tf2ss(siso_dtf1.sys) + return T + + @pytest.fixture + def siso_dss2(self, siso_dtf2): + T = copy(siso_dtf2) + T.sys = tf2ss(siso_dtf2.sys) + return T + + @pytest.fixture + def mimo_dss1(self, mimo_ss1): + ss1 = mimo_ss1.sys + T = TSys( + StateSpace(ss1.A, ss1.B, ss1.C, ss1.D, True)) + T.t = np.arange(0, 5, 0.2) + return T + + @pytest.fixture + def mimo_dss2(self, mimo_ss1): + T = copy(mimo_ss1) + T.sys = c2d(mimo_ss1.sys, T.t[1]-T.t[0]) + return T + + @pytest.fixture + def mimo_tf2(self, siso_ss2, mimo_ss2): + T = copy(mimo_ss2) + # construct from siso to avoid slycot during fixture setup + tf_ = ss2tf(siso_ss2.sys) + T.sys = TransferFunction([[tf_.num[0][0], [0]], [[0], tf_.num[0][0]]], + [[tf_.den[0][0], [1]], [[1], tf_.den[0][0]]], + 0) + return T + + @pytest.fixture + def mimo_dtf1(self, siso_dtf1): + T = copy(siso_dtf1) + # construct from siso to avoid slycot during fixture setup + tf_ = siso_dtf1.sys + T.sys = TransferFunction([[tf_.num[0][0], [0]], [[0], tf_.num[0][0]]], + [[tf_.den[0][0], [1]], [[1], tf_.den[0][0]]], + True) + return T + + @pytest.fixture + def pole_cancellation(self): + # for pole cancellation tests + return TransferFunction([1.067e+05, 5.791e+04], + [10.67, 1.067e+05, 5.791e+04]) + + @pytest.fixture + def no_pole_cancellation(self): + return TransferFunction([1.881e+06], + [188.1, 1.881e+06]) + + @pytest.fixture + def siso_tf_type1(self): + # System Type 1 - Step response not stationary: G(s)=1/s(s+1) + T = TSys(TransferFunction(1, [1, 1, 0])) + T.step_info = { + 'RiseTime': np.NaN, + 'SettlingTime': np.NaN, + 'SettlingMin': np.NaN, + 'SettlingMax': np.NaN, + 'Overshoot': np.NaN, + 'Undershoot': np.NaN, + 'Peak': np.Inf, + 'PeakTime': np.Inf, + 'SteadyStateValue': np.NaN} + return T + + @pytest.fixture + def siso_tf_kpos(self): + # SISO under shoot response and positive final value + # G(s)=(-s+1)/(s²+s+1) + T = TSys(TransferFunction([-1, 1], [1, 1, 1])) + T.step_info = { + 'RiseTime': 1.242, + 'SettlingTime': 9.110, + 'SettlingMin': 0.90, + 'SettlingMax': 1.208, + 'Overshoot': 20.840, + 'Undershoot': 28.0, + 'Peak': 1.208, + 'PeakTime': 4.282, + 'SteadyStateValue': 1.0} + return T + + @pytest.fixture + def siso_tf_kneg(self): + # SISO under shoot response and negative final value + # k=-1 G(s)=-(-s+1)/(s²+s+1) + T = TSys(TransferFunction([1, -1], [1, 1, 1])) + T.step_info = { + 'RiseTime': 1.242, + 'SettlingTime': 9.110, + 'SettlingMin': -1.208, + 'SettlingMax': -0.90, + 'Overshoot': 20.840, + 'Undershoot': 28.0, + 'Peak': 1.208, + 'PeakTime': 4.282, + 'SteadyStateValue': -1.0} + return T + + @pytest.fixture + def siso_tf_step_matlab(self): + # example from matlab online help + # https://www.mathworks.com/help/control/ref/stepinfo.html + T = TSys(TransferFunction([1, 5, 5], [1, 1.65, 5, 6.5, 2])) + T.step_info = { 'RiseTime': 3.8456, 'SettlingTime': 27.9762, 'SettlingMin': 2.0689, @@ -125,538 +256,908 @@ def test_step_info(self): 'Overshoot': 7.4915, 'Undershoot': 0, 'Peak': 2.6873, - 'PeakTime': 8.0530 - } - - S = step_info(sys) - - # Very arbitrary tolerance because I don't know if the - # response from the MATLAB is really that accurate. - # maybe it is a good idea to change the Strue to match - # but I didn't do it because I don't know if it is - # accurate either... - rtol = 2e-2 - np.testing.assert_allclose( - S.get('RiseTime'), - Strue.get('RiseTime'), - rtol=rtol) - np.testing.assert_allclose( - S.get('SettlingTime'), - Strue.get('SettlingTime'), - rtol=rtol) - np.testing.assert_allclose( - S.get('SettlingMin'), - Strue.get('SettlingMin'), - rtol=rtol) - np.testing.assert_allclose( - S.get('SettlingMax'), - Strue.get('SettlingMax'), - rtol=rtol) - np.testing.assert_allclose( - S.get('Overshoot'), - Strue.get('Overshoot'), - rtol=rtol) - np.testing.assert_allclose( - S.get('Undershoot'), - Strue.get('Undershoot'), - rtol=rtol) - np.testing.assert_allclose( - S.get('Peak'), - Strue.get('Peak'), - rtol=rtol) - np.testing.assert_allclose( - S.get('PeakTime'), - Strue.get('PeakTime'), - rtol=rtol) - np.testing.assert_allclose( - S.get('SteadyStateValue'), - 2.50, - rtol=rtol) - - # confirm that pole-zero cancellation doesn't perturb results - # https://github.com/python-control/python-control/issues/440 - step_info_no_cancellation = step_info(self.no_pole_cancellation) - step_info_cancellation = step_info(self.pole_cancellation) - for key in step_info_no_cancellation: - np.testing.assert_allclose(step_info_no_cancellation[key], - step_info_cancellation[key], rtol=1e-4) - - def test_impulse_response(self): - # Test SISO system - sys = self.siso_ss1 - t = np.linspace(0, 1, 10) - youttrue = np.array([86., 70.1808, 57.3753, 46.9975, 38.5766, 31.7344, - 26.1668, 21.6292, 17.9245, 14.8945]) - tout, yout = impulse_response(sys, T=t) - np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) + 'PeakTime': 8.0530, + 'SteadyStateValue': 2.5} + return T + + @pytest.fixture + def mimo_ss_step_matlab(self): + A = [[0.68, -0.34], + [0.34, 0.68]] + B = [[0.18, -0.05], + [0.04, 0.11]] + C = [[0, -1.53], + [-1.12, -1.10]] + D = [[0, 0], + [0.06, -0.37]] + T = TSys(StateSpace(A, B, C, D, 0.2)) + T.kwargs['step_info'] = {'T': 4.6} + T.step_info = [[{'RiseTime': 0.6000, + 'SettlingTime': 3.0000, + 'SettlingMin': -0.5999, + 'SettlingMax': -0.4689, + 'Overshoot': 15.5072, + 'Undershoot': 0., + 'Peak': 0.5999, + 'PeakTime': 1.4000, + 'SteadyStateValue': -0.5193}, + {'RiseTime': 0., + 'SettlingTime': 3.6000, + 'SettlingMin': -0.2797, + 'SettlingMax': -0.1043, + 'Overshoot': 118.9918, + 'Undershoot': 0, + 'Peak': 0.2797, + 'PeakTime': .6000, + 'SteadyStateValue': -0.1277}], + [{'RiseTime': 0.4000, + 'SettlingTime': 2.8000, + 'SettlingMin': -0.6724, + 'SettlingMax': -0.5188, + 'Overshoot': 24.6476, + 'Undershoot': 11.1224, + 'Peak': 0.6724, + 'PeakTime': 1, + 'SteadyStateValue': -0.5394}, + {'RiseTime': 0.0000, # (*) + 'SettlingTime': 3.4000, + 'SettlingMin': -0.1034, + 'SettlingMax': -0.1485, + 'Overshoot': 132.0170, + 'Undershoot': 79.222, # 0. in MATLAB + 'Peak': 0.4350, + 'PeakTime': .2, + 'SteadyStateValue': -0.1875}]] + # (*): MATLAB gives 0.4 here, but it is unclear what + # 10% and 90% of the steady state response mean, when + # the step for this channel does not start a 0 for + # 0 initial conditions + return T + + @pytest.fixture + def siso_ss_step_matlab(self, mimo_ss_step_matlab): + T = copy(mimo_ss_step_matlab) + T.sys = T.sys[1, 0] + T.step_info = T.step_info[1][0] + return T + + @pytest.fixture + def mimo_tf_step_info(self, + siso_tf_kpos, siso_tf_kneg, + siso_tf_step_matlab): + Ta = [[siso_tf_kpos, siso_tf_kneg, siso_tf_step_matlab], + [siso_tf_step_matlab, siso_tf_kpos, siso_tf_kneg]] + T = TSys(TransferFunction( + [[Ti.sys.num[0][0] for Ti in Tr] for Tr in Ta], + [[Ti.sys.den[0][0] for Ti in Tr] for Tr in Ta])) + T.step_info = [[Ti.step_info for Ti in Tr] for Tr in Ta] + # enforce enough sample points for all channels (they have different + # characteristics) + T.kwargs['step_info'] = {'T_num': 2000} + return T + + + @pytest.fixture + def tsystem(self, + request, + siso_ss1, siso_ss2, siso_tf1, siso_tf2, + mimo_ss1, mimo_ss2, mimo_tf2, + siso_dtf0, siso_dtf1, siso_dtf2, + siso_dss1, siso_dss2, + mimo_dss1, mimo_dss2, mimo_dtf1, + pole_cancellation, no_pole_cancellation, siso_tf_type1, + siso_tf_kpos, siso_tf_kneg, + siso_tf_step_matlab, siso_ss_step_matlab, + mimo_ss_step_matlab, mimo_tf_step_info): + systems = {"siso_ss1": siso_ss1, + "siso_ss2": siso_ss2, + "siso_tf1": siso_tf1, + "siso_tf2": siso_tf2, + "mimo_ss1": mimo_ss1, + "mimo_ss2": mimo_ss2, + "mimo_tf2": mimo_tf2, + "siso_dtf0": siso_dtf0, + "siso_dtf1": siso_dtf1, + "siso_dtf2": siso_dtf2, + "siso_dss1": siso_dss1, + "siso_dss2": siso_dss2, + "mimo_dss1": mimo_dss1, + "mimo_dss2": mimo_dss2, + "mimo_dtf1": mimo_dtf1, + "pole_cancellation": pole_cancellation, + "no_pole_cancellation": no_pole_cancellation, + "siso_tf_type1": siso_tf_type1, + "siso_tf_kpos": siso_tf_kpos, + "siso_tf_kneg": siso_tf_kneg, + "siso_tf_step_matlab": siso_tf_step_matlab, + "siso_ss_step_matlab": siso_ss_step_matlab, + "mimo_ss_step_matlab": mimo_ss_step_matlab, + "mimo_tf_step": mimo_tf_step_info, + } + return systems[request.param] + + @pytest.mark.parametrize( + "kwargs", + [{}, + {'X0': 0}, + {'X0': np.array([0, 0])}, + {'X0': 0, 'return_x': True}, + ]) + def test_step_response_siso(self, siso_ss1, kwargs): + """Test SISO system step response""" + sys = siso_ss1.sys + t = siso_ss1.t + yref = siso_ss1.ystep + # SISO call + out = step_response(sys, T=t, **kwargs) + tout, yout = out[:2] + assert len(out) == 3 if ('return_x', True) in kwargs.items() else 2 np.testing.assert_array_almost_equal(tout, t) + np.testing.assert_array_almost_equal(yout, yref, decimal=4) - # Play with arguments - tout, yout = impulse_response(sys, T=t, X0=0) - np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) - np.testing.assert_array_almost_equal(tout, t) + def test_step_response_mimo(self, mimo_ss1): + """Test MIMO system, which contains ``siso_ss1`` twice""" + sys = mimo_ss1.sys + t = mimo_ss1.t + yref = mimo_ss1.ystep + _t, y_00 = step_response(sys, T=t, input=0, output=0) + _t, y_11 = step_response(sys, T=t, input=1, output=1) + np.testing.assert_array_almost_equal(y_00, yref, decimal=4) + np.testing.assert_array_almost_equal(y_11, yref, decimal=4) + + def test_step_response_return(self, mimo_ss1): + """Verify continuous and discrete time use same return conventions""" + sysc = mimo_ss1.sys + sysd = c2d(sysc, 1) # discrete time system + Tvec = np.linspace(0, 10, 11) # make sure to use integer times 0..10 + Tc, youtc = step_response(sysc, Tvec, input=0) + Td, youtd = step_response(sysd, Tvec, input=0) + np.testing.assert_array_equal(Tc.shape, Td.shape) + np.testing.assert_array_equal(youtc.shape, youtd.shape) - X0 = np.array([0, 0]) - tout, yout = impulse_response(sys, T=t, X0=X0) - np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) - np.testing.assert_array_almost_equal(tout, t) - tout, yout, xout = impulse_response(sys, T=t, X0=0, return_x=True) - np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) + @pytest.mark.parametrize("dt", [0, 1], ids=["continuous", "discrete"]) + def test_step_nostates(self, dt): + """Constant system, continuous and discrete time + + gh-374 "Bug in step_response()" + """ + sys = TransferFunction([1], [1], dt) + t, y = step_response(sys) + np.testing.assert_array_equal(y, np.ones(len(t))) + + def assert_step_info_match(self, sys, info, info_ref): + """Assert reasonable step_info accuracy.""" + if sys.isdtime(strict=True): + dt = sys.dt + else: + _, dt = _ideal_tfinal_and_dt(sys, is_step=True) + + for k in ['RiseTime', 'SettlingTime', 'PeakTime']: + np.testing.assert_allclose(info[k], info_ref[k], atol=dt, + err_msg=f"{k} does not match") + for k in ['Overshoot', 'Undershoot', 'Peak', 'SteadyStateValue']: + np.testing.assert_allclose(info[k], info_ref[k], rtol=5e-3, + err_msg=f"{k} does not match") + + # steep gradient right after RiseTime + absrefinf = np.abs(info_ref['SteadyStateValue']) + if info_ref['RiseTime'] > 0: + y_next_sample_max = 0.8*absrefinf/info_ref['RiseTime']*dt + else: + y_next_sample_max = 0 + for k in ['SettlingMin', 'SettlingMax']: + if (np.abs(info_ref[k]) - 0.9 * absrefinf) > y_next_sample_max: + # local min/max peak well after signal has risen + np.testing.assert_allclose(info[k], info_ref[k], rtol=1e-3) + + @pytest.mark.parametrize( + "yfinal", [True, False], ids=["yfinal", "no yfinal"]) + @pytest.mark.parametrize( + "systype, time_2d", + [("ltisys", False), + ("time response", False), + ("time response", True), + ], + ids=["ltisys", "time response (n,)", "time response (1,n)"]) + @pytest.mark.parametrize( + "tsystem", + ["siso_tf_step_matlab", + "siso_ss_step_matlab", + "siso_tf_kpos", + "siso_tf_kneg", + "siso_tf_type1"], + indirect=["tsystem"]) + def test_step_info(self, tsystem, systype, time_2d, yfinal): + """Test step info for SISO systems.""" + step_info_kwargs = tsystem.kwargs.get('step_info', {}) + if systype == "time response": + # simulate long enough for steady state value + tfinal = 3 * tsystem.step_info['SettlingTime'] + if np.isnan(tfinal): + pytest.skip("test system does not settle") + t, y = step_response(tsystem.sys, T=tfinal, T_num=5000) + sysdata = y + step_info_kwargs['T'] = t[np.newaxis, :] if time_2d else t + else: + sysdata = tsystem.sys + if yfinal: + step_info_kwargs['yfinal'] = tsystem.step_info['SteadyStateValue'] + + info = step_info(sysdata, **step_info_kwargs) + + self.assert_step_info_match(tsystem.sys, info, tsystem.step_info) + + @pytest.mark.parametrize( + "yfinal", [True, False], ids=["yfinal", "no_yfinal"]) + @pytest.mark.parametrize( + "systype", ["ltisys", "time response"]) + @pytest.mark.parametrize( + "tsystem", + ['mimo_ss_step_matlab', + pytest.param('mimo_tf_step', marks=slycotonly)], + indirect=["tsystem"]) + def test_step_info_mimo(self, tsystem, systype, yfinal): + """Test step info for MIMO systems.""" + step_info_kwargs = tsystem.kwargs.get('step_info', {}) + if systype == "time response": + tfinal = 3 * max([S['SettlingTime'] + for Srow in tsystem.step_info for S in Srow]) + t, y = step_response(tsystem.sys, T=tfinal, T_num=5000) + sysdata = y + step_info_kwargs['T'] = t + else: + sysdata = tsystem.sys + if yfinal: + step_info_kwargs['yfinal'] = [[S['SteadyStateValue'] + for S in Srow] + for Srow in tsystem.step_info] + + info_dict = step_info(sysdata, **step_info_kwargs) + + for i, row in enumerate(info_dict): + for j, info in enumerate(row): + self.assert_step_info_match(tsystem.sys, + info, tsystem.step_info[i][j]) + + def test_step_info_invalid(self): + """Call step_info with invalid parameters.""" + with pytest.raises(ValueError, match="time series data convention"): + step_info(["not numeric data"]) + with pytest.raises(ValueError, match="time series data convention"): + step_info(np.ones((10, 15))) # invalid shape + with pytest.raises(ValueError, match="matching time vector"): + step_info(np.ones(15), T=np.linspace(0, 1, 20)) # time too long + with pytest.raises(ValueError, match="matching time vector"): + step_info(np.ones((2, 2, 15))) # no time vector + + def test_step_pole_cancellation(self, pole_cancellation, + no_pole_cancellation): + # confirm that pole-zero cancellation doesn't perturb results + # https://github.com/python-control/python-control/issues/440 + step_info_no_cancellation = step_info(no_pole_cancellation) + step_info_cancellation = step_info(pole_cancellation) + self.assert_step_info_match(no_pole_cancellation, + step_info_no_cancellation, + step_info_cancellation) + + @pytest.mark.parametrize( + "tsystem, kwargs", + [("siso_ss2", {}), + ("siso_ss2", {'X0': 0}), + ("siso_ss2", {'X0': np.array([0, 0])}), + ("siso_ss2", {'X0': 0, 'return_x': True}), + ("siso_dtf0", {})], + indirect=["tsystem"]) + def test_impulse_response_siso(self, tsystem, kwargs): + """Test impulse response of SISO systems""" + sys = tsystem.sys + t = tsystem.t + yref = tsystem.yimpulse + + out = impulse_response(sys, T=t, **kwargs) + tout, yout = out[:2] + assert len(out) == 3 if ('return_x', True) in kwargs.items() else 2 np.testing.assert_array_almost_equal(tout, t) + np.testing.assert_array_almost_equal(yout, yref, decimal=4) + + def test_impulse_response_mimo(self, mimo_ss2): + """"Test impulse response of MIMO systems""" + sys = mimo_ss2.sys + t = mimo_ss2.t - # Test MIMO system, which contains ``siso_ss1`` twice - sys = self.mimo_ss1 + yref = mimo_ss2.yimpulse _t, y_00 = impulse_response(sys, T=t, input=0, output=0) + np.testing.assert_array_almost_equal(y_00, yref, decimal=4) _t, y_11 = impulse_response(sys, T=t, input=1, output=1) - np.testing.assert_array_almost_equal(y_00, youttrue, decimal=4) - np.testing.assert_array_almost_equal(y_11, youttrue, decimal=4) + np.testing.assert_array_almost_equal(y_11, yref, decimal=4) - # Test MIMO system, as mimo, and don't trim outputs - sys = self.mimo_ss1 + yref_notrim = np.zeros((2, len(t))) + yref_notrim[:1, :] = yref _t, yy = impulse_response(sys, T=t, input=0) - np.testing.assert_array_almost_equal( - yy, np.vstack((youttrue, np.zeros_like(youttrue))), decimal=4) - - def test_initial_response(self): - # Test SISO system - sys = self.siso_ss1 - t = np.linspace(0, 1, 10) - x0 = np.array([[0.5], [1]]) - youttrue = np.array([11., 8.1494, 5.9361, 4.2258, 2.9118, 1.9092, - 1.1508, 0.5833, 0.1645, -0.1391]) - tout, yout = initial_response(sys, T=t, X0=x0) - np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) + np.testing.assert_array_almost_equal(yy[:,0,:], yref_notrim, decimal=4) + + @pytest.mark.skipif(StrictVersion(sp.__version__) < "1.3", + reason="requires SciPy 1.3 or greater") + def test_discrete_time_impulse(self, siso_tf1): + # discrete time impulse sampled version should match cont time + dt = 0.1 + t = np.arange(0, 3, dt) + sys = siso_tf1.sys + sysdt = sys.sample(dt, 'impulse') + np.testing.assert_array_almost_equal(impulse_response(sys, t)[1], + impulse_response(sysdt, t)[1]) + + def test_impulse_response_warnD(self, siso_ss1): + """Test warning about direct feedthrough""" + with pytest.warns(UserWarning, match="System has direct feedthrough"): + _ = impulse_response(siso_ss1.sys, siso_ss1.t) + + @pytest.mark.parametrize( + "kwargs", + [{}, + {'X0': 0}, + {'X0': np.array([0.5, 1])}, + {'X0': np.array([[0.5], [1]])}, + {'X0': np.array([0.5, 1]), 'return_x': True}, + ]) + def test_initial_response(self, siso_ss1, kwargs): + """Test initial response of SISO system""" + sys = siso_ss1.sys + t = siso_ss1.t + x0 = kwargs.get('X0', 0) + yref = siso_ss1.yinitial if np.any(x0) else np.zeros_like(t) + + out = initial_response(sys, T=t, **kwargs) + tout, yout = out[:2] + assert len(out) == 3 if ('return_x', True) in kwargs.items() else 2 np.testing.assert_array_almost_equal(tout, t) + np.testing.assert_array_almost_equal(yout, yref, decimal=4) - # Play with arguments - tout, yout, xout = initial_response(sys, T=t, X0=x0, return_x=True) - np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) - np.testing.assert_array_almost_equal(tout, t) + def test_initial_response_mimo(self, mimo_ss1): + """Test initial response of MIMO system""" + sys = mimo_ss1.sys + t = mimo_ss1.t + x0 = np.array([[.5], [1.], [.5], [1.]]) + yref = mimo_ss1.yinitial + yref_notrim = np.broadcast_to(yref, (2, len(t))) - # Test MIMO system, which contains ``siso_ss1`` twice - sys = self.mimo_ss1 - x0 = np.matrix(".5; 1.; .5; 1.") _t, y_00 = initial_response(sys, T=t, X0=x0, input=0, output=0) - _t, y_11 = initial_response(sys, T=t, X0=x0, input=1, output=1) - np.testing.assert_array_almost_equal(y_00, youttrue, decimal=4) - np.testing.assert_array_almost_equal(y_11, youttrue, decimal=4) - - def test_initial_response_no_trim(self): - # test MIMO system without trimming - t = np.linspace(0, 1, 10) - x0 = np.matrix(".5; 1.; .5; 1.") - youttrue = np.array([11., 8.1494, 5.9361, 4.2258, 2.9118, 1.9092, - 1.1508, 0.5833, 0.1645, -0.1391]) - sys = self.mimo_ss1 + np.testing.assert_array_almost_equal(y_00, yref, decimal=4) + _t, y_11 = initial_response(sys, T=t, X0=x0, input=0, output=1) + np.testing.assert_array_almost_equal(y_11, yref, decimal=4) _t, yy = initial_response(sys, T=t, X0=x0) - np.testing.assert_array_almost_equal( - yy, np.vstack((youttrue, youttrue)), - decimal=4) - - def test_forced_response(self): - t = np.linspace(0, 1, 10) - - # compute step response - test with state space, and transfer function - # objects - u = np.array([1., 1, 1, 1, 1, 1, 1, 1, 1, 1]) - youttrue = np.array([9., 17.6457, 24.7072, 30.4855, 35.2234, 39.1165, - 42.3227, 44.9694, 47.1599, 48.9776]) - tout, yout, _xout = forced_response(self.siso_ss1, t, u) - np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) + np.testing.assert_array_almost_equal(yy, yref_notrim, decimal=4) + + @pytest.mark.parametrize("tsystem", + ["siso_ss1", "siso_tf2"], + indirect=True) + def test_forced_response_step(self, tsystem): + """Test forced response of SISO systems as step response""" + sys = tsystem.sys + t = tsystem.t + u = np.ones_like(t, dtype=float) + yref = tsystem.ystep + + tout, yout = forced_response(sys, t, u) + np.testing.assert_array_almost_equal(tout, t) + np.testing.assert_array_almost_equal(yout, yref, decimal=4) + + @pytest.mark.parametrize("u", + [np.zeros((10,), dtype=float), + 0] # special algorithm + ) + def test_forced_response_initial(self, siso_ss1, u): + """Test forced response of SISO system as intitial response""" + sys = siso_ss1.sys + t = siso_ss1.t + x0 = np.array([[.5], [1.]]) + yref = siso_ss1.yinitial + + tout, yout = forced_response(sys, t, u, X0=x0) np.testing.assert_array_almost_equal(tout, t) - _t, yout, _xout = forced_response(self.siso_tf2, t, u) - np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) - - # test with initial value and special algorithm for ``U=0`` - u = 0 - x0 = np.matrix(".5; 1.") - youttrue = np.array([11., 8.1494, 5.9361, 4.2258, 2.9118, 1.9092, - 1.1508, 0.5833, 0.1645, -0.1391]) - _t, yout, _xout = forced_response(self.siso_ss1, t, u, x0) - np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) - - # Test MIMO system, which contains ``siso_ss1`` twice + np.testing.assert_array_almost_equal(yout, yref, decimal=4) + + @pytest.mark.parametrize("tsystem, useT", + [("mimo_ss1", True), + ("mimo_dss2", True), + ("mimo_dss2", False)], + indirect=["tsystem"]) + def test_forced_response_mimo(self, tsystem, useT): + """Test forced response of MIMO system""" # first system: initial value, second system: step response + sys = tsystem.sys + t = tsystem.t u = np.array([[0., 0, 0, 0, 0, 0, 0, 0, 0, 0], [1., 1, 1, 1, 1, 1, 1, 1, 1, 1]]) x0 = np.array([[.5], [1], [0], [0]]) - youttrue = np.array([[11., 8.1494, 5.9361, 4.2258, 2.9118, 1.9092, - 1.1508, 0.5833, 0.1645, -0.1391], - [9., 17.6457, 24.7072, 30.4855, 35.2234, 39.1165, - 42.3227, 44.9694, 47.1599, 48.9776]]) - _t, yout, _xout = forced_response(self.mimo_ss1, t, u, x0) - np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) - - # Test discrete MIMO system to use correct convention for input - sysc = self.mimo_ss1 - dt=t[1]-t[0] - sysd = c2d(sysc, dt) # discrete time system - Tc, youtc, _xoutc = forced_response(sysc, t, u, x0) - Td, youtd, _xoutd = forced_response(sysd, t, u, x0) - np.testing.assert_array_equal(Tc.shape, Td.shape) - np.testing.assert_array_equal(youtc.shape, youtd.shape) - np.testing.assert_array_almost_equal(youtc, youtd, decimal=4) - - # Test discrete MIMO system without default T argument - u = np.array([[0., 0, 0, 0, 0, 0, 0, 0, 0, 0], - [1., 1, 1, 1, 1, 1, 1, 1, 1, 1]]) - x0 = np.array([[.5], [1], [0], [0]]) - youttrue = np.array([[11., 8.1494, 5.9361, 4.2258, 2.9118, 1.9092, - 1.1508, 0.5833, 0.1645, -0.1391], - [9., 17.6457, 24.7072, 30.4855, 35.2234, 39.1165, - 42.3227, 44.9694, 47.1599, 48.9776]]) - _t, yout, _xout = forced_response(sysd, U=u, X0=x0) - np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) - - def test_lsim_double_integrator(self): + yref = np.vstack([tsystem.yinitial, tsystem.ystep]) + + if useT: + _t, yout = forced_response(sys, t, u, x0) + else: + _t, yout = forced_response(sys, U=u, X0=x0) + np.testing.assert_array_almost_equal(yout, yref, decimal=4) + + @pytest.mark.usefixtures("editsdefaults") + def test_forced_response_legacy(self): + # Define a system for testing + sys = ct.rss(2, 1, 1) + T = np.linspace(0, 10, 10) + U = np.sin(T) + + """Make sure that legacy version of forced_response works""" + ct.config.use_legacy_defaults("0.8.4") + # forced_response returns x by default + t, y = ct.step_response(sys, T) + t, y, x = ct.forced_response(sys, T, U) + + ct.config.use_legacy_defaults("0.9.0") + # forced_response returns input/output by default + t, y = ct.step_response(sys, T) + t, y = ct.forced_response(sys, T, U) + t, y, x = ct.forced_response(sys, T, U, return_x=True) + + + @pytest.mark.parametrize("u, x0, xtrue", + [(np.zeros((10,)), + np.array([2., 3.]), + np.vstack([np.linspace(2, 5, 10), + np.full((10,), 3)])), + (np.ones((10,)), + np.array([0., 0.]), + np.vstack([0.5 * np.linspace(0, 1, 10)**2, + np.linspace(0, 1, 10)])), + (np.linspace(0, 1, 10), + np.array([0., 0.]), + np.vstack([np.linspace(0, 1, 10)**3 / 6., + np.linspace(0, 1, 10)**2 / 2.]))], + ids=["zeros", "ones", "linear"]) + def test_lsim_double_integrator(self, u, x0, xtrue): + """Test forced response of double integrator""" # Note: scipy.signal.lsim fails if A is not invertible - A = np.mat("0. 1.;0. 0.") - B = np.mat("0.; 1.") - C = np.mat("1. 0.") + A = np.array([[0., 1.], + [0., 0.]]) + B = np.array([[0.], + [1.]]) + C = np.array([[1., 0.]]) D = 0. sys = StateSpace(A, B, C, D) + t = np.linspace(0, 1, 10) + + _t, yout, xout = forced_response(sys, t, u, x0, return_x=True) + np.testing.assert_array_almost_equal(xout, xtrue, decimal=6) + ytrue = np.squeeze(np.asarray(C.dot(xtrue))) + np.testing.assert_array_almost_equal(yout, ytrue, decimal=6) + - def check(u, x0, xtrue): - _t, yout, xout = forced_response(sys, t, u, x0) - np.testing.assert_array_almost_equal(xout, xtrue, decimal=6) - ytrue = np.squeeze(np.asarray(C.dot(xtrue))) - np.testing.assert_array_almost_equal(yout, ytrue, decimal=6) - - # test with zero input - npts = 10 - t = np.linspace(0, 1, npts) - u = np.zeros_like(t) - x0 = np.array([2., 3.]) - xtrue = np.zeros((2, npts)) - xtrue[0, :] = x0[0] + t * x0[1] - xtrue[1, :] = x0[1] - check(u, x0, xtrue) - - # test with step input - u = np.ones_like(t) - xtrue = np.array([0.5 * t**2, t]) - x0 = np.array([0., 0.]) - check(u, x0, xtrue) - - # test with linear input - u = t - xtrue = np.array([1./6. * t**3, 0.5 * t**2]) - check(u, x0, xtrue) - - def test_discrete_initial(self): - h1 = TransferFunction([1.], [1., 0.], 1.) - t, yout = impulse_response(h1, np.arange(4)) - np.testing.assert_array_equal(yout, [0., 1., 0., 0.]) - - @unittest.skipIf(not slycot_check(), "slycot not installed") + @slycotonly def test_step_robustness(self): - "Unit test: https://github.com/python-control/python-control/issues/240" + "Test robustness os step_response against denomiantors: gh-240" # Create 2 input, 2 output system - num = [ [[0], [1]], [[1], [0]] ] - - den1 = [ [[1], [1,1]], [[1,4], [1]] ] + num = [[[0], [1]], [[1], [0]]] + + den1 = [[[1], [1,1]], [[1, 4], [1]]] sys1 = TransferFunction(num, den1) - den2 = [ [[1], [1e-10, 1, 1]], [[1,4], [1]] ] # slight perturbation + den2 = [[[1], [1e-10, 1, 1]], [[1, 4], [1]]] # slight perturbation sys2 = TransferFunction(num, den2) - # Compute step response from input 1 to output 1, 2 t1, y1 = step_response(sys1, input=0, T=2, T_num=100) t2, y2 = step_response(sys2, input=0, T=2, T_num=100) np.testing.assert_array_almost_equal(y1, y2) - def test_auto_generated_time_vector(self): - # confirm a TF with a pole at p simulates for ratio/p seconds - p = 0.5 - ratio = 9.21034*p # taken from code - ratio2 = 25*p - np.testing.assert_array_almost_equal( - _ideal_tfinal_and_dt(TransferFunction(1, [1, .5]))[0], - (ratio/p)) - np.testing.assert_array_almost_equal( - _ideal_tfinal_and_dt(TransferFunction(1, [1, .5]).sample(.1))[0], - (ratio2/p)) - # confirm a TF with poles at 0 and p simulates for ratio/p seconds - np.testing.assert_array_almost_equal( - _ideal_tfinal_and_dt(TransferFunction(1, [1, .5, 0]))[0], - (ratio2/p)) - - # confirm a TF with a natural frequency of wn rad/s gets a - # dt of 1/(ratio*wn) - wn = 10 - ratio_dt = 1/(0.025133 * ratio * wn) - np.testing.assert_array_almost_equal( - _ideal_tfinal_and_dt(TransferFunction(1, [1, 0, wn**2]))[1], - 1/(ratio_dt*ratio*wn)) - wn = 100 - np.testing.assert_array_almost_equal( - _ideal_tfinal_and_dt(TransferFunction(1, [1, 0, wn**2]))[1], - 1/(ratio_dt*ratio*wn)) - zeta = .1 - np.testing.assert_array_almost_equal( - _ideal_tfinal_and_dt(TransferFunction(1, [1, 2*zeta*wn, wn**2]))[1], - 1/(ratio_dt*ratio*wn)) - # but a smapled one keeps its dt - np.testing.assert_array_almost_equal( - _ideal_tfinal_and_dt(TransferFunction(1, [1, 2*zeta*wn, wn**2]).sample(.1))[1], - .1) - np.testing.assert_array_almost_equal( - np.diff(initial_response(TransferFunction(1, [1, 2*zeta*wn, wn**2]).sample(.1))[0][0:2]), - .1) - np.testing.assert_array_almost_equal( - _ideal_tfinal_and_dt(TransferFunction(1, [1, 2*zeta*wn, wn**2]))[1], - 1/(ratio_dt*ratio*wn)) - - - # TF with fast oscillations simulates only 5000 time steps even with long tfinal - self.assertEqual(5000, - len(_default_time_vector(TransferFunction(1, [1, 0, wn**2]),tfinal=100))) - - sys = TransferFunction(1, [1, .5, 0]) - sysdt = TransferFunction(1, [1, .5, 0], .1) - # test impose number of time steps - self.assertEqual(10, len(step_response(sys, T_num=10)[0])) - # test that discrete ignores T_num - self.assertNotEqual(15, len(step_response(sysdt, T_num=15)[0])) - # test impose final time - np.testing.assert_array_almost_equal( - 100, - np.ceil(step_response(sys, 100)[0][-1])) - np.testing.assert_array_almost_equal( - 100, - np.ceil(step_response(sysdt, 100)[0][-1])) - np.testing.assert_array_almost_equal( - 100, - np.ceil(impulse_response(sys, 100)[0][-1])) - np.testing.assert_array_almost_equal( - 100, - np.ceil(initial_response(sys, 100)[0][-1])) - - - def test_time_vector(self): - "Unit test: https://github.com/python-control/python-control/issues/239" - # Discrete time simulations with specified time vectors - Tin1 = np.arange(0, 5, 1) # matches dtf1, dss1; multiple of 0.2 - Tin2 = np.arange(0, 5, 0.2) # matches dtf2, dss2 - Tin3 = np.arange(0, 5, 0.5) # incompatible with 0.2 - - # Initial conditions to use for the different systems - siso_x0 = [1, 2] - mimo_x0 = [1, 2, 3, 4] - # - # Easy cases: make sure that output sample time matches input - # - # No timebase in system => output should match input - # - # Initial response - tout, yout = initial_response(self.siso_dtf1, Tin2, siso_x0, - squeeze=False) - self.assertEqual(np.shape(tout), np.shape(yout[0,:])) - np.testing.assert_array_equal(tout, Tin2) + @pytest.mark.parametrize( + "tfsys, tfinal", + [(TransferFunction(1, [1, .5]), 13.81551), # pole at 0.5 + (TransferFunction(1, [1, .5]).sample(.1), 25), # discrete pole at 0.5 + (TransferFunction(1, [1, .5, 0]), 25)]) # poles at 0.5 and 0 + def test_auto_generated_time_vector_tfinal(self, tfsys, tfinal): + """Confirm a TF with a pole at p simulates for tfinal seconds""" + ideal_tfinal, ideal_dt = _ideal_tfinal_and_dt(tfsys) + np.testing.assert_allclose(ideal_tfinal, tfinal, rtol=1e-4) + T = _default_time_vector(tfsys) + np.testing.assert_allclose(T[-1], tfinal, atol=0.5*ideal_dt) - # Impulse response - tout, yout = impulse_response(self.siso_dtf1, Tin2, - squeeze=False) - self.assertEqual(np.shape(tout), np.shape(yout[0,:])) - np.testing.assert_array_equal(tout, Tin2) + @pytest.mark.parametrize("wn, zeta", [(10, 0), (100, 0), (100, .1)]) + def test_auto_generated_time_vector_dt_cont1(self, wn, zeta): + """Confirm a TF with a natural frequency of wn rad/s gets a + dt of 1/(ratio*wn)""" - # Step response - tout, yout = step_response(self.siso_dtf1, Tin2, - squeeze=False) - self.assertEqual(np.shape(tout), np.shape(yout[0,:])) - np.testing.assert_array_equal(tout, Tin2) - - # Forced response with specified time vector - tout, yout, xout = forced_response(self.siso_dtf1, Tin2, np.sin(Tin2), - squeeze=False) - self.assertEqual(np.shape(tout), np.shape(yout[0,:])) - np.testing.assert_array_equal(tout, Tin2) - - # Forced response with no time vector, no sample time (should use 1) - tout, yout, xout = forced_response(self.siso_dtf1, None, np.sin(Tin1), - squeeze=False) - self.assertEqual(np.shape(tout), np.shape(yout[0,:])) - np.testing.assert_array_equal(tout, Tin1) - - # MIMO forced response - tout, yout, xout = forced_response(self.mimo_dss1, Tin1, - (np.sin(Tin1), np.cos(Tin1)), - mimo_x0) - self.assertEqual(np.shape(tout), np.shape(yout[0,:])) - self.assertEqual(np.shape(tout), np.shape(yout[1,:])) - np.testing.assert_array_equal(tout, Tin1) - - # Matching timebase in system => output should match input - # - # Initial response - tout, yout = initial_response(self.siso_dtf2, Tin2, siso_x0, - squeeze=False) - self.assertEqual(np.shape(tout), np.shape(yout[0,:])) - np.testing.assert_array_equal(tout, Tin2) + dtref = 0.25133 / wn - # Impulse response - tout, yout = impulse_response(self.siso_dtf2, Tin2, - squeeze=False) - self.assertEqual(np.shape(tout), np.shape(yout[0,:])) - np.testing.assert_array_equal(tout, Tin2) + tfsys = TransferFunction(1, [1, 2*zeta*wn, wn**2]) + np.testing.assert_almost_equal(_ideal_tfinal_and_dt(tfsys)[1], dtref, + decimal=5) - # Step response - tout, yout = step_response(self.siso_dtf2, Tin2, - squeeze=False) - self.assertEqual(np.shape(tout), np.shape(yout[0,:])) - np.testing.assert_array_equal(tout, Tin2) - - # Forced response - tout, yout, xout = forced_response(self.siso_dtf2, Tin2, np.sin(Tin2), - squeeze=False) - self.assertEqual(np.shape(tout), np.shape(yout[0,:])) - np.testing.assert_array_equal(tout, Tin2) - - # Forced response with no time vector, use sample time - tout, yout, xout = forced_response(self.siso_dtf2, None, np.sin(Tin2), - squeeze=False) - self.assertEqual(np.shape(tout), np.shape(yout[0,:])) - np.testing.assert_array_equal(tout, Tin2) - - # Compatible timebase in system => output should match input - # - # Initial response - tout, yout = initial_response(self.siso_dtf2, Tin1, siso_x0, - squeeze=False) - self.assertEqual(np.shape(tout), np.shape(yout[0,:])) - np.testing.assert_array_equal(tout, Tin1) + def test_auto_generated_time_vector_dt_cont2(self): + """A sampled tf keeps its dt""" + wn = 100 + zeta = .1 + tfsys = TransferFunction(1, [1, 2*zeta*wn, wn**2]).sample(.1) + tfinal, dt = _ideal_tfinal_and_dt(tfsys) + np.testing.assert_almost_equal(dt, .1) + T, _ = initial_response(tfsys) + np.testing.assert_almost_equal(np.diff(T[:2]), [.1]) - # Impulse response - tout, yout = impulse_response(self.siso_dtf2, Tin1, - squeeze=False) - self.assertEqual(np.shape(tout), np.shape(yout[0,:])) - np.testing.assert_array_equal(tout, Tin1) - # Step response - tout, yout = step_response(self.siso_dtf2, Tin1, - squeeze=False) - self.assertEqual(np.shape(tout), np.shape(yout[0,:])) - np.testing.assert_array_equal(tout, Tin1) + def test_default_timevector_long(self): + """Test long time vector""" - # Forced response - tout, yout, xout = forced_response(self.siso_dtf2, Tin1, np.sin(Tin1), - squeeze=False) - self.assertEqual(np.shape(tout), np.shape(yout[0,:])) - np.testing.assert_array_equal(tout, Tin1) + # TF with fast oscillations simulates only 5000 time steps + # even with long tfinal + wn = 100 + tfsys = TransferFunction(1, [1, 0, wn**2]) + tout = _default_time_vector(tfsys, tfinal=100) + assert len(tout) == 5000 + + @pytest.mark.parametrize("fun", [step_response, + impulse_response, + initial_response]) + def test_default_timevector_functions_c(self, fun): + """Test that functions can calculate the time vector automatically""" + sys = TransferFunction(1, [1, .5, 0]) + _tfinal, _dt = _ideal_tfinal_and_dt(sys) - # - # Interpolation of the input (to match scipy.signal.dlsim) - # - # Initial response - tout, yout, xout = forced_response(self.siso_dtf2, Tin1, - np.sin(Tin1), interpolate=True, - squeeze=False) - self.assertEqual(np.shape(tout), np.shape(yout[0,:])) - self.assertTrue(np.allclose(tout[1:] - tout[:-1], self.siso_dtf2.dt)) + # test impose number of time steps + tout, _ = fun(sys, T_num=10) + assert len(tout) == 10 - # - # Incompatible cases: make sure an error is thrown - # - # System timebase and given time vector are incompatible - # - # Initial response - with self.assertRaises(Exception) as context: - tout, yout = initial_response(self.siso_dtf2, Tin3, siso_x0, - squeeze=False) - self.assertTrue(isinstance(context.exception, ValueError)) - - def test_discrete_time_steps(self): - """Make sure rounding errors in sample time are handled properly""" - # See https://github.com/python-control/python-control/issues/332) - # - # These tests play around with the input time vector to make sure that - # small rounding errors don't generate spurious errors. + # test impose final time + tout, _ = fun(sys, T=100.) + np.testing.assert_allclose(tout[-1], 100., atol=0.5*_dt) - # Discrete time system to use for simulation - # self.siso_dtf2 = TransferFunction([1], [1, 1, 0.25], 0.2) + @pytest.mark.parametrize("fun", [step_response, + impulse_response, + initial_response]) + @pytest.mark.parametrize("dt", [0.1, 0.112]) + def test_default_timevector_functions_d(self, fun, dt): + """Test that functions can calculate the time vector automatically""" + sys = TransferFunction(1, [1, .5, 0], dt) + + # test impose number of time steps is ignored with dt given + tout, _ = fun(sys, T_num=15) + assert len(tout) != 15 + + # test impose final time + tout, _ = fun(sys, 100) + np.testing.assert_allclose(tout[-1], 100., atol=0.5*dt) + + + @pytest.mark.parametrize("tsystem", + ["siso_ss2", # continuous + "siso_tf1", + "siso_dss1", # no timebase + "siso_dtf1", + "siso_dss2", # matching timebase + "siso_dtf2", + "mimo_ss2", # MIMO + pytest.param("mimo_tf2", marks=slycotonly), + "mimo_dss1", + pytest.param("mimo_dtf1", marks=slycotonly), + ], + indirect=True) + @pytest.mark.parametrize("fun", [step_response, + impulse_response, + initial_response, + forced_response]) + @pytest.mark.parametrize("squeeze", [None, True, False]) + def test_time_vector(self, tsystem, fun, squeeze, matarrayout): + """Test time vector handling and correct output convention + + gh-239, gh-295 + """ + sys = tsystem.sys + + kw = {} + if hasattr(tsystem, "t"): + t = tsystem.t + kw['T'] = t + if fun == forced_response: + kw['U'] = np.vstack([np.sin(t) for i in range(sys.ninputs)]) + elif fun == forced_response and isctime(sys): + pytest.skip("No continuous forced_response without time vector.") + if hasattr(tsystem.sys, "nstates"): + kw['X0'] = np.arange(sys.nstates) + 1 + if sys.ninputs > 1 and fun in [step_response, impulse_response]: + kw['input'] = 1 + if squeeze is not None: + kw['squeeze'] = squeeze + + out = fun(sys, **kw) + tout, yout = out[:2] + + assert tout.ndim == 1 + if hasattr(tsystem, 't'): + # tout should always match t, which has shape (n, ) + np.testing.assert_allclose(tout, tsystem.t) + + if squeeze is False or not sys.issiso(): + assert yout.shape[0] == sys.noutputs + assert yout.shape[-1] == tout.shape[0] + else: + assert yout.shape == tout.shape + + if sys.dt > 0 and sys.dt is not True and not np.isclose(sys.dt, 0.5): + kw['T'] = np.arange(0, 5, 0.5) # incompatible timebase + with pytest.raises(ValueError): + fun(sys, **kw) + + @pytest.mark.parametrize("squeeze", [None, True, False]) + def test_time_vector_interpolation(self, siso_dtf2, squeeze): + """Test time vector handling in case of interpolation + + Interpolation of the input (to match scipy.signal.dlsim) + + gh-239, gh-295 + """ + sys = siso_dtf2.sys + t = np.arange(0, 10, 1.) + u = np.sin(t) + x0 = 0 + + squeezekw = {} if squeeze is None else {"squeeze": squeeze} + + tout, yout = forced_response(sys, t, u, x0, + interpolate=True, **squeezekw) + if squeeze is False or sys.noutputs > 1: + assert yout.shape[0] == sys.noutputs + assert yout.shape[1] == tout.shape[0] + else: + assert yout.shape == tout.shape + assert np.allclose(tout[1:] - tout[:-1], sys.dt) + + def test_discrete_time_steps(self, siso_dtf2): + """Make sure rounding errors in sample time are handled properly + + These tests play around with the input time vector to make sure that + small rounding errors don't generate spurious errors. + + gh-332 + """ + sys = siso_dtf2.sys # Set up a time range and simulate T = np.arange(0, 100, 0.2) - tout1, yout1 = step_response(self.siso_dtf2, T) + tout1, yout1 = step_response(sys, T) # Simulate every other time step T = np.arange(0, 100, 0.4) - tout2, yout2 = step_response(self.siso_dtf2, T) + tout2, yout2 = step_response(sys, T) np.testing.assert_array_almost_equal(tout1[::2], tout2) np.testing.assert_array_almost_equal(yout1[::2], yout2) # Add a small error into some of the time steps T = np.arange(0, 100, 0.2) T[1:-2:2] -= 1e-12 # tweak second value and a few others - tout3, yout3 = step_response(self.siso_dtf2, T) + tout3, yout3 = step_response(sys, T) np.testing.assert_array_almost_equal(tout1, tout3) np.testing.assert_array_almost_equal(yout1, yout3) # Add a small error into some of the time steps (w/ skipping) T = np.arange(0, 100, 0.4) T[1:-2:2] -= 1e-12 # tweak second value and a few others - tout4, yout4 = step_response(self.siso_dtf2, T) + tout4, yout4 = step_response(sys, T) np.testing.assert_array_almost_equal(tout2, tout4) np.testing.assert_array_almost_equal(yout2, yout4) # Make sure larger errors *do* generate an error T = np.arange(0, 100, 0.2) T[1:-2:2] -= 1e-3 # change second value and a few others - self.assertRaises(ValueError, step_response, self.siso_dtf2, T) - - def test_time_series_data_convention(self): - """Make sure time series data matches documentation conventions""" - # SISO continuous time - t, y = step_response(self.siso_ss1) - self.assertTrue(isinstance(t, np.ndarray) - and not isinstance(t, np.matrix)) - self.assertTrue(len(t.shape) == 1) - self.assertTrue(len(y.shape) == 1) # SISO returns "scalar" output - self.assertTrue(len(t) == len(y)) # Allows direct plotting of output - - # SISO discrete time - t, y = step_response(self.siso_dss1) - self.assertTrue(isinstance(t, np.ndarray) - and not isinstance(t, np.matrix)) - self.assertTrue(len(t.shape) == 1) - self.assertTrue(len(y.shape) == 1) # SISO returns "scalar" output - self.assertTrue(len(t) == len(y)) # Allows direct plotting of output - - # MIMO continuous time - tin = np.linspace(0, 10, 100) - uin = [np.sin(tin), np.cos(tin)] - t, y, x = forced_response(self.mimo_ss1, tin, uin) - self.assertTrue(isinstance(t, np.ndarray) - and not isinstance(t, np.matrix)) - self.assertTrue(len(t.shape) == 1) - self.assertTrue(len(y[0].shape) == 1) - self.assertTrue(len(y[1].shape) == 1) - self.assertTrue(len(t) == len(y[0])) - self.assertTrue(len(t) == len(y[1])) - - # MIMO discrete time - tin = np.linspace(0, 10, 100) - uin = [np.sin(tin), np.cos(tin)] - t, y, x = forced_response(self.mimo_dss1, tin, uin) - self.assertTrue(isinstance(t, np.ndarray) - and not isinstance(t, np.matrix)) - self.assertTrue(len(t.shape) == 1) - self.assertTrue(len(y[0].shape) == 1) - self.assertTrue(len(y[1].shape) == 1) - self.assertTrue(len(t) == len(y[0])) - self.assertTrue(len(t) == len(y[1])) - - # Allow input time as 2D array (output should be 1D) + with pytest.raises(ValueError): + step_response(sys, T) + + def test_time_series_data_convention_2D(self, siso_ss1): + """Allow input time as 2D array (output should be 1D)""" tin = np.array(np.linspace(0, 10, 100), ndmin=2) - t, y = step_response(self.siso_ss1, tin) - self.assertTrue(isinstance(t, np.ndarray) - and not isinstance(t, np.matrix)) - self.assertTrue(len(t.shape) == 1) - self.assertTrue(len(y.shape) == 1) # SISO returns "scalar" output - self.assertTrue(len(t) == len(y)) # Allows direct plotting of output + t, y = step_response(siso_ss1.sys, tin) + assert isinstance(t, np.ndarray) and not isinstance(t, np.matrix) + assert t.ndim == 1 + assert y.ndim == 1 # SISO returns "scalar" output + assert t.shape == y.shape # Allows direct plotting of output + + @pytest.mark.usefixtures("editsdefaults") + @pytest.mark.parametrize("fcn", [ct.ss, ct.tf, ct.ss2io]) + @pytest.mark.parametrize("nstate, nout, ninp, squeeze, shape1, shape2", [ + # state out in squeeze in/out out-only + [1, 1, 1, None, (8,), (8,)], + [2, 1, 1, True, (8,), (8,)], + [3, 1, 1, False, (1, 1, 8), (1, 8)], + [3, 2, 1, None, (2, 1, 8), (2, 8)], + [4, 2, 1, True, (2, 8), (2, 8)], + [5, 2, 1, False, (2, 1, 8), (2, 8)], + [3, 1, 2, None, (1, 2, 8), (1, 8)], + [4, 1, 2, True, (2, 8), (8,)], + [5, 1, 2, False, (1, 2, 8), (1, 8)], + [4, 2, 2, None, (2, 2, 8), (2, 8)], + [5, 2, 2, True, (2, 2, 8), (2, 8)], + [6, 2, 2, False, (2, 2, 8), (2, 8)], + ]) + def test_squeeze(self, fcn, nstate, nout, ninp, squeeze, shape1, shape2): + # Figure out if we have SciPy 1+ + scipy0 = StrictVersion(sp.__version__) < '1.0' + + # Define the system + if fcn == ct.tf and (nout > 1 or ninp > 1) and not slycot_check(): + pytest.skip("Conversion of MIMO systems to transfer functions " + "requires slycot.") + else: + sys = fcn(ct.rss(nstate, nout, ninp, strictly_proper=True)) + + # Generate the time and input vectors + tvec = np.linspace(0, 1, 8) + uvec = np.dot( + np.ones((sys.ninputs, 1)), + np.reshape(np.sin(tvec), (1, 8))) + # + # Pass squeeze argument and make sure the shape is correct + # + # For responses that are indexed by the input, check against shape1 + # For responses that have no/fixed input, check against shape2 + # -if __name__ == '__main__': - unittest.main() + # Impulse response + if isinstance(sys, StateSpace): + # Check the states as well + _, yvec, xvec = ct.impulse_response( + sys, tvec, squeeze=squeeze, return_x=True) + if sys.issiso(): + assert xvec.shape == (sys.nstates, 8) + else: + assert xvec.shape == (sys.nstates, sys.ninputs, 8) + else: + _, yvec = ct.impulse_response(sys, tvec, squeeze=squeeze) + assert yvec.shape == shape1 + + # Step response + if isinstance(sys, StateSpace): + # Check the states as well + _, yvec, xvec = ct.step_response( + sys, tvec, squeeze=squeeze, return_x=True) + if sys.issiso(): + assert xvec.shape == (sys.nstates, 8) + else: + assert xvec.shape == (sys.nstates, sys.ninputs, 8) + else: + _, yvec = ct.step_response(sys, tvec, squeeze=squeeze) + assert yvec.shape == shape1 + + # Initial response (only indexed by output) + if isinstance(sys, StateSpace): + # Check the states as well + _, yvec, xvec = ct.initial_response( + sys, tvec, 1, squeeze=squeeze, return_x=True) + assert xvec.shape == (sys.nstates, 8) + else: + _, yvec = ct.initial_response(sys, tvec, 1, squeeze=squeeze) + assert yvec.shape == shape2 + + # Forced response (only indexed by output) + if isinstance(sys, StateSpace): + # Check the states as well + _, yvec, xvec = ct.forced_response( + sys, tvec, uvec, 0, return_x=True, squeeze=squeeze) + assert xvec.shape == (sys.nstates, 8) + else: + # Just check the input/output response + _, yvec = ct.forced_response(sys, tvec, uvec, 0, squeeze=squeeze) + assert yvec.shape == shape2 + + # Test cases where we choose a subset of inputs and outputs + _, yvec = ct.step_response( + sys, tvec, input=ninp-1, output=nout-1, squeeze=squeeze) + if squeeze is False: + # Shape should be unsqueezed + assert yvec.shape == (1, 1, 8) + else: + # Shape should be squeezed + assert yvec.shape == (8, ) + + # For InputOutputSystems, also test input/output response + if isinstance(sys, ct.InputOutputSystem) and not scipy0: + _, yvec = ct.input_output_response(sys, tvec, uvec, squeeze=squeeze) + assert yvec.shape == shape2 + + # + # Changing config.default to False should return 3D frequency response + # + ct.config.set_defaults('control', squeeze_time_response=False) + + _, yvec = ct.impulse_response(sys, tvec) + if squeeze is not True or sys.ninputs > 1 or sys.noutputs > 1: + assert yvec.shape == (sys.noutputs, sys.ninputs, 8) + + _, yvec = ct.step_response(sys, tvec) + if squeeze is not True or sys.ninputs > 1 or sys.noutputs > 1: + assert yvec.shape == (sys.noutputs, sys.ninputs, 8) + + _, yvec = ct.initial_response(sys, tvec, 1) + if squeeze is not True or sys.noutputs > 1: + assert yvec.shape == (sys.noutputs, 8) + + if isinstance(sys, ct.StateSpace): + _, yvec, xvec = ct.forced_response( + sys, tvec, uvec, 0, return_x=True) + assert xvec.shape == (sys.nstates, 8) + else: + _, yvec = ct.forced_response(sys, tvec, uvec, 0) + if squeeze is not True or sys.noutputs > 1: + assert yvec.shape == (sys.noutputs, 8) + + # For InputOutputSystems, also test input_output_response + if isinstance(sys, ct.InputOutputSystem) and not scipy0: + _, yvec = ct.input_output_response(sys, tvec, uvec) + if squeeze is not True or sys.noutputs > 1: + assert yvec.shape == (sys.noutputs, 8) + + @pytest.mark.parametrize("fcn", [ct.ss, ct.tf, ct.ss2io]) + def test_squeeze_exception(self, fcn): + sys = fcn(ct.rss(2, 1, 1)) + with pytest.raises(ValueError, match="unknown squeeze value"): + step_response(sys, squeeze=1) + + @pytest.mark.usefixtures("editsdefaults") + @pytest.mark.parametrize("nstate, nout, ninp, squeeze, shape", [ + [1, 1, 1, None, (8,)], + [2, 1, 1, True, (8,)], + [3, 1, 1, False, (1, 8)], + [1, 2, 1, None, (2, 8)], + [2, 2, 1, True, (2, 8)], + [3, 2, 1, False, (2, 8)], + [1, 1, 2, None, (8,)], + [2, 1, 2, True, (8,)], + [3, 1, 2, False, (1, 8)], + [1, 2, 2, None, (2, 8)], + [2, 2, 2, True, (2, 8)], + [3, 2, 2, False, (2, 8)], + ]) + def test_squeeze_0_8_4(self, nstate, nout, ninp, squeeze, shape): + # Set defaults to match release 0.8.4 + ct.config.use_legacy_defaults('0.8.4') + ct.config.use_numpy_matrix(False) + + # Generate system, time, and input vectors + sys = ct.rss(nstate, nout, ninp, strictly_proper=True) + tvec = np.linspace(0, 1, 8) + uvec = np.dot( + np.ones((sys.ninputs, 1)), + np.reshape(np.sin(tvec), (1, 8))) + + _, yvec = ct.initial_response(sys, tvec, 1, squeeze=squeeze) + assert yvec.shape == shape + + @pytest.mark.parametrize( + "nstate, nout, ninp, squeeze, ysh_in, ysh_no, xsh_in", [ + [4, 1, 1, None, (8,), (8,), (8, 4)], + [4, 1, 1, True, (8,), (8,), (8, 4)], + [4, 1, 1, False, (8, 1, 1), (8, 1), (8, 4)], + [4, 2, 1, None, (8, 2, 1), (8, 2), (8, 4, 1)], + [4, 2, 1, True, (8, 2), (8, 2), (8, 4, 1)], + [4, 2, 1, False, (8, 2, 1), (8, 2), (8, 4, 1)], + [4, 1, 2, None, (8, 1, 2), (8, 1), (8, 4, 2)], + [4, 1, 2, True, (8, 2), (8,), (8, 4, 2)], + [4, 1, 2, False, (8, 1, 2), (8, 1), (8, 4, 2)], + [4, 2, 2, None, (8, 2, 2), (8, 2), (8, 4, 2)], + [4, 2, 2, True, (8, 2, 2), (8, 2), (8, 4, 2)], + [4, 2, 2, False, (8, 2, 2), (8, 2), (8, 4, 2)], + ]) + def test_response_transpose( + self, nstate, nout, ninp, squeeze, ysh_in, ysh_no, xsh_in): + sys = ct.rss(nstate, nout, ninp) + T = np.linspace(0, 1, 8) + + # Step response - input indexed + t, y, x = ct.step_response( + sys, T, transpose=True, return_x=True, squeeze=squeeze) + assert t.shape == (T.size, ) + assert y.shape == ysh_in + assert x.shape == xsh_in + + # Initial response - no input indexing + t, y, x = ct.initial_response( + sys, T, 1, transpose=True, return_x=True, squeeze=squeeze) + assert t.shape == (T.size, ) + assert y.shape == ysh_no + assert x.shape == (T.size, sys.nstates) diff --git a/control/tests/type_conversion_test.py b/control/tests/type_conversion_test.py new file mode 100644 index 000000000..3f51c2bbc --- /dev/null +++ b/control/tests/type_conversion_test.py @@ -0,0 +1,187 @@ +# type_conversion_test.py - test type conversions +# RMM, 3 Jan 2021 +# +# This set of tests looks at how various classes are converted when using +# algebraic operations. See GitHub issue #459 for some discussion on what the +# desired combinations should be. + +import control as ct +import numpy as np +import operator +import pytest + +@pytest.fixture() +def sys_dict(): + sdict = {} + sdict['ss'] = ct.ss([[-1]], [[1]], [[1]], [[0]]) + sdict['tf'] = ct.tf([1],[0.5, 1]) + sdict['tfx'] = ct.tf([1, 1],[1]) # non-proper transfer function + sdict['frd'] = ct.frd([10+0j, 9 + 1j, 8 + 2j], [1,2,3]) + sdict['lio'] = ct.LinearIOSystem(ct.ss([[-1]], [[5]], [[5]], [[0]])) + sdict['ios'] = ct.NonlinearIOSystem( + sdict['lio']._rhs, sdict['lio']._out, 1, 1, 1) + sdict['arr'] = np.array([[2.0]]) + sdict['flt'] = 3. + return sdict + +type_dict = { + 'ss': ct.StateSpace, 'tf': ct.TransferFunction, + 'frd': ct.FrequencyResponseData, 'lio': ct.LinearICSystem, + 'ios': ct.InterconnectedSystem, 'arr': np.ndarray, 'flt': float} + +# +# Current table of expected conversions +# +# This table describes all of the conversions that are supposed to +# happen for various system combinations. This is written out this way +# to make it easy to read, but this is converted below into a list of +# specific tests that can be iterated over. +# +# Items marked as 'E' should generate an exception. +# +# Items starting with 'x' currently generate an expected exception but +# should eventually generate a useful result (when everything is +# implemented properly). +# +# Note 1: some of the entries below are currently converted to to lower level +# types than needed. In particular, LinearIOSystems should combine with +# StateSpace and TransferFunctions in a way that preserves I/O system +# structure when possible. +# +# Note 2: eventually the operator entry for this table can be pulled out and +# tested as a separate parameterized variable (since all operators should +# return consistent values). +# +# Note 3: this table documents the current state, but not actually the desired +# state. See bottom of the file for the (eventual) desired behavior. +# + +rtype_list = ['ss', 'tf', 'frd', 'lio', 'ios', 'arr', 'flt'] +conversion_table = [ + # op left ss tf frd lio ios arr flt + ('add', 'ss', ['ss', 'ss', 'xrd', 'ss', 'xos', 'ss', 'ss' ]), + ('add', 'tf', ['tf', 'tf', 'xrd', 'tf', 'xos', 'tf', 'tf' ]), + ('add', 'frd', ['xrd', 'xrd', 'frd', 'xrd', 'E', 'xrd', 'xrd']), + ('add', 'lio', ['xio', 'xio', 'xrd', 'lio', 'ios', 'xio', 'xio']), + ('add', 'ios', ['xos', 'xos', 'E', 'ios', 'ios', 'xos', 'xos']), + ('add', 'arr', ['ss', 'tf', 'xrd', 'xio', 'xos', 'arr', 'arr']), + ('add', 'flt', ['ss', 'tf', 'xrd', 'xio', 'xos', 'arr', 'flt']), + + # op left ss tf frd lio ios arr flt + ('sub', 'ss', ['ss', 'ss', 'xrd', 'ss', 'xos', 'ss', 'ss' ]), + ('sub', 'tf', ['tf', 'tf', 'xrd', 'tf', 'xos', 'tf', 'tf' ]), + ('sub', 'frd', ['xrd', 'xrd', 'frd', 'xrd', 'E', 'xrd', 'xrd']), + ('sub', 'lio', ['xio', 'xio', 'xrd', 'lio', 'ios', 'xio', 'xio']), + ('sub', 'ios', ['xos', 'xio', 'E', 'ios', 'xos' 'xos', 'xos']), + ('sub', 'arr', ['ss', 'tf', 'xrd', 'xio', 'xos', 'arr', 'arr']), + ('sub', 'flt', ['ss', 'tf', 'xrd', 'xio', 'xos', 'arr', 'flt']), + + # op left ss tf frd lio ios arr flt + ('mul', 'ss', ['ss', 'ss', 'xrd', 'ss', 'xos', 'ss', 'ss' ]), + ('mul', 'tf', ['tf', 'tf', 'xrd', 'tf', 'xos', 'tf', 'tf' ]), + ('mul', 'frd', ['xrd', 'xrd', 'frd', 'xrd', 'E', 'xrd', 'frd']), + ('mul', 'lio', ['xio', 'xio', 'xrd', 'lio', 'ios', 'xio', 'xio']), + ('mul', 'ios', ['xos', 'xos', 'E', 'ios', 'ios', 'xos', 'xos']), + ('mul', 'arr', ['ss', 'tf', 'xrd', 'xio', 'xos', 'arr', 'arr']), + ('mul', 'flt', ['ss', 'tf', 'frd', 'xio', 'xos', 'arr', 'flt']), + + # op left ss tf frd lio ios arr flt + ('truediv', 'ss', ['xs', 'tf', 'xrd', 'xio', 'xos', 'xs', 'xs' ]), + ('truediv', 'tf', ['tf', 'tf', 'xrd', 'tf', 'xos', 'tf', 'tf' ]), + ('truediv', 'frd', ['xrd', 'xrd', 'frd', 'xrd', 'E', 'xrd', 'frd']), + ('truediv', 'lio', ['xio', 'tf', 'xrd', 'xio', 'xio', 'xio', 'xio']), + ('truediv', 'ios', ['xos', 'xos', 'E', 'xos', 'xos' 'xos', 'xos']), + ('truediv', 'arr', ['xs', 'tf', 'xrd', 'xio', 'xos', 'arr', 'arr']), + ('truediv', 'flt', ['xs', 'tf', 'frd', 'xio', 'xos', 'arr', 'flt'])] + +# Now create list of the tests we actually want to run +test_matrix = [] +for i, (opname, ltype, expected_list) in enumerate(conversion_table): + for rtype, expected in zip(rtype_list, expected_list): + # Add this to the list of tests to run + test_matrix.append([opname, ltype, rtype, expected]) + +@pytest.mark.parametrize("opname, ltype, rtype, expected", test_matrix) +def test_operator_type_conversion(opname, ltype, rtype, expected, sys_dict): + op = getattr(operator, opname) + leftsys = sys_dict[ltype] + rightsys = sys_dict[rtype] + + # Get rid of warnings for InputOutputSystem objects by making a copy + if isinstance(leftsys, ct.InputOutputSystem) and leftsys == rightsys: + rightsys = leftsys.copy() + + # Make sure we get the right result + if expected == 'E' or expected[0] == 'x': + # Exception expected + with pytest.raises(TypeError): + op(leftsys, rightsys) + else: + # Operation should work and return the given type + result = op(leftsys, rightsys) + + # Print out what we are testing in case something goes wrong + assert isinstance(result, type_dict[expected]) + +# +# Updated table that describes desired outputs for all operators +# +# General rules (subject to change) +# +# * For LTI/LTI, keep the type of the left operand whenever possible. This +# prioritizes the first operand, but we need to watch out for non-proper +# transfer functions (in which case TransferFunction should be returned) +# +# * For FRD/LTI, convert LTI to FRD by evaluating the LTI transfer function +# at the FRD frequencies (can't got the other way since we can't convert +# an FRD object to state space/transfer function). +# +# * For IOS/LTI, convert to IOS. In the case of a linear I/O system (LIO), +# this will preserve the linear structure since the LTI system will +# be converted to state space. +# +# * When combining state space or transfer with linear I/O systems, the +# * output should be of type Linear IO system, since that maintains the +# * underlying state space attributes. +# +# Note: tfx = non-proper transfer function, order(num) > order(den) +# + +type_list = ['ss', 'tf', 'tfx', 'frd', 'lio', 'ios', 'arr', 'flt'] +conversion_table = [ + # L \ R ['ss', 'tf', 'tfx', 'frd', 'lio', 'ios', 'arr', 'flt'] + ('ss', ['ss', 'ss', 'tf' 'frd', 'lio', 'ios', 'ss', 'ss' ]), + ('tf', ['tf', 'tf', 'tf' 'frd', 'lio', 'ios', 'tf', 'tf' ]), + ('tfx', ['tf', 'tf', 'tf', 'frd', 'E', 'E', 'tf', 'tf' ]), + ('frd', ['frd', 'frd', 'frd', 'frd', 'E', 'E', 'frd', 'frd']), + ('lio', ['lio', 'lio', 'E', 'E', 'lio', 'ios', 'lio', 'lio']), + ('ios', ['ios', 'ios', 'E', 'E', 'ios', 'ios', 'ios', 'ios']), + ('arr', ['ss', 'tf', 'tf' 'frd', 'lio', 'ios', 'arr', 'arr']), + ('flt', ['ss', 'tf', 'tf' 'frd', 'lio', 'ios', 'arr', 'flt'])] + +@pytest.mark.skip(reason="future test; conversions not yet fully implemented") +# @pytest.mark.parametrize("opname", ['add', 'sub', 'mul', 'truediv']) +# @pytest.mark.parametrize("ltype", type_list) +# @pytest.mark.parametrize("rtype", type_list) +def test_binary_op_type_conversions(opname, ltype, rtype, sys_dict): + op = getattr(operator, opname) + leftsys = sys_dict[ltype] + rightsys = sys_dict[rtype] + expected = \ + conversion_table[type_list.index(ltype)][1][type_list.index(rtype)] + + # Get rid of warnings for InputOutputSystem objects by making a copy + if isinstance(leftsys, ct.InputOutputSystem) and leftsys == rightsys: + rightsys = leftsys.copy() + + # Make sure we get the right result + if expected == 'E' or expected[0] == 'x': + # Exception expected + with pytest.raises(TypeError): + op(leftsys, rightsys) + else: + # Operation should work and return the given type + result = op(leftsys, rightsys) + + # Print out what we are testing in case something goes wrong + assert isinstance(result, type_dict[expected]) diff --git a/control/tests/xferfcn_input_test.py b/control/tests/xferfcn_input_test.py index 52fb85c29..00024ba4c 100644 --- a/control/tests/xferfcn_input_test.py +++ b/control/tests/xferfcn_input_test.py @@ -1,259 +1,79 @@ -#!/usr/bin/env python -# -# xferfcn_input_test.py - test inputs to TransferFunction class -# jed-frey, 18 Feb 2017 (based on xferfcn_test.py) +"""xferfcn_input_test.py - test inputs to TransferFunction class -import unittest -import numpy as np +jed-frey, 18 Feb 2017 (based on xferfcn_test.py) +BG, 31 Jul 2020 convert to pytest and parametrize into single function +""" -from numpy import int, int8, int16, int32, int64 -from numpy import float, float16, float32, float64, longdouble -from numpy import all, ndarray, array +import numpy as np +import pytest from control.xferfcn import _clean_part - -class TestXferFcnInput(unittest.TestCase): - """These are tests for functionality of cleaning and validating XferFcnInput.""" - - # Tests for raising exceptions. - def test_clean_part_bad_input_type(self): - """Give the part cleaner invalid input type.""" - - self.assertRaises(TypeError, _clean_part, [[0., 1.], [2., 3.]]) - - def test_clean_part_bad_input_type2(self): - """Give the part cleaner another invalid input type.""" - self.assertRaises(TypeError, _clean_part, [1, "a"]) - - def test_clean_part_scalar(self): - """Test single scalar value.""" - num = 1 - num_ = _clean_part(num) - - assert isinstance(num_, list) - assert np.all([isinstance(part, list) for part in num_]) - np.testing.assert_array_equal(num_[0][0], array([1.0], dtype=float)) - - def test_clean_part_list_scalar(self): - """Test single scalar value in list.""" - num = [1] - num_ = _clean_part(num) - - assert isinstance(num_, list) - assert np.all([isinstance(part, list) for part in num_]) - np.testing.assert_array_equal(num_[0][0], array([1.0], dtype=float)) - - def test_clean_part_tuple_scalar(self): - """Test single scalar value in tuple.""" - num = (1) - num_ = _clean_part(num) - - assert isinstance(num_, list) - assert np.all([isinstance(part, list) for part in num_]) - np.testing.assert_array_equal(num_[0][0], array([1.0], dtype=float)) - - def test_clean_part_list(self): - """Test multiple values in a list.""" - num = [1, 2] - num_ = _clean_part(num) - - assert isinstance(num_, list) - assert np.all([isinstance(part, list) for part in num_]) - np.testing.assert_array_equal(num_[0][0], array([1.0, 2.0], dtype=float)) - - def test_clean_part_tuple(self): - """Test multiple values in tuple.""" - num = (1, 2) - num_ = _clean_part(num) - - assert isinstance(num_, list) - assert np.all([isinstance(part, list) for part in num_]) - np.testing.assert_array_equal(num_[0][0], array([1.0, 2.0], dtype=float)) - - def test_clean_part_all_scalar_types(self): - """Test single scalar value for all valid data types.""" - for dtype in [int, int8, int16, int32, int64, float, float16, float32, float64, longdouble]: - num = dtype(1) - num_ = _clean_part(num) - - assert isinstance(num_, list) - assert np.all([isinstance(part, list) for part in num_]) - np.testing.assert_array_equal(num_[0][0], array([1.0], dtype=float)) - - def test_clean_part_np_array(self): - """Test multiple values in numpy array.""" - num = np.array([1, 2]) - num_ = _clean_part(num) - - assert isinstance(num_, list) - assert np.all([isinstance(part, list) for part in num_]) - np.testing.assert_array_equal(num_[0][0], array([1.0, 2.0], dtype=float)) - - def test_clean_part_all_np_array_types(self): - """Test scalar value in numpy array of ndim=0 for all data types.""" - for dtype in [int, int8, int16, int32, int64, float, float16, float32, float64, longdouble]: - num = np.array(1, dtype=dtype) - num_ = _clean_part(num) - - assert isinstance(num_, list) - assert np.all([isinstance(part, list) for part in num_]) - np.testing.assert_array_equal(num_[0][0], array([1.0], dtype=float)) - - def test_clean_part_all_np_array_types2(self): - """Test numpy array for all types.""" - for dtype in [int, int8, int16, int32, int64, float, float16, float32, float64, longdouble]: - num = np.array([1, 2], dtype=dtype) - num_ = _clean_part(num) - - assert isinstance(num_, list) - assert np.all([isinstance(part, list) for part in num_]) - np.testing.assert_array_equal(num_[0][0], array([1.0, 2.0], dtype=float)) - - def test_clean_part_list_all_types(self): - """Test list of a single value for all data types.""" - for dtype in [int, int8, int16, int32, int64, float, float16, float32, float64, longdouble]: - num = [dtype(1)] - num_ = _clean_part(num) - assert isinstance(num_, list) - assert np.all([isinstance(part, list) for part in num_]) - np.testing.assert_array_equal(num_[0][0], array([1.0], dtype=float)) - - def test_clean_part_list_all_types2(self): - """List of list of numbers of all data types.""" - for dtype in [int, int8, int16, int32, int64, float, float16, float32, float64, longdouble]: - num = [dtype(1), dtype(2)] - num_ = _clean_part(num) - assert isinstance(num_, list) - assert np.all([isinstance(part, list) for part in num_]) - np.testing.assert_array_equal(num_[0][0], array([1.0, 2.0], dtype=float)) - - def test_clean_part_tuple_all_types(self): - """Test tuple of a single value for all data types.""" - for dtype in [int, int8, int16, int32, int64, float, float16, float32, float64, longdouble]: - num = (dtype(1),) - num_ = _clean_part(num) - assert isinstance(num_, list) - assert np.all([isinstance(part, list) for part in num_]) - np.testing.assert_array_equal(num_[0][0], array([1.0], dtype=float)) - - def test_clean_part_tuple_all_types2(self): - """Test tuple of a single value for all data types.""" - for dtype in [int, int8, int16, int32, int64, float, float16, float32, float64, longdouble]: - num = (dtype(1), dtype(2)) - num_ = _clean_part(num) - assert isinstance(num_, list) - assert np.all([isinstance(part, list) for part in num_]) - np.testing.assert_array_equal(num_[0][0], array([1, 2], dtype=float)) - - def test_clean_part_list_list_list_int(self): - """ Test an int in a list of a list of a list.""" - num = [[[1]]] - num_ = _clean_part(num) - assert isinstance(num_, list) - assert np.all([isinstance(part, list) for part in num_]) - np.testing.assert_array_equal(num_[0][0], array([1.0], dtype=float)) - - def test_clean_part_list_list_list_float(self): - """ Test a float in a list of a list of a list.""" - num = [[[1.0]]] - num_ = _clean_part(num) - assert isinstance(num_, list) - assert np.all([isinstance(part, list) for part in num_]) - np.testing.assert_array_equal(num_[0][0], array([1.0], dtype=float)) - - def test_clean_part_list_list_list_ints(self): - """Test 2 lists of ints in a list in a list.""" - num = [[[1, 1], [2, 2]]] - num_ = _clean_part(num) - - assert isinstance(num_, list) - assert np.all([isinstance(part, list) for part in num_]) - np.testing.assert_array_equal(num_[0][0], array([1.0, 1.0], dtype=float)) - np.testing.assert_array_equal(num_[0][1], array([2.0, 2.0], dtype=float)) - - def test_clean_part_list_list_list_floats(self): - """Test 2 lists of ints in a list in a list.""" - num = [[[1.0, 1.0], [2.0, 2.0]]] - num_ = _clean_part(num) - - assert isinstance(num_, list) - assert np.all([isinstance(part, list) for part in num_]) - np.testing.assert_array_equal(num_[0][0], array([1.0, 1.0], dtype=float)) - np.testing.assert_array_equal(num_[0][1], array([2.0, 2.0], dtype=float)) - - def test_clean_part_list_list_array(self): - """List of list of numpy arrays for all valid types.""" - for dtype in int, int8, int16, int32, int64, float, float16, float32, float64, longdouble: - num = [[array([1, 1], dtype=dtype), array([2, 2], dtype=dtype)]] - num_ = _clean_part(num) - - assert isinstance(num_, list) - assert np.all([isinstance(part, list) for part in num_]) - np.testing.assert_array_equal(num_[0][0], array([1.0, 1.0], dtype=float)) - np.testing.assert_array_equal(num_[0][1], array([2.0, 2.0], dtype=float)) - - def test_clean_part_tuple_list_array(self): - """Tuple of list of numpy arrays for all valid types.""" - for dtype in int, int8, int16, int32, int64, float, float16, float32, float64, longdouble: - num = ([array([1, 1], dtype=dtype), array([2, 2], dtype=dtype)],) - num_ = _clean_part(num) - - assert isinstance(num_, list) - assert np.all([isinstance(part, list) for part in num_]) - np.testing.assert_array_equal(num_[0][0], array([1.0, 1.0], dtype=float)) - np.testing.assert_array_equal(num_[0][1], array([2.0, 2.0], dtype=float)) - - def test_clean_part_list_tuple_array(self): - """List of tuple of numpy array for all valid types.""" - for dtype in int, int8, int16, int32, int64, float, float16, float32, float64, longdouble: - num = [(array([1, 1], dtype=dtype), array([2, 2], dtype=dtype))] - num_ = _clean_part(num) - - assert isinstance(num_, list) - assert np.all([isinstance(part, list) for part in num_]) - np.testing.assert_array_equal(num_[0][0], array([1.0, 1.0], dtype=float)) - np.testing.assert_array_equal(num_[0][1], array([2.0, 2.0], dtype=float)) - - def test_clean_part_tuple_tuples_arrays(self): - """Tuple of tuples of numpy arrays for all valid types.""" - for dtype in int, int8, int16, int32, int64, float, float16, float32, float64, longdouble: - num = ((array([1, 1], dtype=dtype), array([2, 2], dtype=dtype)), - (array([3, 4], dtype=dtype), array([4, 4], dtype=dtype))) - num_ = _clean_part(num) - - assert isinstance(num_, list) - assert np.all([isinstance(part, list) for part in num_]) - np.testing.assert_array_equal(num_[0][0], array([1.0, 1.0], dtype=float)) - np.testing.assert_array_equal(num_[0][1], array([2.0, 2.0], dtype=float)) - - def test_clean_part_list_tuples_arrays(self): - """List of tuples of numpy arrays for all valid types.""" - for dtype in int, int8, int16, int32, int64, float, float16, float32, float64, longdouble: - num = [(array([1, 1], dtype=dtype), array([2, 2], dtype=dtype)), - (array([3, 4], dtype=dtype), array([4, 4], dtype=dtype))] - num_ = _clean_part(num) - - assert isinstance(num_, list) - assert np.all([isinstance(part, list) for part in num_]) - np.testing.assert_array_equal(num_[0][0], array([1.0, 1.0], dtype=float)) - np.testing.assert_array_equal(num_[0][1], array([2.0, 2.0], dtype=float)) - - def test_clean_part_list_list_arrays(self): - """List of list of numpy arrays for all valid types.""" - for dtype in int, int8, int16, int32, int64, float, float16, float32, float64, longdouble: - num = [[array([1, 1], dtype=dtype), array([2, 2], dtype=dtype)], - [array([3, 3], dtype=dtype), array([4, 4], dtype=dtype)]] - num_ = _clean_part(num) - - assert len(num_) == 2 - assert np.all([isinstance(part, list) for part in num_]) - assert np.all([len(part) == 2 for part in num_]) - np.testing.assert_array_equal(num_[0][0], array([1.0, 1.0], dtype=float)) - np.testing.assert_array_equal(num_[0][1], array([2.0, 2.0], dtype=float)) - np.testing.assert_array_equal(num_[1][0], array([3.0, 3.0], dtype=float)) - np.testing.assert_array_equal(num_[1][1], array([4.0, 4.0], dtype=float)) - - -if __name__ == "__main__": - unittest.main() +cases = { + "scalar": + (1, lambda dtype, v: dtype(v)), + "scalar in 0d array": + (1, lambda dtype, v: np.array(v, dtype=dtype)), + "numpy array": + ([1, 2], lambda dtype, v: np.array(v, dtype=dtype)), + "list of scalar": + (1, lambda dtype, v: [dtype(v)]), + "list of scalars": + ([1, 2], lambda dtype, v: [dtype(vi) for vi in v]), + "list of list of list of scalar": + (1, lambda dtype, v: [[[dtype(v)]]]), + "list of list of list of scalars": + ([[1, 1], [2, 2]], + lambda dtype, v: [[[dtype(vi) for vi in vr] for vr in v]]), + "tuple of scalar": + (1, lambda dtype, v: (dtype(v),)), + "tuple of scalars": + ([1, 2], lambda dtype, v: tuple(dtype(vi) for vi in v)), + "list of list of numpy arrays": + ([[1, 1], [2, 2]], + lambda dtype, v: [[np.array(vr, dtype=dtype) for vr in v]]), + "tuple of list of numpy arrays": + ([[1, 1], [2, 2]], + lambda dtype, v: ([np.array(vr, dtype=dtype) for vr in v],)), + "list of tuple of numpy arrays": + ([[1, 1], [2, 2]], + lambda dtype, v: [tuple(np.array(vr, dtype=dtype) for vr in v)]), + "tuple of tuples of numpy arrays": + ([[[1, 1], [2, 2]], [[3, 3], [4, 4]]], + lambda dtype, v: tuple(tuple(np.array(vr, dtype=dtype) for vr in vp) + for vp in v)), + "list of tuples of numpy arrays": + ([[[1, 1], [2, 2]], [[3, 3], [4, 4]]], + lambda dtype, v: [tuple(np.array(vr, dtype=dtype) for vr in vp) + for vp in v]), + "list of lists of numpy arrays": + ([[[1, 1], [2, 2]], [[3, 3], [4, 4]]], + lambda dtype, v: [[np.array(vr, dtype=dtype) for vr in vp] + for vp in v]), +} + + +@pytest.mark.parametrize("dtype", + [int, np.int8, np.int16, np.int32, np.int64, + float, np.float16, np.float32, np.float64, + np.longdouble]) +@pytest.mark.parametrize("num, fun", cases.values(), ids=cases.keys()) +def test_clean_part(num, fun, dtype): + """Test clean part for various inputs""" + numa = fun(dtype, num) + num_ = _clean_part(numa) + ref_ = np.array(num, dtype=float, ndmin=3) + + assert isinstance(num_, list) + assert np.all([isinstance(part, list) for part in num_]) + for i, numi in enumerate(num_): + assert len(numi) == ref_.shape[1] + for j, numj in enumerate(numi): + np.testing.assert_array_equal(numj, ref_[i, j, ...]) + + +@pytest.mark.parametrize("badinput", [[[0., 1.], [2., 3.]], "a"]) +def test_clean_part_bad_input(badinput): + """Give the part cleaner invalid input type.""" + with pytest.raises(TypeError): + _clean_part(badinput) diff --git a/control/tests/xferfcn_test.py b/control/tests/xferfcn_test.py index 02e6c2b37..06e7fc9d8 100644 --- a/control/tests/xferfcn_test.py +++ b/control/tests/xferfcn_test.py @@ -1,115 +1,148 @@ -#!/usr/bin/env python -# -# xferfcn_test.py - test TransferFunction class -# RMM, 30 Mar 2011 (based on TestXferFcn from v0.4a) +"""xferfcn_test.py - test TransferFunction class + +RMM, 30 Mar 2011 (based on TestXferFcn from v0.4a) +""" -import unittest -import sys as pysys import numpy as np -from control.statesp import StateSpace, _convertToStateSpace, rss +import pytest +import operator + +import control as ct +from control.statesp import StateSpace, _convert_to_statespace, rss from control.xferfcn import TransferFunction, _convert_to_transfer_function, \ ss2tf from control.lti import evalfr -from control.exception import slycot_check +from control.tests.conftest import slycotonly, nopython2, matrixfilter from control.lti import isctime, isdtime from control.dtime import sample_system +from control.config import defaults -class TestXferFcn(unittest.TestCase): - """These are tests for functionality and correct reporting of the transfer - function class. Throughout these tests, we will give different input +class TestXferFcn: + """Test functionality and correct reporting of the transfer function class. + + Throughout these tests, we will give different input formats to the xTranferFunction constructor, to try to break it. These - tests have been verified in MATLAB.""" + tests have been verified in MATLAB. + """ # Tests for raising exceptions. def test_constructor_bad_input_type(self): """Give the constructor invalid input types.""" - # MIMO requires lists of lists of vectors (not lists of vectors) - self.assertRaises( - TypeError, - TransferFunction, [[0., 1.], [2., 3.]], [[5., 2.], [3., 0.]]) - TransferFunction([[ [0., 1.], [2., 3.] ]], [[ [5., 2.], [3., 0.] ]]) + with pytest.raises(TypeError): + TransferFunction([[0., 1.], [2., 3.]], [[5., 2.], [3., 0.]]) + # good input + TransferFunction([[[0., 1.], [2., 3.]]], + [[[5., 2.], [3., 0.]]]) # Single argument of the wrong type - self.assertRaises(TypeError, TransferFunction, [1]) + with pytest.raises(TypeError): + TransferFunction([1]) # Too many arguments - self.assertRaises(ValueError, TransferFunction, 1, 2, 3, 4) + with pytest.raises(ValueError): + TransferFunction(1, 2, 3, 4) # Different numbers of elements in numerator rows - self.assertRaises( - ValueError, - TransferFunction, [ [[0, 1], [2, 3]], - [[4, 5]] ], - [ [[6, 7], [4, 5]], - [[2, 3], [0, 1]] ]) - self.assertRaises( - ValueError, - TransferFunction, [ [[0, 1], [2, 3]], - [[4, 5], [6, 7]] ], - [ [[6, 7], [4, 5]], - [[2, 3]] ]) - TransferFunction( # This version is OK - [ [[0, 1], [2, 3]], [[4, 5], [6, 7]] ], - [ [[6, 7], [4, 5]], [[2, 3], [0, 1]] ]) + with pytest.raises(ValueError): + TransferFunction([[[0, 1], [2, 3]], + [[4, 5]]], + [[[6, 7], [4, 5]], + [[2, 3], [0, 1]]]) + with pytest.raises(ValueError): + TransferFunction([[[0, 1], [2, 3]], + [[4, 5], [6, 7]]], + [[[6, 7], [4, 5]], + [[2, 3]]]) + # good input + TransferFunction([[[0, 1], [2, 3]], + [[4, 5], [6, 7]]], + [[[6, 7], [4, 5]], + [[2, 3], [0, 1]]]) def test_constructor_inconsistent_dimension(self): """Give constructor numerators, denominators of different sizes.""" - - self.assertRaises(ValueError, TransferFunction, - [[[1.]]], [[[1.], [2., 3.]]]) - self.assertRaises(ValueError, TransferFunction, - [[[1.]]], [[[1.]], [[2., 3.]]]) - self.assertRaises(ValueError, TransferFunction, - [[[1.]]], [[[1.], [1., 2.]], [[5., 2.], [2., 3.]]]) + with pytest.raises(ValueError): + TransferFunction([[[1.]]], [[[1.], [2., 3.]]]) + with pytest.raises(ValueError): + TransferFunction([[[1.]]], [[[1.]], [[2., 3.]]]) + with pytest.raises(ValueError): + TransferFunction([[[1.]]], + [[[1.], [1., 2.]], [[5., 2.], [2., 3.]]]) def test_constructor_inconsistent_columns(self): """Give the constructor inputs that do not have the same number of columns in each row.""" - - self.assertRaises(ValueError, TransferFunction, - 1., [[[1.]], [[2.], [3.]]]) - self.assertRaises(ValueError, TransferFunction, - [[[1.]], [[2.], [3.]]], 1.) + with pytest.raises(ValueError): + TransferFunction(1., [[[1.]], [[2.], [3.]]]) + with pytest.raises(ValueError): + TransferFunction([[[1.]], [[2.], [3.]]], 1.) def test_constructor_zero_denominator(self): """Give the constructor a transfer function with a zero denominator.""" - - self.assertRaises(ValueError, TransferFunction, 1., 0.) - self.assertRaises(ValueError, TransferFunction, - [[[1.], [2., 3.]], [[-1., 4.], [3., 2.]]], - [[[1., 0.], [0.]], [[0., 0.], [2.]]]) + with pytest.raises(ValueError): + TransferFunction(1., 0.) + with pytest.raises(ValueError): + TransferFunction([[[1.], [2., 3.]], [[-1., 4.], [3., 2.]]], + [[[1., 0.], [0.]], [[0., 0.], [2.]]]) + + def test_constructor_nodt(self): + """Test the constructor when an object without dt is passed""" + sysin = TransferFunction([[[0., 1.], [2., 3.]]], + [[[5., 2.], [3., 0.]]]) + del sysin.dt + sys = TransferFunction(sysin) + assert sys.dt == defaults['control.default_dt'] + + # test for static gain + sysin = TransferFunction([[[2.], [3.]]], + [[[1.], [.1]]]) + del sysin.dt + sys = TransferFunction(sysin) + assert sys.dt is None + + def test_constructor_double_dt(self): + """Test that providing dt as arg and kwarg prefers arg with warning""" + with pytest.warns(UserWarning, match="received multiple dt.*" + "using positional arg"): + sys = TransferFunction(1, [1, 2, 3], 0.1, dt=0.2) + assert sys.dt == 0.1 def test_add_inconsistent_dimension(self): """Add two transfer function matrices of different sizes.""" - sys1 = TransferFunction([[[1., 2.]]], [[[4., 5.]]]) sys2 = TransferFunction([[[4., 3.]], [[1., 2.]]], [[[1., 6.]], [[2., 4.]]]) - self.assertRaises(ValueError, sys1.__add__, sys2) - self.assertRaises(ValueError, sys1.__sub__, sys2) - self.assertRaises(ValueError, sys1.__radd__, sys2) - self.assertRaises(ValueError, sys1.__rsub__, sys2) + with pytest.raises(ValueError): + sys1.__add__(sys2) + with pytest.raises(ValueError): + sys1.__sub__(sys2) + with pytest.raises(ValueError): + sys1.__radd__(sys2) + with pytest.raises(ValueError): + sys1.__rsub__(sys2) def test_mul_inconsistent_dimension(self): """Multiply two transfer function matrices of incompatible sizes.""" - sys1 = TransferFunction([[[1., 2.], [4., 5.]], [[2., 5.], [4., 3.]]], [[[6., 2.], [4., 1.]], [[6., 7.], [2., 4.]]]) sys2 = TransferFunction([[[1.]], [[2.]], [[3.]]], [[[4.]], [[5.]], [[6.]]]) - self.assertRaises(ValueError, sys1.__mul__, sys2) - self.assertRaises(ValueError, sys2.__mul__, sys1) - self.assertRaises(ValueError, sys1.__rmul__, sys2) - self.assertRaises(ValueError, sys2.__rmul__, sys1) + with pytest.raises(ValueError): + sys1.__mul__(sys2) + with pytest.raises(ValueError): + sys2.__mul__(sys1) + with pytest.raises(ValueError): + sys1.__rmul__(sys2) + with pytest.raises(ValueError): + sys2.__rmul__(sys1) # Tests for TransferFunction._truncatecoeff def test_truncate_coefficients_non_null_numerator(self): """Remove extraneous zeros in polynomial representations.""" - sys1 = TransferFunction([0., 0., 1., 2.], [[[0., 0., 0., 3., 2., 1.]]]) np.testing.assert_array_equal(sys1.num, [[[1., 2.]]]) @@ -117,7 +150,6 @@ def test_truncate_coefficients_non_null_numerator(self): def test_truncate_coefficients_null_numerator(self): """Remove extraneous zeros in polynomial representations.""" - sys1 = TransferFunction([0., 0., 0.], 1.) np.testing.assert_array_equal(sys1.num, [[[0.]]]) @@ -127,7 +159,6 @@ def test_truncate_coefficients_null_numerator(self): def test_reverse_sign_scalar(self): """Negate a direct feedthrough system.""" - sys1 = TransferFunction(2., np.array([-3.])) sys2 = - sys1 @@ -136,17 +167,15 @@ def test_reverse_sign_scalar(self): def test_reverse_sign_siso(self): """Negate a SISO system.""" - sys1 = TransferFunction([1., 3., 5], [1., 6., 2., -1.]) sys2 = - sys1 np.testing.assert_array_equal(sys2.num, [[[-1., -3., -5.]]]) np.testing.assert_array_equal(sys2.den, [[[1., 6., 2., -1.]]]) - @unittest.skipIf(not slycot_check(), "slycot not installed") + @slycotonly def test_reverse_sign_mimo(self): """Negate a MIMO system.""" - num1 = [[[1., 2.], [0., 3.], [2., -1.]], [[1.], [4., 0.], [1., -4., 3.]]] num3 = [[[-1., -2.], [0., -3.], [-2., 1.]], @@ -158,8 +187,8 @@ def test_reverse_sign_mimo(self): sys2 = - sys1 sys3 = TransferFunction(num3, den1) - for i in range(sys3.outputs): - for j in range(sys3.inputs): + for i in range(sys3.noutputs): + for j in range(sys3.ninputs): np.testing.assert_array_equal(sys2.num[i][j], sys3.num[i][j]) np.testing.assert_array_equal(sys2.den[i][j], sys3.den[i][j]) @@ -167,7 +196,6 @@ def test_reverse_sign_mimo(self): def test_add_scalar(self): """Add two direct feedthrough systems.""" - sys1 = TransferFunction(1., [[[1.]]]) sys2 = TransferFunction(np.array([2.]), [1.]) sys3 = sys1 + sys2 @@ -177,7 +205,6 @@ def test_add_scalar(self): def test_add_siso(self): """Add two SISO systems.""" - sys1 = TransferFunction([1., 3., 5], [1., 6., 2., -1]) sys2 = TransferFunction([[np.array([-1., 3.])]], [[[1., 0., -1.]]]) sys3 = sys1 + sys2 @@ -186,10 +213,9 @@ def test_add_siso(self): np.testing.assert_array_equal(sys3.num, [[[20., 4., -8]]]) np.testing.assert_array_equal(sys3.den, [[[1., 6., 1., -7., -2., 1.]]]) - @unittest.skipIf(not slycot_check(), "slycot not installed") + @slycotonly def test_add_mimo(self): """Add two MIMO systems.""" - num1 = [[[1., 2.], [0., 3.], [2., -1.]], [[1.], [4., 0.], [1., -4., 3.]]] den1 = [[[-3., 2., 4.], [1., 0., 0.], [2., -1.]], @@ -207,8 +233,8 @@ def test_add_mimo(self): sys2 = TransferFunction(num2, den2) sys3 = sys1 + sys2 - for i in range(sys3.outputs): - for j in range(sys3.inputs): + for i in range(sys3.noutputs): + for j in range(sys3.ninputs): np.testing.assert_array_equal(sys3.num[i][j], num3[i][j]) np.testing.assert_array_equal(sys3.den[i][j], den3[i][j]) @@ -216,7 +242,6 @@ def test_add_mimo(self): def test_subtract_scalar(self): """Subtract two direct feedthrough systems.""" - sys1 = TransferFunction(1., [[[1.]]]) sys2 = TransferFunction(np.array([2.]), [1.]) sys3 = sys1 - sys2 @@ -226,7 +251,6 @@ def test_subtract_scalar(self): def test_subtract_siso(self): """Subtract two SISO systems.""" - sys1 = TransferFunction([1., 3., 5], [1., 6., 2., -1]) sys2 = TransferFunction([[np.array([-1., 3.])]], [[[1., 0., -1.]]]) sys3 = sys1 - sys2 @@ -237,10 +261,9 @@ def test_subtract_siso(self): np.testing.assert_array_equal(sys4.num, [[[-2., -6., 12., 10., 2.]]]) np.testing.assert_array_equal(sys4.den, [[[1., 6., 1., -7., -2., 1.]]]) - @unittest.skipIf(not slycot_check(), "slycot not installed") + @slycotonly def test_subtract_mimo(self): """Subtract two MIMO systems.""" - num1 = [[[1., 2.], [0., 3.], [2., -1.]], [[1.], [4., 0.], [1., -4., 3.]]] den1 = [[[-3., 2., 4.], [1., 0., 0.], [2., -1.]], @@ -258,8 +281,8 @@ def test_subtract_mimo(self): sys2 = TransferFunction(num2, den2) sys3 = sys1 - sys2 - for i in range(sys3.outputs): - for j in range(sys3.inputs): + for i in range(sys3.noutputs): + for j in range(sys3.ninputs): np.testing.assert_array_equal(sys3.num[i][j], num3[i][j]) np.testing.assert_array_equal(sys3.den[i][j], den3[i][j]) @@ -267,7 +290,6 @@ def test_subtract_mimo(self): def test_multiply_scalar(self): """Multiply two direct feedthrough systems.""" - sys1 = TransferFunction(2., [1.]) sys2 = TransferFunction(1., 4.) sys3 = sys1 * sys2 @@ -280,7 +302,6 @@ def test_multiply_scalar(self): def test_multiply_siso(self): """Multiply two SISO systems.""" - sys1 = TransferFunction([1., 3., 5], [1., 6., 2., -1]) sys2 = TransferFunction([[[-1., 3.]]], [[[1., 0., -1.]]]) sys3 = sys1 * sys2 @@ -291,10 +312,9 @@ def test_multiply_siso(self): np.testing.assert_array_equal(sys3.num, sys4.num) np.testing.assert_array_equal(sys3.den, sys4.den) - @unittest.skipIf(not slycot_check(), "slycot not installed") + @slycotonly def test_multiply_mimo(self): """Multiply two MIMO systems.""" - num1 = [[[1., 2.], [0., 3.], [2., -1.]], [[1.], [4., 0.], [1., -4., 3.]]] den1 = [[[-3., 2., 4.], [1., 0., 0.], [2., -1.]], @@ -317,8 +337,8 @@ def test_multiply_mimo(self): sys2 = TransferFunction(num2, den2) sys3 = sys1 * sys2 - for i in range(sys3.outputs): - for j in range(sys3.inputs): + for i in range(sys3.noutputs): + for j in range(sys3.ninputs): np.testing.assert_array_equal(sys3.num[i][j], num3[i][j]) np.testing.assert_array_equal(sys3.den[i][j], den3[i][j]) @@ -326,7 +346,6 @@ def test_multiply_mimo(self): def test_divide_scalar(self): """Divide two direct feedthrough systems.""" - sys1 = TransferFunction(np.array([3.]), -4.) sys2 = TransferFunction(5., 2.) sys3 = sys1 / sys2 @@ -336,7 +355,6 @@ def test_divide_scalar(self): def test_divide_siso(self): """Divide two SISO systems.""" - sys1 = TransferFunction([1., 3., 5], [1., 6., 2., -1]) sys2 = TransferFunction([[[-1., 3.]]], [[[1., 0., -1.]]]) sys3 = sys1 / sys2 @@ -349,89 +367,96 @@ def test_divide_siso(self): def test_div(self): # Make sure that sampling times work correctly - sys1 = TransferFunction([1., 3., 5], [1., 6., 2., -1]) + sys1 = TransferFunction([1., 3., 5], [1., 6., 2., -1], None) sys2 = TransferFunction([[[-1., 3.]]], [[[1., 0., -1.]]], True) sys3 = sys1 / sys2 - self.assertEqual(sys3.dt, True) + assert sys3.dt is True sys2 = TransferFunction([[[-1., 3.]]], [[[1., 0., -1.]]], 0.5) sys3 = sys1 / sys2 - self.assertEqual(sys3.dt, 0.5) + assert sys3.dt == 0.5 sys1 = TransferFunction([1., 3., 5], [1., 6., 2., -1], 0.1) - self.assertRaises(ValueError, TransferFunction.__truediv__, sys1, sys2) + with pytest.raises(ValueError): + TransferFunction.__truediv__(sys1, sys2) sys1 = sample_system(rss(4, 1, 1), 0.5) sys3 = TransferFunction.__rtruediv__(sys2, sys1) - self.assertEqual(sys3.dt, 0.5) + assert sys3.dt == 0.5 def test_pow(self): sys1 = TransferFunction([1., 3., 5], [1., 6., 2., -1]) - self.assertRaises(ValueError, TransferFunction.__pow__, sys1, 0.5) + with pytest.raises(ValueError): + TransferFunction.__pow__(sys1, 0.5) def test_slice(self): sys = TransferFunction( [ [ [1], [2], [3]], [ [3], [4], [5]] ], [ [[1, 2], [1, 3], [1, 4]], [[1, 4], [1, 5], [1, 6]] ]) sys1 = sys[1:, 1:] - self.assertEqual((sys1.inputs, sys1.outputs), (2, 1)) + assert (sys1.ninputs, sys1.noutputs) == (2, 1) sys2 = sys[:2, :2] - self.assertEqual((sys2.inputs, sys2.outputs), (2, 2)) + assert (sys2.ninputs, sys2.noutputs) == (2, 2) sys = TransferFunction( [ [ [1], [2], [3]], [ [3], [4], [5]] ], [ [[1, 2], [1, 3], [1, 4]], [[1, 4], [1, 5], [1, 6]] ], 0.5) sys1 = sys[1:, 1:] - self.assertEqual((sys1.inputs, sys1.outputs), (2, 1)) - self.assertEqual(sys1.dt, 0.5) - - def test_evalfr_siso(self): + assert (sys1.ninputs, sys1.noutputs) == (2, 1) + assert sys1.dt == 0.5 + + def test__isstatic(self): + numstatic = 1.1 + denstatic = 1.2 + numdynamic = [1, 1] + dendynamic = [2, 1] + numstaticmimo = [[[1.1,], [1.2,]], [[1.2,], [0.8,]]] + denstaticmimo = [[[1.9,], [1.2,]], [[1.2,], [0.8,]]] + numdynamicmimo = [[[1.1, 0.9], [1.2]], [[1.2], [0.8]]] + dendynamicmimo = [[[1.1, 0.7], [0.2]], [[1.2], [0.8]]] + assert TransferFunction(numstatic, denstatic)._isstatic() + assert TransferFunction(numstaticmimo, denstaticmimo)._isstatic() + + assert not TransferFunction(numstatic, dendynamic)._isstatic() + assert not TransferFunction(numdynamic, dendynamic)._isstatic() + assert not TransferFunction(numdynamic, denstatic)._isstatic() + assert not TransferFunction(numstatic, dendynamic)._isstatic() + + assert not TransferFunction(numstaticmimo, + dendynamicmimo)._isstatic() + assert not TransferFunction(numdynamicmimo, + denstaticmimo)._isstatic() + + @pytest.mark.parametrize("omega, resp", + [(1, np.array([[-0.5 - 0.5j]])), + (32, np.array([[0.002819593 - 0.03062847j]]))]) + @pytest.mark.parametrize("dt", [None, 0, 1e-3]) + def test_call_siso(self, dt, omega, resp): """Evaluate the frequency response of a SISO system at one frequency.""" - sys = TransferFunction([1., 3., 5], [1., 6., 2., -1]) - np.testing.assert_array_almost_equal(evalfr(sys, 1j), - np.array([[-0.5 - 0.5j]])) - np.testing.assert_array_almost_equal( - evalfr(sys, 32j), - np.array([[0.00281959302585077 - 0.030628473607392j]])) + if dt: + sys = sample_system(sys, dt) + s = np.exp(omega * 1j * dt) + else: + s = omega * 1j - # Test call version as well - np.testing.assert_almost_equal(sys(1.j), -0.5 - 0.5j) - np.testing.assert_almost_equal( - sys(32.j), 0.00281959302585077 - 0.030628473607392j) - - # Test internal version (with real argument) - np.testing.assert_array_almost_equal( - sys._evalfr(1.), np.array([[-0.5 - 0.5j]])) - np.testing.assert_array_almost_equal( - sys._evalfr(32.), - np.array([[0.00281959302585077 - 0.030628473607392j]])) - - # This test only works in Python 3 due to a conflict with the same - # warning type in other test modules (frd_test.py). See - # https://bugs.python.org/issue4180 for more details - @unittest.skipIf(pysys.version_info < (3, 0), "test requires Python 3+") - def test_evalfr_deprecated(self): - sys = TransferFunction([1., 3., 5], [1., 6., 2., -1]) - - # Deprecated version of the call (should generate warning) - import warnings - with warnings.catch_warnings(): - # Make warnings generate an exception - warnings.simplefilter('error') + # Correct versions of the call + np.testing.assert_allclose(evalfr(sys, s), resp, atol=1e-3) + np.testing.assert_allclose(sys(s), resp, atol=1e-3) + # Deprecated version of the call (should generate exception) + with pytest.raises(AttributeError): + np.testing.assert_allclose(sys.evalfr(omega), resp, atol=1e-3) - # Make sure that we get a pending deprecation warning - self.assertRaises(PendingDeprecationWarning, sys.evalfr, 1.) - @unittest.skipIf(pysys.version_info < (3, 0), "test requires Python 3+") - def test_evalfr_dtime(self): + @nopython2 + def test_call_dtime(self): sys = TransferFunction([1., 3., 5], [1., 6., 2., -1], 0.1) np.testing.assert_array_almost_equal(sys(1j), -0.5 - 0.5j) - @unittest.skipIf(not slycot_check(), "slycot not installed") - def test_evalfr_mimo(self): + @slycotonly + def test_call_mimo(self): """Evaluate the frequency response of a MIMO system at one frequency.""" num = [[[1., 2.], [0., 3.], [2., -1.]], @@ -443,12 +468,18 @@ def test_evalfr_mimo(self): [-0.083333333333333, -0.188235294117647 - 0.847058823529412j, -1. - 8.j]] - np.testing.assert_array_almost_equal(sys._evalfr(2.), resp) + np.testing.assert_array_almost_equal(evalfr(sys, 2j), resp) # Test call version as well np.testing.assert_array_almost_equal(sys(2.j), resp) - def test_freqresp_siso(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): + sys.freqresp(1.) + + def test_frequency_response_siso(self): """Evaluate the magnitude and phase of a SISO system at multiple frequencies.""" @@ -459,17 +490,15 @@ def test_freqresp_siso(self): -1.32655885133871]]] trueomega = [0.1, 1., 10.] - mag, phase, omega = sys.freqresp(trueomega) + mag, phase, omega = sys.frequency_response(trueomega, squeeze=False) np.testing.assert_array_almost_equal(mag, truemag) np.testing.assert_array_almost_equal(phase, truephase) np.testing.assert_array_almost_equal(omega, trueomega) - @unittest.skipIf(not slycot_check(), "slycot not installed") + @slycotonly def test_freqresp_mimo(self): - """Evaluate the magnitude and phase of a MIMO system at - multiple frequencies.""" - + """Evaluate the MIMO magnitude and phase at multiple frequencies.""" num = [[[1., 2.], [0., 3.], [2., -1.]], [[1.], [4., 0.], [1., -4., 3.]]] den = [[[-3., 2., 4.], [1., 0., 0.], [2., -1.]], @@ -489,7 +518,7 @@ def test_freqresp_mimo(self): [-1.66852323, -1.89254688, -1.62050658], [-0.13298964, -1.10714871, -2.75046720]]] - mag, phase, omega = sys.freqresp(true_omega) + mag, phase, omega = sys.frequency_response(true_omega) np.testing.assert_array_almost_equal(mag, true_mag) np.testing.assert_array_almost_equal(phase, true_phase) @@ -498,7 +527,6 @@ def test_freqresp_mimo(self): # Tests for TransferFunction.pole and TransferFunction.zero. def test_common_den(self): """ Test the helper function to compute common denomitators.""" - # _common_den() computes the common denominator per input/column. # The testing columns are: # 0: no common poles @@ -548,7 +576,6 @@ def test_common_den(self): def test_common_den_nonproper(self): """ Test _common_den with order(num)>order(den) """ - tf1 = TransferFunction( [[[1., 2., 3.]], [[1., 2.]]], [[[1., -2.]], [[1., -3.]]]) @@ -566,10 +593,9 @@ def test_common_den_nonproper(self): _, den2, _ = tf2._common_den(allow_nonproper=True) np.testing.assert_array_almost_equal(den2, common_den_ref) - @unittest.skipIf(not slycot_check(), "slycot not installed") + @slycotonly def test_pole_mimo(self): """Test for correct MIMO poles.""" - sys = TransferFunction( [[[1.], [1.]], [[1.], [1.]]], [[[1., 2.], [1., 3.]], [[1., 4., 4.], [1., 9., 14.]]]) @@ -594,7 +620,6 @@ def test_double_cancelling_poles_siso(self): # Tests for TransferFunction.feedback def test_feedback_siso(self): """Test for correct SISO transfer function feedback.""" - sys1 = TransferFunction([-1., 4.], [1., 3., 5.]) sys2 = TransferFunction([2., 3., 0.], [1., -3., 4., 0]) @@ -606,10 +631,9 @@ def test_feedback_siso(self): np.testing.assert_array_equal(sys4.num, [[[-1., 7., -16., 16., 0.]]]) np.testing.assert_array_equal(sys4.den, [[[1., 0., 2., -8., 8., 0.]]]) - @unittest.skipIf(not slycot_check(), "slycot not installed") + @slycotonly def test_convert_to_transfer_function(self): """Test for correct state space to transfer function conversion.""" - A = [[1., -2.], [-3., 4.]] B = [[6., 5.], [4., 3.]] C = [[1., -2.], [3., -4.], [5., -6.]] @@ -621,13 +645,15 @@ def test_convert_to_transfer_function(self): num = [[np.array([1., -7., 10.]), np.array([-1., 10.])], [np.array([2., -8.]), np.array([1., -2., -8.])], [np.array([1., 1., -30.]), np.array([7., -22.])]] - den = [[np.array([1., -5., -2.]) for _ in range(sys.inputs)] - for _ in range(sys.outputs)] + den = [[np.array([1., -5., -2.]) for _ in range(sys.ninputs)] + for _ in range(sys.noutputs)] - for i in range(sys.outputs): - for j in range(sys.inputs): - np.testing.assert_array_almost_equal(tfsys.num[i][j], num[i][j]) - np.testing.assert_array_almost_equal(tfsys.den[i][j], den[i][j]) + for i in range(sys.noutputs): + for j in range(sys.ninputs): + np.testing.assert_array_almost_equal(tfsys.num[i][j], + num[i][j]) + np.testing.assert_array_almost_equal(tfsys.den[i][j], + den[i][j]) def test_minreal(self): """Try the minreal function, and also test easy entry by creation @@ -659,7 +685,6 @@ def test_minreal_3(self): g = TransferFunction([1,1],[1,1]).minreal() np.testing.assert_array_almost_equal(1.0, g.num[0][0]) np.testing.assert_array_almost_equal(1.0, g.den[0][0]) - np.testing.assert_equal(None, g.dt) def test_minreal_4(self): """Check minreal on discrete TFs.""" @@ -671,7 +696,7 @@ def test_minreal_4(self): np.testing.assert_array_almost_equal(hm.num[0][0], hr.num[0][0]) np.testing.assert_equal(hr.dt, hm.dt) - @unittest.skipIf(not slycot_check(), "slycot not installed") + @slycotonly def test_state_space_conversion_mimo(self): """Test conversion of a single input, two-output state-space system against the same TF""" @@ -686,15 +711,16 @@ def test_state_space_conversion_mimo(self): h = (b0 + b1*s + b2*s**2)/(a0 + a1*s + a2*s**2 + a3*s**3) H = TransferFunction([[h.num[0][0]], [(h*s).num[0][0]]], [[h.den[0][0]], [h.den[0][0]]]) - sys = _convertToStateSpace(H) + sys = _convert_to_statespace(H) H2 = _convert_to_transfer_function(sys) np.testing.assert_array_almost_equal(H.num[0][0], H2.num[0][0]) np.testing.assert_array_almost_equal(H.den[0][0], H2.den[0][0]) np.testing.assert_array_almost_equal(H.num[1][0], H2.num[1][0]) np.testing.assert_array_almost_equal(H.den[1][0], H2.den[1][0]) - @unittest.skipIf(not slycot_check(), "slycot not installed") + @slycotonly def test_indexing(self): + """Test TF scalar indexing and slice""" tm = ss2tf(rss(5, 3, 3)) # scalar indexing @@ -713,26 +739,64 @@ def test_indexing(self): np.testing.assert_array_almost_equal(sys.num[1][1], tm.num[1][2]) np.testing.assert_array_almost_equal(sys.den[1][1], tm.den[1][2]) - def test_matrix_multiply(self): - """MIMO transfer functions should be multiplyable by constant - matrices""" - s = TransferFunction([1, 0], [1]) - b0 = 0.2 - b1 = 0.1 - b2 = 0.5 - a0 = 2.3 - a1 = 6.3 - a2 = 3.6 - a3 = 1.0 - h = (b0 + b1*s + b2*s**2)/(a0 + a1*s + a2*s**2 + a3*s**3) - H = TransferFunction([[h.num[0][0]], [(h*s).num[0][0]]], - [[h.den[0][0]], [h.den[0][0]]]) - H1 = (np.matrix([[1.0, 0]])*H).minreal() - H2 = (np.matrix([[0, 1.0]])*H).minreal() - np.testing.assert_array_almost_equal(H.num[0][0], H1.num[0][0]) - np.testing.assert_array_almost_equal(H.den[0][0], H1.den[0][0]) - np.testing.assert_array_almost_equal(H.num[1][0], H2.num[0][0]) - np.testing.assert_array_almost_equal(H.den[1][0], H2.den[0][0]) + @pytest.mark.parametrize( + "matarrayin", + [pytest.param(np.array, + id="arrayin", + marks=[nopython2, + pytest.mark.skip(".__matmul__ not implemented")]), + pytest.param(np.matrix, + id="matrixin", + marks=matrixfilter)], + indirect=True) + @pytest.mark.parametrize("X_, ij", + [([[2., 0., ]], 0), + ([[0., 2., ]], 1)]) + def test_matrix_array_multiply(self, matarrayin, X_, ij): + """Test mulitplication of MIMO TF with matrix and matmul with array""" + # 2 inputs, 2 outputs with prime zeros so they do not cancel + n = 2 + p = [3, 5, 7, 11, 13, 17, 19, 23] + H = TransferFunction( + [[np.poly(p[2 * i + j:2 * i + j + 1]) for j in range(n)] + for i in range(n)], + [[[1, -1]] * n] * n) + + X = matarrayin(X_) + + if matarrayin is np.matrix: + XH = X * H + else: + # XH = X @ H + XH = np.matmul(X, H) + XH = XH.minreal() + assert XH.ninputs == n + assert XH.noutputs == X.shape[0] + assert len(XH.num) == XH.noutputs + assert len(XH.den) == XH.noutputs + assert len(XH.num[0]) == n + assert len(XH.den[0]) == n + np.testing.assert_allclose(2. * H.num[ij][0], XH.num[0][0], rtol=1e-4) + np.testing.assert_allclose( H.den[ij][0], XH.den[0][0], rtol=1e-4) + np.testing.assert_allclose(2. * H.num[ij][1], XH.num[0][1], rtol=1e-4) + np.testing.assert_allclose( H.den[ij][1], XH.den[0][1], rtol=1e-4) + + if matarrayin is np.matrix: + HXt = H * X.T + else: + # HXt = H @ X.T + HXt = np.matmul(H, X.T) + HXt = HXt.minreal() + assert HXt.ninputs == X.T.shape[1] + assert HXt.noutputs == n + assert len(HXt.num) == n + assert len(HXt.den) == n + assert len(HXt.num[0]) == HXt.ninputs + assert len(HXt.den[0]) == HXt.ninputs + np.testing.assert_allclose(2. * H.num[0][ij], HXt.num[0][0], rtol=1e-4) + np.testing.assert_allclose( H.den[0][ij], HXt.den[0][0], rtol=1e-4) + np.testing.assert_allclose(2. * H.num[1][ij], HXt.num[1][0], rtol=1e-4) + np.testing.assert_allclose( H.den[1][ij], HXt.den[1][0], rtol=1e-4) def test_dcgain_cont(self): """Test DC gain for continuous-time transfer functions""" @@ -765,12 +829,18 @@ def test_dcgain_discr(self): sys = TransferFunction(1, [1, -1], True) np.testing.assert_equal(sys.dcgain(), np.inf) + # differencer, with warning + sys = TransferFunction(1, [1, -1], True) + with pytest.warns(RuntimeWarning, match="divide by zero"): + np.testing.assert_equal( + sys.dcgain(warn_infinite=True), np.inf) + # summer - # causes a RuntimeWarning due to the divide by zero sys = TransferFunction([1, -1], [1], True) np.testing.assert_equal(sys.dcgain(), 0) def test_ss2tf(self): + """Test SISO ss2tf""" A = np.array([[-4, -1], [-1, -4]]) B = np.array([[1], [3]]) C = np.array([[3, 1]]) @@ -780,79 +850,90 @@ def test_ss2tf(self): np.testing.assert_almost_equal(sys.num, true_sys.num) np.testing.assert_almost_equal(sys.den, true_sys.den) - def test_class_constants(self): - # Make sure that the 's' variable is defined properly + def test_class_constants_s(self): + """Make sure that the 's' variable is defined properly""" s = TransferFunction.s G = (s + 1)/(s**2 + 2*s + 1) np.testing.assert_array_almost_equal(G.num, [[[1, 1]]]) np.testing.assert_array_almost_equal(G.den, [[[1, 2, 1]]]) - self.assertTrue(isctime(G, strict=True)) + assert isctime(G, strict=True) - # Make sure that the 'z' variable is defined properly + def test_class_constants_z(self): + """Make sure that the 'z' variable is defined properly""" z = TransferFunction.z G = (z + 1)/(z**2 + 2*z + 1) np.testing.assert_array_almost_equal(G.num, [[[1, 1]]]) np.testing.assert_array_almost_equal(G.den, [[[1, 2, 1]]]) - self.assertTrue(isdtime(G, strict=True)) + assert isdtime(G, strict=True) def test_printing(self): - # SISO, continuous time + """Print SISO""" sys = ss2tf(rss(4, 1, 1)) - self.assertTrue(isinstance(str(sys), str)) - self.assertTrue(isinstance(sys._repr_latex_(), str)) + assert isinstance(str(sys), str) + assert isinstance(sys._repr_latex_(), str) # SISO, discrete time sys = sample_system(sys, 1) - self.assertTrue(isinstance(str(sys), str)) - self.assertTrue(isinstance(sys._repr_latex_(), str)) - - def test_printing_polynomial(self): - """Cover all _tf_polynomial_to_string code branches""" - # Note: the assertions below use plain assert statements instead of - # unittest methods so that debugging with pytest is easier - - assert str(TransferFunction([0], [1])) == "\n0\n-\n1\n" - assert str(TransferFunction([1.0001], [-1.1111])) == \ - "\n 1\n------\n-1.111\n" - assert str(TransferFunction([0, 1], [0, 1.])) == "\n1\n-\n1\n" - for var, dt, dtstring in zip(["s", "z", "z"], - [None, True, 1], - ['', '', '\ndt = 1\n']): - assert str(TransferFunction([1, 0], [2, 1], dt)) == \ - "\n {var}\n-------\n2 {var} + 1\n{dtstring}".format( - var=var, dtstring=dtstring) - assert str(TransferFunction([2, 0, -1], [1, 0, 0, 1.2], dt)) == \ - "\n2 {var}^2 - 1\n---------\n{var}^3 + 1.2\n{dtstring}".format( - var=var, dtstring=dtstring) - - @unittest.skipIf(not slycot_check(), "slycot not installed") + assert isinstance(str(sys), str) + assert isinstance(sys._repr_latex_(), str) + + @pytest.mark.parametrize( + "args, output", + [(([0], [1]), "\n0\n-\n1\n"), + (([1.0001], [-1.1111]), "\n 1\n------\n-1.111\n"), + (([0, 1], [0, 1.]), "\n1\n-\n1\n"), + ]) + def test_printing_polynomial_const(self, args, output): + """Test _tf_polynomial_to_string for constant systems""" + assert str(TransferFunction(*args)) == output + + @pytest.mark.parametrize( + "args, outputfmt", + [(([1, 0], [2, 1]), + "\n {var}\n-------\n2 {var} + 1\n{dtstring}"), + (([2, 0, -1], [1, 0, 0, 1.2]), + "\n2 {var}^2 - 1\n---------\n{var}^3 + 1.2\n{dtstring}")]) + @pytest.mark.parametrize("var, dt, dtstring", + [("s", None, ''), + ("z", True, ''), + ("z", 1, '\ndt = 1\n')]) + def test_printing_polynomial(self, args, outputfmt, var, dt, dtstring): + """Test _tf_polynomial_to_string for all other code branches""" + assert str(TransferFunction(*(args + (dt,)))) == \ + outputfmt.format(var=var, dtstring=dtstring) + + @slycotonly def test_printing_mimo(self): - # MIMO, continuous time + """Print MIMO, continuous time""" sys = ss2tf(rss(4, 2, 3)) - self.assertTrue(isinstance(str(sys), str)) - self.assertTrue(isinstance(sys._repr_latex_(), str)) + assert isinstance(str(sys), str) + assert isinstance(sys._repr_latex_(), str) - @unittest.skipIf(not slycot_check(), "slycot not installed") + @slycotonly def test_size_mismatch(self): + """Test size mismacht""" sys1 = ss2tf(rss(2, 2, 2)) # Different number of inputs sys2 = ss2tf(rss(3, 1, 2)) - self.assertRaises(ValueError, TransferFunction.__add__, sys1, sys2) + with pytest.raises(ValueError): + TransferFunction.__add__(sys1, sys2) # Different number of outputs sys2 = ss2tf(rss(3, 2, 1)) - self.assertRaises(ValueError, TransferFunction.__add__, sys1, sys2) + with pytest.raises(ValueError): + TransferFunction.__add__(sys1, sys2) # Inputs and outputs don't match - self.assertRaises(ValueError, TransferFunction.__mul__, sys2, sys1) + with pytest.raises(ValueError): + TransferFunction.__mul__(sys2, sys1) # Feedback mismatch (MIMO not implemented) - self.assertRaises(NotImplementedError, - TransferFunction.feedback, sys2, sys1) + with pytest.raises(NotImplementedError): + TransferFunction.feedback(sys2, sys1) def test_latex_repr(self): - """ Test latex printout for TransferFunction """ + """Test latex printout for TransferFunction""" Hc = TransferFunction([1e-5, 2e5, 3e-4], [1.2e34, 2.3e-4, 2.3e-45]) Hd = TransferFunction([1e-5, 2e5, 3e-4], @@ -871,68 +952,97 @@ def test_latex_repr(self): r'+ 0.00023 ' + var + ' ' r'+ 2.3 ' + expmul + ' 10^{-45}' r'}' + suffix + '$$') - self.assertEqual(H._repr_latex_(), ref) - - def test_repr(self): + assert H._repr_latex_() == ref + + @pytest.mark.parametrize( + "Hargs, ref", + [(([-1., 4.], [1., 3., 5.]), + "TransferFunction(array([-1., 4.]), array([1., 3., 5.]))"), + (([2., 3., 0.], [1., -3., 4., 0], 2.0), + "TransferFunction(array([2., 3., 0.])," + " array([ 1., -3., 4., 0.]), 2.0)"), + + (([[[0, 1], [2, 3]], [[4, 5], [6, 7]]], + [[[6, 7], [4, 5]], [[2, 3], [0, 1]]]), + "TransferFunction([[array([1]), array([2, 3])]," + " [array([4, 5]), array([6, 7])]]," + " [[array([6, 7]), array([4, 5])]," + " [array([2, 3]), array([1])]])"), + (([[[0, 1], [2, 3]], [[4, 5], [6, 7]]], + [[[6, 7], [4, 5]], [[2, 3], [0, 1]]], + 0.5), + "TransferFunction([[array([1]), array([2, 3])]," + " [array([4, 5]), array([6, 7])]]," + " [[array([6, 7]), array([4, 5])]," + " [array([2, 3]), array([1])]], 0.5)") + ]) + def test_repr(self, Hargs, ref): """Test __repr__ printout.""" - Hc = TransferFunction([-1., 4.], [1., 3., 5.]) - Hd = TransferFunction([2., 3., 0.], [1., -3., 4., 0], 2.0) - Hcm = TransferFunction( - [ [[0, 1], [2, 3]], [[4, 5], [6, 7]] ], - [ [[6, 7], [4, 5]], [[2, 3], [0, 1]] ]) - Hdm = TransferFunction( - [ [[0, 1], [2, 3]], [[4, 5], [6, 7]] ], - [ [[6, 7], [4, 5]], [[2, 3], [0, 1]] ], 0.5) - - refs = [ - "TransferFunction(array([-1., 4.]), array([1., 3., 5.]))", - "TransferFunction(array([2., 3., 0.])," - " array([ 1., -3., 4., 0.]), 2.0)", - "TransferFunction([[array([1]), array([2, 3])]," - " [array([4, 5]), array([6, 7])]]," - " [[array([6, 7]), array([4, 5])]," - " [array([2, 3]), array([1])]])", - "TransferFunction([[array([1]), array([2, 3])]," - " [array([4, 5]), array([6, 7])]]," - " [[array([6, 7]), array([4, 5])]," - " [array([2, 3]), array([1])]], 0.5)" ] - self.assertEqual(repr(Hc), refs[0]) - self.assertEqual(repr(Hd), refs[1]) - self.assertEqual(repr(Hcm), refs[2]) - self.assertEqual(repr(Hdm), refs[3]) + H = TransferFunction(*Hargs) + + assert repr(H) == ref # and reading back - array = np.array - for H in (Hc, Hd, Hcm, Hdm): - H2 = eval(H.__repr__()) - for p in range(len(H.num)): - for m in range(len(H.num[0])): - np.testing.assert_array_almost_equal( - H.num[p][m], H2.num[p][m]) - np.testing.assert_array_almost_equal( - H.den[p][m], H2.den[p][m]) - self.assertEqual(H.dt, H2.dt) - - def test_sample_system_prewarping(self): - """test that prewarping works when converting from cont to discrete time system""" - A = np.array([ - [ 0.00000000e+00, 1.00000000e+00, 0.00000000e+00, 0.00000000e+00], - [-3.81097561e+01, -1.12500000e+00, 0.00000000e+00, 0.00000000e+00], - [ 0.00000000e+00, 0.00000000e+00, 0.00000000e+00, 1.00000000e+00], - [ 0.00000000e+00, 0.00000000e+00, -1.66356135e+04, -1.34748470e+01]]) - B = np.array([ - [ 0. ], [ 38.1097561 ],[ 0. ],[16635.61352143]]) - C = np.array([[0.90909091, 0. , 0.09090909, 0. ],]) - wwarp = 50 - Ts = 0.025 - plant = StateSpace(A,B,C,0) - plant = ss2tf(plant) - plant_d_warped = plant.sample(Ts, 'bilinear', prewarp_frequency=wwarp) - np.testing.assert_array_almost_equal( - evalfr(plant, wwarp*1j), - evalfr(plant_d_warped, np.exp(wwarp*1j*Ts)), - decimal=4) - - -if __name__ == "__main__": - unittest.main() + array = np.array # noqa + H2 = eval(H.__repr__()) + for p in range(len(H.num)): + for m in range(len(H.num[0])): + np.testing.assert_array_almost_equal(H.num[p][m], H2.num[p][m]) + np.testing.assert_array_almost_equal(H.den[p][m], H2.den[p][m]) + assert H.dt == H2.dt + + +class TestLTIConverter: + """Test returnScipySignalLTI method""" + + @pytest.fixture + def mimotf(self, request): + """Test system with various dt values""" + return TransferFunction([[[11], [12], [13]], + [[21], [22], [23]]], + [[[1, -1]] * 3] * 2, + request.param) + + @pytest.mark.parametrize("mimotf", + [None, + 0, + 0.1, + 1, + True], + indirect=True) + def test_returnScipySignalLTI(self, mimotf): + """Test returnScipySignalLTI method with strict=False""" + sslti = mimotf.returnScipySignalLTI(strict=False) + for i in range(2): + for j in range(3): + np.testing.assert_allclose(sslti[i][j].num, mimotf.num[i][j]) + np.testing.assert_allclose(sslti[i][j].den, mimotf.den[i][j]) + if mimotf.dt == 0: + assert sslti[i][j].dt is None + else: + assert sslti[i][j].dt == mimotf.dt + + @pytest.mark.parametrize("mimotf", [None], indirect=True) + def test_returnScipySignalLTI_error(self, mimotf): + """Test returnScipySignalLTI method with dt=None and strict=True""" + with pytest.raises(ValueError): + mimotf.returnScipySignalLTI() + with pytest.raises(ValueError): + mimotf.returnScipySignalLTI(strict=True) + +@pytest.mark.parametrize( + "op", + [pytest.param(getattr(operator, s), id=s) for s in ('add', 'sub', 'mul')]) +@pytest.mark.parametrize( + "tf, arr", + [pytest.param(ct.tf([1], [0.5, 1]), np.array(2.), id="0D scalar"), + pytest.param(ct.tf([1], [0.5, 1]), np.array([2.]), id="1D scalar"), + pytest.param(ct.tf([1], [0.5, 1]), np.array([[2.]]), id="2D scalar")]) +def test_xferfcn_ndarray_precedence(op, tf, arr): + # Apply the operator to the transfer function and array + result = op(tf, arr) + assert isinstance(result, ct.TransferFunction) + + # Apply the operator to the array and transfer function + result = op(arr, tf) + assert isinstance(result, ct.TransferFunction) diff --git a/control/timeresp.py b/control/timeresp.py index 8b0010c1c..eafe10992 100644 --- a/control/timeresp.py +++ b/control/timeresp.py @@ -1,9 +1,7 @@ -# timeresp.py - time-domain simulation routines -# -# This file contains a collection of functions that calculate time -# responses for linear systems. +""" +timeresp.py - time-domain simulation routines. -"""The :mod:`~control.timeresp` module contains a collection of +The :mod:`~control.timeresp` module contains a collection of functions that are used to compute time-domain simulations of LTI systems. @@ -21,9 +19,7 @@ See :ref:`time-series-convention` for more information on how time series data are represented. -""" - -"""Copyright (c) 2011 by California Institute of Technology +Copyright (c) 2011 by California Institute of Technology All rights reserved. Copyright (c) 2011 by Eike Welk @@ -71,16 +67,17 @@ $Id$ """ -# Libraries that we make use of -import scipy as sp # SciPy library (used all over) -import numpy as np # NumPy library -from scipy.linalg import eig, eigvals, matrix_balance, norm -from numpy import (einsum, maximum, minimum, - atleast_1d) import warnings -from .lti import LTI # base class of StateSpace, TransferFunction -from .statesp import _convertToStateSpace, _mimo2simo, _mimo2siso, ssdata -from .lti import isdtime, isctime + +import numpy as np +import scipy as sp +from numpy import einsum, maximum, minimum +from scipy.linalg import eig, eigvals, matrix_balance, norm + +from . import config +from .lti import isctime, isdtime +from .statesp import StateSpace, _convert_to_statespace, _mimo2simo, _mimo2siso +from .xferfcn import TransferFunction __all__ = ['forced_response', 'step_response', 'step_info', 'initial_response', 'impulse_response'] @@ -89,8 +86,7 @@ # Helper function for checking array-like parameters def _check_convert_array(in_obj, legal_shapes, err_msg_start, squeeze=False, transpose=False): - """ - Helper function for checking array_like parameters. + """Helper function for checking array_like parameters. * Check type and shape of ``in_obj``. * Convert ``in_obj`` to an array if necessary. @@ -102,10 +98,10 @@ def _check_convert_array(in_obj, legal_shapes, err_msg_start, squeeze=False, Parameters ---------- - in_obj: array like object + in_obj : array like object The array or matrix which is checked. - legal_shapes: list of tuple + legal_shapes : list of tuple A list of shapes that in_obj can legally have. The special value "any" means that there can be any number of elements in a certain dimension. @@ -114,26 +110,28 @@ def _check_convert_array(in_obj, legal_shapes, err_msg_start, squeeze=False, * ``(2, "any")`` describes an array with 2 rows and any number of columns - err_msg_start: str + err_msg_start : str String that is prepended to the error messages, when this function raises an exception. It should be used to identify the argument which is currently checked. - squeeze: bool + squeeze : bool If True, all dimensions with only one element are removed from the array. If False the array's shape is unmodified. For example: ``array([[1,2,3]])`` is converted to ``array([1, 2, 3])`` - transpose: bool - If True, assume that input arrays are transposed for the standard + transpose : bool, optional + If True, assume that 2D input arrays are transposed from the standard format. Used to convert MATLAB-style inputs to our format. - Returns: + Returns + ------- - out_array: array + out_array : array The checked and converted contents of ``in_obj``. + """ # convert nearly everything to an array. out_array = np.asarray(in_obj) @@ -195,7 +193,7 @@ def shape_matches(s_legal, s_actual): # Forced response of a linear system def forced_response(sys, T=None, U=0., X0=0., transpose=False, - interpolate=False, squeeze=True): + interpolate=False, return_x=None, squeeze=None): """Simulate the output of a linear system. As a convenience for parameters `U`, `X0`: @@ -207,46 +205,60 @@ def forced_response(sys, T=None, U=0., X0=0., transpose=False, Parameters ---------- - sys: LTI (StateSpace or TransferFunction) + sys : StateSpace or TransferFunction LTI system to simulate - T: array_like, optional for discrete LTI `sys` + T : array_like, optional for discrete LTI `sys` Time steps at which the input is defined; values must be evenly spaced. - U: array_like or float, optional + U : array_like or float, optional Input array giving input at each time `T` (default = 0). If `U` is ``None`` or ``0``, a special algorithm is used. This special algorithm is faster than the general algorithm, which is used otherwise. - X0: array_like or float, optional + X0 : array_like or float, optional Initial condition (default = 0). - transpose: bool, optional (default=False) + transpose : bool, optional If True, transpose all input and output arrays (for backward - compatibility with MATLAB and :func:`scipy.signal.lsim`) + compatibility with MATLAB and :func:`scipy.signal.lsim`). Default + value is False. - interpolate: bool, optional (default=False) + interpolate : bool, optional (default=False) If True and system is a discrete time system, the input will be interpolated between the given time steps and the output will be given at system sampling rate. Otherwise, only return the output at the times given in `T`. No effect on continuous time simulations (default = False). - squeeze: bool, optional (default=True) - If True, remove single-dimensional entries from the shape of - the output. For single output systems, this converts the - output response to a 1D array. + return_x : bool, optional + If True (default), return the the state vector. Set to False to + return only the time and output vectors. + + squeeze : bool, optional + By default, if a system is single-input, single-output (SISO) then + the output response is returned as a 1D array (indexed by time). If + squeeze=True, remove single-dimensional entries from the shape of + the output even if the system is not SISO. If squeeze=False, keep + the output as a 2D array (indexed by the output number and time) + even if the system is SISO. The default value can be set using + config.defaults['control.squeeze_time_response']. Returns ------- - T: array + T : array Time values of the output. - yout: array - Response of the system. - xout: array - Time evolution of the state vector. + + yout : array + Response of the system. If the system is SISO and squeeze is not + True, the array is 1D (indexed by time). If the system is not SISO or + squeeze is False, the array is 2D (indexed by the output number and + time). + + xout : array + Time evolution of the state vector. Not affected by squeeze. See Also -------- @@ -268,10 +280,21 @@ def forced_response(sys, T=None, U=0., X0=0., transpose=False, See :ref:`time-series-convention`. """ - if not isinstance(sys, LTI): - raise TypeError('Parameter ``sys``: must be a ``LTI`` object. ' - '(For example ``StateSpace`` or ``TransferFunction``)') - sys = _convertToStateSpace(sys) + if not isinstance(sys, (StateSpace, TransferFunction)): + raise TypeError('Parameter ``sys``: must be a ``StateSpace`` or' + ' ``TransferFunction``)') + + # If return_x was not specified, figure out the default + if return_x is None: + return_x = config.defaults['forced_response.return_x'] + + # If return_x is used for TransferFunction, issue a warning + if return_x and isinstance(sys, TransferFunction): + warnings.warn( + "return_x specified for a transfer function system. Internal " + "conversion to state space used; results may meaningless.") + + sys = _convert_to_statespace(sys) A, B, C, D = np.asarray(sys.A), np.asarray(sys.B), np.asarray(sys.C), \ np.asarray(sys.D) # d_type = A.dtype @@ -324,6 +347,13 @@ def forced_response(sys, T=None, U=0., X0=0., transpose=False, X0 = _check_convert_array(X0, [(n_states,), (n_states, 1)], 'Parameter ``X0``: ', squeeze=True) + # If we are passed a transfer function and X0 is non-zero, warn the user + if isinstance(sys, TransferFunction) and np.any(X0 != 0): + warnings.warn( + "Non-zero initial condition given for transfer function system. " + "Internal conversion to state space used; may not be consistent " + "with given X0.") + xout = np.zeros((n_states, n_steps)) xout[:, 0] = X0 yout = np.zeros((n_outputs, n_steps)) @@ -417,58 +447,176 @@ def forced_response(sys, T=None, U=0., X0=0., transpose=False, xout = np.transpose(xout) yout = np.transpose(yout) - # Get rid of unneeded dimensions - if squeeze: + return _process_time_response(sys, tout, yout, xout, transpose=transpose, + return_x=return_x, squeeze=squeeze) + + +# Process time responses in a uniform way +def _process_time_response( + sys, tout, yout, xout, transpose=None, return_x=False, + squeeze=None, input=None, output=None): + """Process time response signals. + + This function processes the outputs of the time response functions and + processes the transpose and squeeze keywords. + + Parameters + ---------- + sys : LTI or InputOutputSystem + System that generated the data (used to check if SISO/MIMO). + + T : 1D array + Time values of the output. Ignored if None. + + yout : ndarray + Response of the system. This can either be a 1D array indexed by time + (for SISO systems), a 2D array indexed by output and time (for MIMO + systems with no input indexing, such as initial_response or forced + response) or a 3D array indexed by output, input, and time. + + xout : array, optional + Individual response of each x variable (if return_x is True). For a + SISO system (or if a single input is specified), this should be a 2D + array indexed by the state index and time (for single input systems) + or a 3D array indexed by state, input, and time. Ignored if None. + + transpose : bool, optional + If True, transpose all input and output arrays (for backward + compatibility with MATLAB and :func:`scipy.signal.lsim`). Default + value is False. + + return_x : bool, optional + If True, return the state vector (default = False). + + squeeze : bool, optional + By default, if a system is single-input, single-output (SISO) then the + output response is returned as a 1D array (indexed by time). If + squeeze=True, remove single-dimensional entries from the shape of the + output even if the system is not SISO. If squeeze=False, keep the + output as a 3D array (indexed by the output, input, and time) even if + the system is SISO. The default value can be set using + config.defaults['control.squeeze_time_response']. + + input : int, optional + If present, the response represents only the listed input. + + output : int, optional + If present, the response represents only the listed output. + + Returns + ------- + T : 1D array + Time values of the output + + yout : ndarray + Response of the system. If the system is SISO and squeeze is not + True, the array is 1D (indexed by time). If the system is not SISO or + squeeze is False, the array is either 2D (indexed by output and time) + or 3D (indexed by input, output, and time). + + xout : array, optional + Individual response of each x variable (if return_x is True). For a + SISO system (or if a single input is specified), xout is a 2D array + indexed by the state index and time. For a non-SISO system, xout is a + 3D array indexed by the state, the input, and time. The shape of xout + is not affected by the ``squeeze`` keyword. + """ + # If squeeze was not specified, figure out the default (might remain None) + if squeeze is None: + squeeze = config.defaults['control.squeeze_time_response'] + + # Determine if the system is SISO + issiso = sys.issiso() or (input is not None and output is not None) + + # Figure out whether and how to squeeze output data + if squeeze is True: # squeeze all dimensions yout = np.squeeze(yout) - xout = np.squeeze(xout) + elif squeeze is False: # squeeze no dimensions + pass + elif squeeze is None: # squeeze signals if SISO + if issiso: + if len(yout.shape) == 3: + yout = yout[0][0] # remove input and output + else: + yout = yout[0] # remove input + else: + raise ValueError("unknown squeeze value") + + # Figure out whether and how to squeeze the state data + if issiso and xout is not None and len(xout.shape) > 2: + xout = xout[:, 0, :] # remove input # See if we need to transpose the data back into MATLAB form if transpose: + # Transpose time vector in case we are using np.matrix tout = np.transpose(tout) - yout = np.transpose(yout) - xout = np.transpose(xout) - return tout, yout, xout + # For signals, put the last index (time) into the first slot + yout = np.transpose(yout, np.roll(range(yout.ndim), 1)) + if xout is not None: + xout = np.transpose(xout, np.roll(range(xout.ndim), 1)) + + # Return time, output, and (optionally) state + return (tout, yout, xout) if return_x else (tout, yout) + + +def _get_ss_simo(sys, input=None, output=None, squeeze=None): + """Return a SISO or SIMO state-space version of sys. + This function converts the given system to a state space system in + preparation for simulation and sets the system matrixes to match the + desired input and output. -def _get_ss_simo(sys, input=None, output=None): - """Return a SISO or SIMO state-space version of sys + If input is not specified, select first input and issue warning (legacy + behavior that should eventually not be used). + + If the output is not specified, report on all outputs. - If input is not specified, select first input and issue warning """ - sys_ss = _convertToStateSpace(sys) + # If squeeze was not specified, figure out the default + if squeeze is None: + squeeze = config.defaults['control.squeeze_time_response'] + + sys_ss = _convert_to_statespace(sys) if sys_ss.issiso(): - return sys_ss + return squeeze, sys_ss + elif squeeze == None and (input is None or output is None): + # Don't squeeze outputs if resulting system turns out to be siso + # Note: if we expand input to allow a tuple, need to update this check + squeeze = False + warn = False if input is None: # issue warning if input is not given warn = True input = 0 + if output is None: - return _mimo2simo(sys_ss, input, warn_conversion=warn) + return squeeze, _mimo2simo(sys_ss, input, warn_conversion=warn) else: - return _mimo2siso(sys_ss, input, output, warn_conversion=warn) + return squeeze, _mimo2siso(sys_ss, input, output, warn_conversion=warn) def step_response(sys, T=None, X0=0., input=None, output=None, T_num=None, - transpose=False, return_x=False, squeeze=True): + transpose=False, return_x=False, squeeze=None): # pylint: disable=W0622 - """Step response of a linear system + """Compute the step response for a linear system. - If the system has multiple inputs or outputs (MIMO), one input has - to be selected for the simulation. Optionally, one output may be - selected. The parameters `input` and `output` do this. All other - inputs are set to 0, all other outputs are ignored. + If the system has multiple inputs and/or multiple outputs, the step + response is computed for each input/output pair, with all other inputs set + to zero. Optionally, a single input and/or single output can be selected, + in which case all other inputs are set to 0 and all other outputs are + ignored. For information on the **shape** of parameters `T`, `X0` and return values `T`, `yout`, see :ref:`time-series-convention`. Parameters ---------- - sys: StateSpace or TransferFunction + sys : StateSpace or TransferFunction LTI system to simulate - T: array_like or float, optional + T : array_like or float, optional Time vector, or simulation time duration if a number. If T is not provided, an attempt is made to create it automatically from the dynamics of sys. If sys is continuous-time, the time increment dt @@ -479,44 +627,56 @@ def step_response(sys, T=None, X0=0., input=None, output=None, T_num=None, only tfinal is computed, and final is reduced if it requires too many simulation steps. - X0: array_like or float, optional - Initial condition (default = 0) - - Numbers are converted to constant arrays with the correct shape. + X0 : array_like or float, optional + Initial condition (default = 0). Numbers are converted to constant + arrays with the correct shape. - input: int - Index of the input that will be used in this simulation. + input : int, optional + Only compute the step response for the listed input. If not + specified, the step responses for each independent input are computed. - output: int - Index of the output that will be used in this simulation. Set to None - to not trim outputs + output : int, optional + Only report the step response for the listed output. If not + specified, all outputs are reported. - T_num: int, optional + T_num : int, optional Number of time steps to use in simulation if T is not provided as an array (autocomputed if not given); ignored if sys is discrete-time. - transpose: bool + transpose : bool, optional If True, transpose all input and output arrays (for backward - compatibility with MATLAB and :func:`scipy.signal.lsim`) + compatibility with MATLAB and :func:`scipy.signal.lsim`). Default + value is False. - return_x: bool + return_x : bool, optional If True, return the state vector (default = False). - squeeze: bool, optional (default=True) - If True, remove single-dimensional entries from the shape of - the output. For single output systems, this converts the - output response to a 1D array. + squeeze : bool, optional + By default, if a system is single-input, single-output (SISO) then the + output response is returned as a 1D array (indexed by time). If + squeeze=True, remove single-dimensional entries from the shape of the + output even if the system is not SISO. If squeeze=False, keep the + output as a 3D array (indexed by the output, input, and time) even if + the system is SISO. The default value can be set using + config.defaults['control.squeeze_time_response']. Returns ------- - T: array + T : 1D array Time values of the output - yout: array - Response of the system + yout : ndarray + Response of the system. If the system is SISO and squeeze is not + True, the array is 1D (indexed by time). If the system is not SISO or + squeeze is False, the array is 3D (indexed by the input, output, and + time). - xout: array - Individual response of each x variable + xout : array, optional + Individual response of each x variable (if return_x is True). For a + SISO system (or if a single input is specified), xout is a 2D array + indexed by the state index and time. For a non-SISO system, xout is a + 3D array indexed by the state, the input, and time. The shape of xout + is not affected by the ``squeeze`` keyword. See Also -------- @@ -532,56 +692,104 @@ def step_response(sys, T=None, X0=0., input=None, output=None, T_num=None, >>> T, yout = step_response(sys, T, X0) """ - sys = _get_ss_simo(sys, input, output) + # Create the time and input vectors if T is None or np.asarray(T).size == 1: T = _default_time_vector(sys, N=T_num, tfinal=T, is_step=True) U = np.ones_like(T) - T, yout, xout = forced_response(sys, T, U, X0, transpose=transpose, - squeeze=squeeze) - - if return_x: - return T, yout, xout - - return T, yout - - -def step_info(sys, T=None, T_num=None, SettlingTimeThreshold=0.02, - RiseTimeLimits=(0.1, 0.9)): - ''' + # If we are passed a transfer function and X0 is non-zero, warn the user + if isinstance(sys, TransferFunction) and np.any(X0 != 0): + warnings.warn( + "Non-zero initial condition given for transfer function system. " + "Internal conversion to state space used; may not be consistent " + "with given X0.") + + # Convert to state space so that we can simulate + sys = _convert_to_statespace(sys) + + # Set up arrays to handle the output + ninputs = sys.ninputs if input is None else 1 + noutputs = sys.noutputs if output is None else 1 + yout = np.empty((noutputs, ninputs, np.asarray(T).size)) + xout = np.empty((sys.nstates, ninputs, np.asarray(T).size)) + + # Simulate the response for each input + for i in range(sys.ninputs): + # If input keyword was specified, only simulate for that input + if isinstance(input, int) and i != input: + continue + + # Create a set of single inputs system for simulation + squeeze, simo = _get_ss_simo(sys, i, output, squeeze=squeeze) + + out = forced_response(simo, T, U, X0, transpose=False, + return_x=return_x, squeeze=True) + inpidx = i if input is None else 0 + yout[:, inpidx, :] = out[1] + if return_x: + xout[:, i, :] = out[2] + + return _process_time_response( + sys, out[0], yout, xout, transpose=transpose, return_x=return_x, + squeeze=squeeze, input=input, output=output) + + +def step_info(sysdata, T=None, T_num=None, yfinal=None, + SettlingTimeThreshold=0.02, RiseTimeLimits=(0.1, 0.9)): + """ Step response characteristics (Rise time, Settling Time, Peak and others). Parameters ---------- - sys : StateSpace or TransferFunction - LTI system to simulate - + sysdata : StateSpace or TransferFunction or array_like + The system data. Either LTI system to similate (StateSpace, + TransferFunction), or a time series of step response data. T : array_like or float, optional Time vector, or simulation time duration if a number (time vector is - autocomputed if not given, see :func:`step_response` for more detail) - + autocomputed if not given, see :func:`step_response` for more detail). + Required, if sysdata is a time series of response data. T_num : int, optional Number of time steps to use in simulation if T is not provided as an - array (autocomputed if not given); ignored if sys is discrete-time. - - SettlingTimeThreshold : float value, optional + array; autocomputed if not given; ignored if sysdata is a + discrete-time system or a time series or response data. + yfinal : scalar or array_like, optional + Steady-state response. If not given, sysdata.dcgain() is used for + systems to simulate and the last value of the the response data is + used for a given time series of response data. Scalar for SISO, + (noutputs, ninputs) array_like for MIMO systems. + SettlingTimeThreshold : float, optional Defines the error to compute settling time (default = 0.02) - RiseTimeLimits : tuple (lower_threshold, upper_theshold) Defines the lower and upper threshold for RiseTime computation Returns ------- - S: a dictionary containing: - RiseTime: Time from 10% to 90% of the steady-state value. - SettlingTime: Time to enter inside a default error of 2% - SettlingMin: Minimum value after RiseTime - SettlingMax: Maximum value after RiseTime - Overshoot: Percentage of the Peak relative to steady value - Undershoot: Percentage of undershoot - Peak: Absolute peak value - PeakTime: time of the Peak - SteadyStateValue: Steady-state value + S : dict or list of list of dict + If `sysdata` corresponds to a SISO system, S is a dictionary + containing: + + RiseTime: + Time from 10% to 90% of the steady-state value. + SettlingTime: + Time to enter inside a default error of 2% + SettlingMin: + Minimum value after RiseTime + SettlingMax: + Maximum value after RiseTime + Overshoot: + Percentage of the Peak relative to steady value + Undershoot: + Percentage of undershoot + Peak: + Absolute peak value + PeakTime: + time of the Peak + SteadyStateValue: + Steady-state value + + If `sysdata` corresponds to a MIMO system, `S` is a 2D list of dicts. + To get the step response characteristics from the j-th input to the + i-th output, access ``S[i][j]`` See Also @@ -590,47 +798,166 @@ def step_info(sys, T=None, T_num=None, SettlingTimeThreshold=0.02, Examples -------- - >>> info = step_info(sys, T) - ''' - sys = _get_ss_simo(sys) - if T is None or np.asarray(T).size == 1: - T = _default_time_vector(sys, N=T_num, tfinal=T, is_step=True) - - T, yout = step_response(sys, T) - - # Steady state value - InfValue = yout[-1] - - # RiseTime - tr_lower_index = (np.where(yout >= RiseTimeLimits[0] * InfValue)[0])[0] - tr_upper_index = (np.where(yout >= RiseTimeLimits[1] * InfValue)[0])[0] - RiseTime = T[tr_upper_index] - T[tr_lower_index] - - # SettlingTime - sup_margin = (1. + SettlingTimeThreshold) * InfValue - inf_margin = (1. - SettlingTimeThreshold) * InfValue - # find Steady State looking for the first point out of specified limits - for i in reversed(range(T.size)): - if((yout[i] <= inf_margin) | (yout[i] >= sup_margin)): - SettlingTime = T[i + 1] - break - - PeakIndex = np.abs(yout).argmax() - return { - 'RiseTime': RiseTime, - 'SettlingTime': SettlingTime, - 'SettlingMin': yout[tr_upper_index:].min(), - 'SettlingMax': yout.max(), - 'Overshoot': 100. * (yout.max() - InfValue) / (InfValue - yout[0]), - 'Undershoot': yout.min(), # not very confident about this - 'Peak': yout[PeakIndex], - 'PeakTime': T[PeakIndex], - 'SteadyStateValue': InfValue - } - + >>> from control import step_info, TransferFunction + >>> sys = TransferFunction([-1, 1], [1, 1, 1]) + >>> S = step_info(sys) + >>> for k in S: + ... print(f"{k}: {S[k]:3.4}") + ... + RiseTime: 1.256 + SettlingTime: 9.071 + SettlingMin: 0.9011 + SettlingMax: 1.208 + Overshoot: 20.85 + Undershoot: 27.88 + Peak: 1.208 + PeakTime: 4.187 + SteadyStateValue: 1.0 + + MIMO System: Simulate until a final time of 10. Get the step response + characteristics for the second input and specify a 5% error until the + signal is considered settled. + + >>> from numpy import sqrt + >>> from control import step_info, StateSpace + >>> sys = StateSpace([[-1., -1.], + ... [1., 0.]], + ... [[-1./sqrt(2.), 1./sqrt(2.)], + ... [0, 0]], + ... [[sqrt(2.), -sqrt(2.)]], + ... [[0, 0]]) + >>> S = step_info(sys, T=10., SettlingTimeThreshold=0.05) + >>> for k, v in S[0][1].items(): + ... print(f"{k}: {float(v):3.4}") + RiseTime: 1.212 + SettlingTime: 6.061 + SettlingMin: -1.209 + SettlingMax: -0.9184 + Overshoot: 20.87 + Undershoot: 28.02 + Peak: 1.209 + PeakTime: 4.242 + SteadyStateValue: -1.0 + """ + if isinstance(sysdata, (StateSpace, TransferFunction)): + if T is None or np.asarray(T).size == 1: + T = _default_time_vector(sysdata, N=T_num, tfinal=T, is_step=True) + T, Yout = step_response(sysdata, T, squeeze=False) + if yfinal: + InfValues = np.atleast_2d(yfinal) + else: + InfValues = np.atleast_2d(sysdata.dcgain()) + retsiso = sysdata.issiso() + noutputs = sysdata.noutputs + ninputs = sysdata.ninputs + else: + # Time series of response data + errmsg = ("`sys` must be a LTI system, or time response data" + " with a shape following the python-control" + " time series data convention.") + try: + Yout = np.array(sysdata, dtype=float) + except ValueError: + raise ValueError(errmsg) + if Yout.ndim == 1 or (Yout.ndim == 2 and Yout.shape[0] == 1): + Yout = Yout[np.newaxis, np.newaxis, :] + retsiso = True + elif Yout.ndim == 3: + retsiso = False + else: + raise ValueError(errmsg) + if T is None or Yout.shape[2] != len(np.squeeze(T)): + raise ValueError("For time response data, a matching time vector" + " must be given") + T = np.squeeze(T) + noutputs = Yout.shape[0] + ninputs = Yout.shape[1] + InfValues = np.atleast_2d(yfinal) if yfinal else Yout[:, :, -1] + + ret = [] + for i in range(noutputs): + retrow = [] + for j in range(ninputs): + yout = Yout[i, j, :] + + # Steady state value + InfValue = InfValues[i, j] + sgnInf = np.sign(InfValue.real) + + rise_time: float = np.NaN + settling_time: float = np.NaN + settling_min: float = np.NaN + settling_max: float = np.NaN + peak_value: float = np.Inf + peak_time: float = np.Inf + undershoot: float = np.NaN + overshoot: float = np.NaN + steady_state_value: complex = np.NaN + + if not np.isnan(InfValue) and not np.isinf(InfValue): + # RiseTime + tr_lower_index = np.where( + sgnInf * (yout - RiseTimeLimits[0] * InfValue) >= 0 + )[0][0] + tr_upper_index = np.where( + sgnInf * (yout - RiseTimeLimits[1] * InfValue) >= 0 + )[0][0] + rise_time = T[tr_upper_index] - T[tr_lower_index] + + # SettlingTime + settled = np.where( + np.abs(yout/InfValue-1) >= SettlingTimeThreshold)[0][-1]+1 + # MIMO systems can have unsettled channels without infinite + # InfValue + if settled < len(T): + settling_time = T[settled] + + settling_min = (yout[tr_upper_index:]).min() + settling_max = (yout[tr_upper_index:]).max() + + # Overshoot + y_os = (sgnInf * yout).max() + dy_os = np.abs(y_os) - np.abs(InfValue) + if dy_os > 0: + overshoot = np.abs(100. * dy_os / InfValue) + else: + overshoot = 0 + + # Undershoot + y_us = (sgnInf * yout).min() + dy_us = np.abs(y_us) + if dy_us > 0: + undershoot = np.abs(100. * dy_us / InfValue) + else: + undershoot = 0 + + # Peak + peak_index = np.abs(yout).argmax() + peak_value = np.abs(yout[peak_index]) + peak_time = T[peak_index] + + # SteadyStateValue + steady_state_value = InfValue + + retij = { + 'RiseTime': rise_time, + 'SettlingTime': settling_time, + 'SettlingMin': settling_min, + 'SettlingMax': settling_max, + 'Overshoot': overshoot, + 'Undershoot': undershoot, + 'Peak': peak_value, + 'PeakTime': peak_time, + 'SteadyStateValue': steady_state_value + } + retrow.append(retij) + + ret.append(retrow) + + return ret[0][0] if retsiso else ret def initial_response(sys, T=None, X0=0., input=0, output=None, T_num=None, - transpose=False, return_x=False, squeeze=True): + transpose=False, return_x=False, squeeze=None): # pylint: disable=W0622 """Initial condition response of a linear system @@ -651,42 +978,51 @@ def initial_response(sys, T=None, X0=0., input=0, output=None, T_num=None, autocomputed if not given; see :func:`step_response` for more detail) X0 : array_like or float, optional - Initial condition (default = 0) - - Numbers are converted to constant arrays with the correct shape. + Initial condition (default = 0). Numbers are converted to constant + arrays with the correct shape. input : int Ignored, has no meaning in initial condition calculation. Parameter - ensures compatibility with step_response and impulse_response + ensures compatibility with step_response and impulse_response. output : int Index of the output that will be used in this simulation. Set to None - to not trim outputs + to not trim outputs. T_num : int, optional Number of time steps to use in simulation if T is not provided as an array (autocomputed if not given); ignored if sys is discrete-time. - transpose : bool + transpose : bool, optional If True, transpose all input and output arrays (for backward - compatibility with MATLAB and :func:`scipy.signal.lsim`) + compatibility with MATLAB and :func:`scipy.signal.lsim`). Default + value is False. - return_x : bool + return_x : bool, optional If True, return the state vector (default = False). - squeeze : bool, optional (default=True) - If True, remove single-dimensional entries from the shape of - the output. For single output systems, this converts the - output response to a 1D array. + squeeze : bool, optional + By default, if a system is single-input, single-output (SISO) then the + output response is returned as a 1D array (indexed by time). If + squeeze=True, remove single-dimensional entries from the shape of the + output even if the system is not SISO. If squeeze=False, keep the + output as a 2D array (indexed by the output number and time) even if + the system is SISO. The default value can be set using + config.defaults['control.squeeze_time_response']. Returns ------- T : array Time values of the output + yout : array - Response of the system - xout : array - Individual response of each x variable + Response of the system. If the system is SISO and squeeze is not + True, the array is 1D (indexed by time). If the system is not SISO or + squeeze is False, the array is 2D (indexed by the output number and + time). + + xout : array, optional + Individual response of each x variable (if return_x is True). See Also -------- @@ -700,8 +1036,9 @@ def initial_response(sys, T=None, X0=0., input=0, output=None, T_num=None, Examples -------- >>> T, yout = initial_response(sys, T, X0) + """ - sys = _get_ss_simo(sys, input, output) + squeeze, sys = _get_ss_simo(sys, input, output, squeeze=squeeze) # Create time and input vectors; checking is done in forced_response(...) # The initial vector X0 is created in forced_response(...) if necessary @@ -709,24 +1046,20 @@ def initial_response(sys, T=None, X0=0., input=0, output=None, T_num=None, T = _default_time_vector(sys, N=T_num, tfinal=T, is_step=False) U = np.zeros_like(T) - T, yout, _xout = forced_response(sys, T, U, X0, transpose=transpose, - squeeze=squeeze) - - if return_x: - return T, yout, _xout - - return T, yout + return forced_response(sys, T, U, X0, transpose=transpose, + return_x=return_x, squeeze=squeeze) -def impulse_response(sys, T=None, X0=0., input=0, output=None, T_num=None, - transpose=False, return_x=False, squeeze=True): +def impulse_response(sys, T=None, X0=0., input=None, output=None, T_num=None, + transpose=False, return_x=False, squeeze=None): # pylint: disable=W0622 - """Impulse response of a linear system + """Compute the impulse response for a linear system. - If the system has multiple inputs or outputs (MIMO), one input has - to be selected for the simulation. Optionally, one output may be - selected. The parameters `input` and `output` do this. All other - inputs are set to 0, all other outputs are ignored. + If the system has multiple inputs and/or multiple outputs, the impulse + response is computed for each input/output pair, with all other inputs set + to zero. Optionally, a single input and/or single output can be selected, + in which case all other inputs are set to 0 and all other outputs are + ignored. For information on the **shape** of parameters `T`, `X0` and return values `T`, `yout`, see :ref:`time-series-convention`. @@ -745,37 +1078,53 @@ def impulse_response(sys, T=None, X0=0., input=0, output=None, T_num=None, Numbers are converted to constant arrays with the correct shape. - input : int - Index of the input that will be used in this simulation. + input : int, optional + Only compute the impulse response for the listed input. If not + specified, the impulse responses for each independent input are + computed. - output : int - Index of the output that will be used in this simulation. Set to None - to not trim outputs + output : int, optional + Only report the step response for the listed output. If not + specified, all outputs are reported. T_num : int, optional Number of time steps to use in simulation if T is not provided as an array (autocomputed if not given); ignored if sys is discrete-time. - transpose : bool + transpose : bool, optional If True, transpose all input and output arrays (for backward - compatibility with MATLAB and :func:`scipy.signal.lsim`) + compatibility with MATLAB and :func:`scipy.signal.lsim`). Default + value is False. - return_x : bool + return_x : bool, optional If True, return the state vector (default = False). - squeeze : bool, optional (default=True) - If True, remove single-dimensional entries from the shape of - the output. For single output systems, this converts the - output response to a 1D array. + squeeze : bool, optional + By default, if a system is single-input, single-output (SISO) then the + output response is returned as a 1D array (indexed by time). If + squeeze=True, remove single-dimensional entries from the shape of the + output even if the system is not SISO. If squeeze=False, keep the + output as a 2D array (indexed by the output number and time) even if + the system is SISO. The default value can be set using + config.defaults['control.squeeze_time_response']. Returns ------- T : array Time values of the output + yout : array - Response of the system - xout : array - Individual response of each x variable + Response of the system. If the system is SISO and squeeze is not + True, the array is 1D (indexed by time). If the system is not SISO or + squeeze is False, the array is 2D (indexed by the output number and + time). + + xout : array, optional + Individual response of each x variable (if return_x is True). For a + SISO system (or if a single input is specified), xout is a 2D array + indexed by the state index and time. For a non-SISO system, xout is a + 3D array indexed by the state, the input, and time. The shape of xout + is not affected by the ``squeeze`` keyword. See Also -------- @@ -792,10 +1141,10 @@ def impulse_response(sys, T=None, X0=0., input=0, output=None, T_num=None, >>> T, yout = impulse_response(sys, T, X0) """ - sys = _get_ss_simo(sys, input, output) + # Convert to state space so that we can simulate + sys = _convert_to_statespace(sys) - # if system has direct feedthrough, can't simulate impulse response - # numerically + # Check to make sure there is not a direct term if np.any(sys.D != 0) and isctime(sys): warnings.warn("System has direct feedthrough: ``D != 0``. The " "infinite impulse at ``t=0`` does not appear in the " @@ -813,24 +1162,49 @@ def impulse_response(sys, T=None, X0=0., input=0, output=None, T_num=None, T = _default_time_vector(sys, N=T_num, tfinal=T, is_step=False) U = np.zeros_like(T) - # Compute new X0 that contains the impulse - # We can't put the impulse into U because there is no numerical - # representation for it (infinitesimally short, infinitely high). - # See also: http://www.mathworks.com/support/tech-notes/1900/1901.html - if isctime(sys): - B = np.asarray(sys.B).squeeze() - new_X0 = B + X0 - else: - new_X0 = X0 - U[0] = 1. + # Set up arrays to handle the output + ninputs = sys.ninputs if input is None else 1 + noutputs = sys.noutputs if output is None else 1 + yout = np.empty((noutputs, ninputs, np.asarray(T).size)) + xout = np.empty((sys.nstates, ninputs, np.asarray(T).size)) + + # Simulate the response for each input + for i in range(sys.ninputs): + # If input keyword was specified, only handle that case + if isinstance(input, int) and i != input: + continue + + # Get the system we need to simulate + squeeze, simo = _get_ss_simo(sys, i, output, squeeze=squeeze) + + # + # Compute new X0 that contains the impulse + # + # We can't put the impulse into U because there is no numerical + # representation for it (infinitesimally short, infinitely high). + # See also: http://www.mathworks.com/support/tech-notes/1900/1901.html + # + if isctime(simo): + B = np.asarray(simo.B).squeeze() + new_X0 = B + X0 + else: + new_X0 = X0 + U[0] = 1./simo.dt # unit area impulse + + # Simulate the impulse response fo this input + out = forced_response(simo, T, U, new_X0, transpose=False, + return_x=return_x, squeeze=squeeze) - T, yout, _xout = forced_response(sys, T, U, new_X0, transpose=transpose, - squeeze=squeeze) + # Store the output (and states) + inpidx = i if input is None else 0 + yout[:, inpidx, :] = out[1] + if return_x: + xout[:, i, :] = out[2] - if return_x: - return T, yout, _xout + return _process_time_response( + sys, out[0], yout, xout, transpose=transpose, return_x=return_x, + squeeze=squeeze, input=input, output=output) - return T, yout # utility function to find time period and time increment using pole locations def _ideal_tfinal_and_dt(sys, is_step=True): @@ -846,8 +1220,8 @@ def _ideal_tfinal_and_dt(sys, is_step=True): The system whose time response is to be computed is_step : bool Scales the dc value by the magnitude of the nonzero mode since - integrating the impulse response gives - :math:`\int e^{-\lambda t} = -e^{-\lambda t}/ \lambda` + integrating the impulse response gives + :math:`\\int e^{-\\lambda t} = -e^{-\\lambda t}/ \\lambda` Default is True. Returns @@ -884,14 +1258,14 @@ def _ideal_tfinal_and_dt(sys, is_step=True): default_dt = 0.1 total_cycles = 5 # number of cycles for oscillating modes pts_per_cycle = 25 # Number of points divide a period of oscillation - log_decay_percent = np.log(100) # Factor of reduction for real pole decays + log_decay_percent = np.log(1000) # Factor of reduction for real pole decays - if sys.is_static_gain(): + if sys._isstatic(): tfinal = default_tfinal dt = sys.dt if isdtime(sys, strict=True) else default_dt elif isdtime(sys, strict=True): dt = sys.dt - A = _convertToStateSpace(sys).A + A = _convert_to_statespace(sys).A tfinal = default_tfinal p = eigvals(A) # Array Masks @@ -900,8 +1274,10 @@ def _ideal_tfinal_and_dt(sys, is_step=True): p_u, p = p[m_u], p[~m_u] if p_u.size > 0: m_u = (p_u.real < 0) & (np.abs(p_u.imag) < sqrt_eps) - t_emp = np.max(log_decay_percent / np.abs(np.log(p_u[~m_u])/dt)) - tfinal = max(tfinal, t_emp) + if np.any(~m_u): + t_emp = np.max( + log_decay_percent / np.abs(np.log(p_u[~m_u]) / dt)) + tfinal = max(tfinal, t_emp) # zero - negligible effect on tfinal m_z = np.abs(p) < sqrt_eps @@ -929,7 +1305,7 @@ def _ideal_tfinal_and_dt(sys, is_step=True): if p_int.size > 0: tfinal = tfinal * 5 else: # cont time - sys_ss = _convertToStateSpace(sys) + sys_ss = _convert_to_statespace(sys) # Improve conditioning via balancing and zeroing tiny entries # See for [[1,2,0], [9,1,0.01], [1,2,10*np.pi]] before/after balance b, (sca, perm) = matrix_balance(sys_ss.A, separate=True) @@ -1005,16 +1381,18 @@ def _default_time_vector(sys, N=None, tfinal=None, is_step=True): # only need to use default_tfinal if not given; N is ignored. if tfinal is None: # for discrete time, change from ideal_tfinal if N too large/small - N = int(np.clip(ideal_tfinal/sys.dt, N_min_dt, N_max))# [N_min, N_max] - tfinal = sys.dt * N + # [N_min, N_max] + N = int(np.clip(np.ceil(ideal_tfinal/sys.dt)+1, N_min_dt, N_max)) + tfinal = sys.dt * (N-1) else: - N = int(tfinal/sys.dt) - tfinal = N * sys.dt # make tfinal an integer multiple of sys.dt + N = int(np.ceil(tfinal/sys.dt)) + 1 + tfinal = sys.dt * (N-1) # make tfinal an integer multiple of sys.dt else: if tfinal is None: # for continuous time, simulate to ideal_tfinal but limit N tfinal = ideal_tfinal if N is None: - N = int(np.clip(tfinal/ideal_dt, N_min_ct, N_max)) # N<-[N_min, N_max] + # [N_min, N_max] + N = int(np.clip(np.ceil(tfinal/ideal_dt)+1, N_min_ct, N_max)) - return np.linspace(0, tfinal, N, endpoint=False) + return np.linspace(0, tfinal, N, endpoint=True) diff --git a/control/xferfcn.py b/control/xferfcn.py index 1cba50bd7..99603b253 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -57,23 +57,22 @@ polyadd, polymul, polyval, roots, sqrt, zeros, squeeze, exp, pi, \ where, delete, real, poly, nonzero import scipy as sp -from scipy.signal import lti, tf2zpk, zpk2tf, cont2discrete +from scipy.signal import tf2zpk, zpk2tf, cont2discrete +from scipy.signal import TransferFunction as signalTransferFunction from copy import deepcopy from warnings import warn from itertools import chain from re import sub -from .lti import LTI, timebaseEqual, timebase, isdtime +from .lti import LTI, common_timebase, isdtime, _process_frequency_response from . import config __all__ = ['TransferFunction', 'tf', 'ss2tf', 'tfdata'] # Define module default parameter values -_xferfcn_defaults = { - 'xferfcn.default_dt': None} +_xferfcn_defaults = {} class TransferFunction(LTI): - """TransferFunction(num, den[, dt]) A class for representing transfer functions @@ -89,13 +88,22 @@ class TransferFunction(LTI): means that the numerator of the transfer function from the 6th input to the 3rd output is set to s^2 + 4s + 8. - Discrete-time transfer functions are implemented by using the 'dt' - instance variable and setting it to something other than 'None'. If 'dt' - has a non-zero value, then it must match whenever two transfer functions - are combined. If 'dt' is set to True, the system will be treated as a - discrete time system with unspecified sampling time. The default value of - 'dt' is None and can be changed by changing the value of - ``control.config.defaults['xferfcn.default_dt']``. + A discrete time transfer function is created by specifying a nonzero + 'timebase' dt when the system is constructed: + + * dt = 0: continuous time system (default) + * dt > 0: discrete time system with sampling period 'dt' + * dt = True: discrete time with unspecified sampling period + * dt = None: no timebase specified + + Systems must have compatible timebases in order to be combined. A discrete + time system with unspecified sampling time (`dt = True`) can be combined + with a system having a specified sampling time; the result will be a + discrete time system with the sample time of the latter system. Similarly, + a system with timebase `None` can be combined with a system having any + timebase; the result will have the timebase of the latter system. + The default value of dt can be changed by changing the value of + ``control.config.defaults['control.default_dt']``. The TransferFunction class defines two constants ``s`` and ``z`` that represent the differentiation and delay operators in continuous and @@ -104,9 +112,12 @@ class TransferFunction(LTI): >>> s = TransferFunction.s >>> G = (s + 1)/(s**2 + 2*s + 1) - """ - def __init__(self, *args): + + # Give TransferFunction._rmul_() priority for ndarray * TransferFunction + __array_priority__ = 11 # override ndarray and matrix types + + def __init__(self, *args, **kwargs): """TransferFunction(num, den[, dt]) Construct a transfer function. @@ -124,7 +135,6 @@ def __init__(self, *args): if len(args) == 2: # The user provided a numerator and a denominator. (num, den) = args - dt = config.defaults['xferfcn.default_dt'] elif len(args) == 3: # Discrete time transfer function (num, den, dt) = args @@ -136,11 +146,6 @@ def __init__(self, *args): % type(args[0])) num = args[0].num den = args[0].den - # TODO: not sure this can ever happen since dt is always present - try: - dt = args[0].dt - except NameError: # pragma: no coverage - dt = config.defaults['xferfcn.default_dt'] else: raise ValueError("Needs 1, 2 or 3 arguments; received %i." % len(args)) @@ -198,25 +203,120 @@ def __init__(self, *args): if zeronum: den[i][j] = ones(1) - LTI.__init__(self, inputs, outputs, dt) + LTI.__init__(self, inputs, outputs) self.num = num self.den = den self._truncatecoeff() - def __call__(self, s): - """Evaluate the system's transfer function for a complex variable + # get dt + if len(args) == 2: + # no dt given in positional arguments + if 'dt' in kwargs: + dt = kwargs['dt'] + elif self._isstatic(): + dt = None + else: + dt = config.defaults['control.default_dt'] + elif len(args) == 3: + # Discrete time transfer function + if 'dt' in kwargs: + warn('received multiple dt arguments, ' + 'using positional arg dt=%s' % dt) + elif len(args) == 1: + # TODO: not sure this can ever happen since dt is always present + try: + dt = args[0].dt + except AttributeError: + if self._isstatic(): + dt = None + else: + dt = config.defaults['control.default_dt'] + self.dt = dt - For a SISO transfer function, returns the value of the - transfer function. For a MIMO transfer fuction, returns a - matrix of values evaluated at complex variable s.""" + def __call__(self, x, squeeze=None, warn_infinite=True): + """Evaluate system's transfer function at complex frequencies. - if self.issiso(): - # return a scalar - return self.horner(s)[0][0] - else: - # return a matrix - return self.horner(s) + Returns the complex frequency response `sys(x)` where `x` is `s` for + continuous-time systems and `z` for discrete-time systems. + + In general the system may be multiple input, multiple output + (MIMO), where `m = self.ninputs` number of inputs and `p = + self.noutputs` number of outputs. + + To evaluate at a frequency omega in radians per second, enter + ``x = omega * 1j``, for continuous-time systems, or + ``x = exp(1j * omega * dt)`` for discrete-time systems. Or use + :meth:`TransferFunction.frequency_response`. + + Parameters + ---------- + x : complex or complex 1D array_like + Complex frequencies + squeeze : bool, optional + If squeeze=True, remove single-dimensional entries from the shape + of the output even if the system is not SISO. If squeeze=False, + keep all indices (output, input and, if omega is array_like, + frequency) even if the system is SISO. The default value can be + set using config.defaults['control.squeeze_frequency_response']. + If True and the system is single-input single-output (SISO), + return a 1D array rather than a 3D array. Default value (True) + set by config.defaults['control.squeeze_frequency_response']. + warn_infinite : bool, optional + If set to `False`, turn off divide by zero warning. + + Returns + ------- + fresp : complex ndarray + The frequency response of the system. If the system is SISO and + squeeze is not True, the shape of the array matches the shape of + omega. If the system is not SISO or squeeze is False, the first + two dimensions of the array are indices for the output and input + and the remaining dimensions match omega. If ``squeeze`` is True + then single-dimensional axes are removed. + + """ + out = self.horner(x, warn_infinite=warn_infinite) + return _process_frequency_response(self, x, out, squeeze=squeeze) + + def horner(self, x, warn_infinite=True): + """Evaluate system's transfer function at complex frequency + using Horner's method. + + Evaluates `sys(x)` where `x` is `s` for continuous-time systems and `z` + for discrete-time systems. + + Expects inputs and outputs to be formatted correctly. Use ``sys(x)`` + for a more user-friendly interface. + + Parameters + ---------- + x : complex array_like or complex scalar + Complex frequencies + + Returns + ------- + output : (self.noutputs, self.ninputs, len(x)) complex ndarray + Frequency response + + """ + # Make sure the argument is a 1D array of complex numbers + x_arr = np.atleast_1d(x).astype(complex, copy=False) + + # Make sure that we are operating on a simple list + if len(x_arr.shape) > 1: + raise ValueError("input list must be 1D") + + # Initialize the output matrix in the proper shape + out = empty((self.noutputs, self.ninputs, len(x_arr)), dtype=complex) + + # Set up error processing based on warn_infinite flag + with np.errstate(all='warn' if warn_infinite else 'ignore'): + for i in range(self.noutputs): + for j in range(self.ninputs): + out[i][j] = (polyval(self.num[i][j], x_arr) / + polyval(self.den[i][j], x_arr)) + return out def _truncatecoeff(self): """Remove extraneous zero coefficients from num and den. @@ -230,8 +330,8 @@ def _truncatecoeff(self): # Beware: this is a shallow copy. This should be okay. data = [self.num, self.den] for p in range(len(data)): - for i in range(self.outputs): - for j in range(self.inputs): + for i in range(self.noutputs): + for j in range(self.ninputs): # Find the first nontrivial coefficient. nonzero = None for k in range(data[p][i][j].size): @@ -250,14 +350,14 @@ def _truncatecoeff(self): def __str__(self, var=None): """String representation of the transfer function.""" - mimo = self.inputs > 1 or self.outputs > 1 + mimo = self.ninputs > 1 or self.noutputs > 1 if var is None: # TODO: replace with standard calls to lti functions var = 's' if self.dt is None or self.dt == 0 else 'z' outstr = "" - for i in range(self.inputs): - for j in range(self.outputs): + for i in range(self.ninputs): + for j in range(self.noutputs): if mimo: outstr += "\nInput %i to output %i:" % (i + 1, j + 1) @@ -299,7 +399,7 @@ def __repr__(self): def _repr_latex_(self, var=None): """LaTeX representation of transfer function, for Jupyter notebook""" - mimo = self.inputs > 1 or self.outputs > 1 + mimo = self.ninputs > 1 or self.noutputs > 1 if var is None: # ! TODO: replace with standard calls to lti functions @@ -310,8 +410,8 @@ def _repr_latex_(self, var=None): if mimo: out.append(r"\begin{bmatrix}") - for i in range(self.outputs): - for j in range(self.inputs): + for i in range(self.noutputs): + for j in range(self.ninputs): # Convert the numerator and denominator polynomials to strings. numstr = _tf_polynomial_to_string(self.num[i][j], var=var) denstr = _tf_polynomial_to_string(self.den[i][j], var=var) @@ -321,7 +421,7 @@ def _repr_latex_(self, var=None): out += [r"\frac{", numstr, "}{", denstr, "}"] - if mimo and j < self.outputs - 1: + if mimo and j < self.noutputs - 1: out.append("&") if mimo: @@ -342,8 +442,8 @@ def __neg__(self): """Negate a transfer function.""" num = deepcopy(self.num) - for i in range(self.outputs): - for j in range(self.inputs): + for i in range(self.noutputs): + for j in range(self.ninputs): num[i][j] *= -1 return TransferFunction(num, self.den, self.dt) @@ -356,34 +456,27 @@ def __add__(self, other): if isinstance(other, StateSpace): other = _convert_to_transfer_function(other) elif not isinstance(other, TransferFunction): - other = _convert_to_transfer_function(other, inputs=self.inputs, - outputs=self.outputs) + other = _convert_to_transfer_function(other, inputs=self.ninputs, + outputs=self.noutputs) # Check that the input-output sizes are consistent. - if self.inputs != other.inputs: + if self.ninputs != other.ninputs: raise ValueError( "The first summand has %i input(s), but the second has %i." - % (self.inputs, other.inputs)) - if self.outputs != other.outputs: + % (self.ninputs, other.ninputs)) + if self.noutputs != other.noutputs: raise ValueError( "The first summand has %i output(s), but the second has %i." - % (self.outputs, other.outputs)) - - # Figure out the sampling time to use - if self.dt is None and other.dt is not None: - dt = other.dt # use dt from second argument - elif (other.dt is None and self.dt is not None) or \ - (timebaseEqual(self, other)): - dt = self.dt # use dt from first argument - else: - raise ValueError("Systems have different sampling times") + % (self.noutputs, other.noutputs)) + + dt = common_timebase(self.dt, other.dt) # Preallocate the numerator and denominator of the sum. - num = [[[] for j in range(self.inputs)] for i in range(self.outputs)] - den = [[[] for j in range(self.inputs)] for i in range(self.outputs)] + num = [[[] for j in range(self.ninputs)] for i in range(self.noutputs)] + den = [[[] for j in range(self.ninputs)] for i in range(self.noutputs)] - for i in range(self.outputs): - for j in range(self.inputs): + for i in range(self.noutputs): + for j in range(self.ninputs): num[i][j], den[i][j] = _add_siso( self.num[i][j], self.den[i][j], other.num[i][j], other.den[i][j]) @@ -406,28 +499,21 @@ def __mul__(self, other): """Multiply two LTI objects (serial connection).""" # Convert the second argument to a transfer function. if isinstance(other, (int, float, complex, np.number)): - other = _convert_to_transfer_function(other, inputs=self.inputs, - outputs=self.inputs) + other = _convert_to_transfer_function(other, inputs=self.ninputs, + outputs=self.ninputs) else: other = _convert_to_transfer_function(other) # Check that the input-output sizes are consistent. - if self.inputs != other.outputs: + if self.ninputs != other.noutputs: raise ValueError( "C = A * B: A has %i column(s) (input(s)), but B has %i " - "row(s)\n(output(s))." % (self.inputs, other.outputs)) + "row(s)\n(output(s))." % (self.ninputs, other.noutputs)) - inputs = other.inputs - outputs = self.outputs + inputs = other.ninputs + outputs = self.noutputs - # Figure out the sampling time to use - if self.dt is None and other.dt is not None: - dt = other.dt # use dt from second argument - elif (other.dt is None and self.dt is not None) or \ - (self.dt == other.dt): - dt = self.dt # use dt from first argument - else: - raise ValueError("Systems have different sampling times") + dt = common_timebase(self.dt, other.dt) # Preallocate the numerator and denominator of the sum. num = [[[0] for j in range(inputs)] for i in range(outputs)] @@ -435,13 +521,13 @@ def __mul__(self, other): # Temporary storage for the summands needed to find the (i, j)th # element of the product. - num_summand = [[] for k in range(self.inputs)] - den_summand = [[] for k in range(self.inputs)] + num_summand = [[] for k in range(self.ninputs)] + den_summand = [[] for k in range(self.ninputs)] # Multiply & add. for row in range(outputs): for col in range(inputs): - for k in range(self.inputs): + for k in range(self.ninputs): num_summand[k] = polymul( self.num[row][k], other.num[k][col]) den_summand[k] = polymul( @@ -457,28 +543,21 @@ def __rmul__(self, other): # Convert the second argument to a transfer function. if isinstance(other, (int, float, complex, np.number)): - other = _convert_to_transfer_function(other, inputs=self.inputs, - outputs=self.inputs) + other = _convert_to_transfer_function(other, inputs=self.ninputs, + outputs=self.ninputs) else: other = _convert_to_transfer_function(other) # Check that the input-output sizes are consistent. - if other.inputs != self.outputs: + if other.ninputs != self.noutputs: raise ValueError( "C = A * B: A has %i column(s) (input(s)), but B has %i " - "row(s)\n(output(s))." % (other.inputs, self.outputs)) + "row(s)\n(output(s))." % (other.ninputs, self.noutputs)) - inputs = self.inputs - outputs = other.outputs + inputs = self.ninputs + outputs = other.noutputs - # Figure out the sampling time to use - if self.dt is None and other.dt is not None: - dt = other.dt # use dt from second argument - elif (other.dt is None and self.dt is not None) \ - or (self.dt == other.dt): - dt = self.dt # use dt from first argument - else: - raise ValueError("Systems have different sampling times") + dt = common_timebase(self.dt, other.dt) # Preallocate the numerator and denominator of the sum. num = [[[0] for j in range(inputs)] for i in range(outputs)] @@ -487,12 +566,12 @@ def __rmul__(self, other): # Temporary storage for the summands needed to find the # (i, j)th element # of the product. - num_summand = [[] for k in range(other.inputs)] - den_summand = [[] for k in range(other.inputs)] + num_summand = [[] for k in range(other.ninputs)] + den_summand = [[] for k in range(other.ninputs)] for i in range(outputs): # Iterate through rows of product. for j in range(inputs): # Iterate through columns of product. - for k in range(other.inputs): # Multiply & add. + for k in range(other.ninputs): # Multiply & add. num_summand[k] = polymul(other.num[i][k], self.num[k][j]) den_summand[k] = polymul(other.den[i][k], self.den[k][j]) num[i][j], den[i][j] = _add_siso( @@ -507,25 +586,18 @@ def __truediv__(self, other): if isinstance(other, (int, float, complex, np.number)): other = _convert_to_transfer_function( - other, inputs=self.inputs, - outputs=self.inputs) + other, inputs=self.ninputs, + outputs=self.ninputs) else: other = _convert_to_transfer_function(other) - if (self.inputs > 1 or self.outputs > 1 or - other.inputs > 1 or other.outputs > 1): + if (self.ninputs > 1 or self.noutputs > 1 or + other.ninputs > 1 or other.noutputs > 1): raise NotImplementedError( "TransferFunction.__truediv__ is currently \ implemented only for SISO systems.") - # Figure out the sampling time to use - if self.dt is None and other.dt is not None: - dt = other.dt # use dt from second argument - elif (other.dt is None and self.dt is not None) or \ - (self.dt == other.dt): - dt = self.dt # use dt from first argument - else: - raise ValueError("Systems have different sampling times") + dt = common_timebase(self.dt, other.dt) num = polymul(self.num[0][0], other.den[0][0]) den = polymul(self.den[0][0], other.num[0][0]) @@ -541,13 +613,13 @@ def __rtruediv__(self, other): """Right divide two LTI objects.""" if isinstance(other, (int, float, complex, np.number)): other = _convert_to_transfer_function( - other, inputs=self.inputs, - outputs=self.inputs) + other, inputs=self.ninputs, + outputs=self.ninputs) else: other = _convert_to_transfer_function(other) - if (self.inputs > 1 or self.outputs > 1 or - other.inputs > 1 or other.outputs > 1): + if (self.ninputs > 1 or self.noutputs > 1 or + other.ninputs > 1 or other.noutputs > 1): raise NotImplementedError( "TransferFunction.__rtruediv__ is currently implemented only " "for SISO systems.") @@ -608,105 +680,19 @@ def __getitem__(self, key): else: return TransferFunction(num, den, self.dt) - def evalfr(self, omega): - """Evaluate a transfer function at a single angular frequency. - - self._evalfr(omega) returns the value of the transfer function - matrix with input value s = i * omega. - - """ - warn("TransferFunction.evalfr(omega) will be deprecated in a " - "future release of python-control; use evalfr(sys, omega*1j) " - "instead", PendingDeprecationWarning) - return self._evalfr(omega) - - def _evalfr(self, omega): - """Evaluate a transfer function at a single angular frequency.""" - # TODO: implement for discrete time systems - if isdtime(self, strict=True): - # Convert the frequency to discrete time - dt = timebase(self) - s = exp(1.j * omega * dt) - if np.any(omega * dt > pi): - warn("_evalfr: frequency evaluation above Nyquist frequency") - else: - s = 1.j * omega - - return self.horner(s) - - def horner(self, s): - """Evaluate the systems's transfer function for a complex variable - - Returns a matrix of values evaluated at complex variable s. - """ - - # Preallocate the output. - if getattr(s, '__iter__', False): - out = empty((self.outputs, self.inputs, len(s)), dtype=complex) - else: - out = empty((self.outputs, self.inputs), dtype=complex) - - for i in range(self.outputs): - for j in range(self.inputs): - out[i][j] = (polyval(self.num[i][j], s) / - polyval(self.den[i][j], s)) - - return out - def freqresp(self, omega): - """Evaluate the transfer function at a list of angular frequencies. - - Reports the frequency response of the system, - - G(j*omega) = mag*exp(j*phase) - - for continuous time. For discrete time systems, the response is - evaluated around the unit circle such that - - G(exp(j*omega*dt)) = mag*exp(j*phase). - - Parameters - ---------- - omega : array_like - A list of frequencies in radians/sec at which the system should be - evaluated. The list can be either a python list or a numpy array - and will be sorted before evaluation. + """(deprecated) Evaluate transfer function at complex frequencies. - Returns - ------- - mag : (self.outputs, self.inputs, len(omega)) ndarray - The magnitude (absolute value, not dB or log10) of the system - frequency response. - phase : (self.outputs, self.inputs, len(omega)) ndarray - The wrapped phase in radians of the system frequency response. - omega : ndarray or list or tuple - The list of sorted frequencies at which the response was - evaluated. + .. deprecated::0.9.0 + Method has been given the more pythonic name + :meth:`TransferFunction.frequency_response`. Or use + :func:`freqresp` in the MATLAB compatibility module. """ - # Preallocate outputs. - numfreq = len(omega) - mag = empty((self.outputs, self.inputs, numfreq)) - phase = empty((self.outputs, self.inputs, numfreq)) - - # Figure out the frequencies - omega.sort() - if isdtime(self, strict=True): - dt = timebase(self) - slist = np.array([exp(1.j * w * dt) for w in omega]) - if max(omega) * dt > pi: - warn("freqresp: frequency evaluation above Nyquist frequency") - else: - slist = np.array([1j * w for w in omega]) - - # Compute frequency response for each input/output pair - for i in range(self.outputs): - for j in range(self.inputs): - fresp = (polyval(self.num[i][j], slist) / - polyval(self.den[i][j], slist)) - mag[i, j, :] = abs(fresp) - phase[i, j, :] = angle(fresp) - - return mag, phase, 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) + return self.frequency_response(omega) def pole(self): """Compute the poles of a transfer function.""" @@ -718,7 +704,7 @@ def pole(self): def zero(self): """Compute the zeros of a transfer function.""" - if self.inputs > 1 or self.outputs > 1: + if self.ninputs > 1 or self.noutputs > 1: raise NotImplementedError( "TransferFunction.zero is currently only implemented " "for SISO systems.") @@ -730,21 +716,13 @@ def feedback(self, other=1, sign=-1): """Feedback interconnection between two LTI objects.""" other = _convert_to_transfer_function(other) - if (self.inputs > 1 or self.outputs > 1 or - other.inputs > 1 or other.outputs > 1): + if (self.ninputs > 1 or self.noutputs > 1 or + other.ninputs > 1 or other.noutputs > 1): # TODO: MIMO feedback raise NotImplementedError( "TransferFunction.feedback is currently only implemented " "for SISO functions.") - - # Figure out the sampling time to use - if self.dt is None and other.dt is not None: - dt = other.dt # use dt from second argument - elif (other.dt is None and self.dt is not None) or \ - (self.dt == other.dt): - dt = self.dt # use dt from first argument - else: - raise ValueError("Systems have different sampling times") + dt = common_timebase(self.dt, other.dt) num1 = self.num[0][0] den1 = self.den[0][0] @@ -770,11 +748,11 @@ def minreal(self, tol=None): sqrt_eps = sqrt(float_info.epsilon) # pre-allocate arrays - num = [[[] for j in range(self.inputs)] for i in range(self.outputs)] - den = [[[] for j in range(self.inputs)] for i in range(self.outputs)] + num = [[[] for j in range(self.ninputs)] for i in range(self.noutputs)] + den = [[[] for j in range(self.ninputs)] for i in range(self.noutputs)] - for i in range(self.outputs): - for j in range(self.inputs): + for i in range(self.noutputs): + for j in range(self.ninputs): # split up in zeros, poles and gain newzeros = [] @@ -801,7 +779,7 @@ def minreal(self, tol=None): # end result return TransferFunction(num, den, self.dt) - def returnScipySignalLTI(self): + def returnScipySignalLTI(self, strict=True): """Return a list of a list of :class:`scipy.signal.lti` objects. For instance, @@ -809,22 +787,44 @@ def returnScipySignalLTI(self): >>> out = tfobject.returnScipySignalLTI() >>> out[3][5] - is a class:`scipy.signal.lti` object corresponding to the + is a :class:`scipy.signal.lti` object corresponding to the transfer function from the 6th input to the 4th output. + Parameters + ---------- + strict : bool, optional + True (default): + The timebase `tfobject.dt` cannot be None; it must be + continuous (0) or discrete (True or > 0). + False: + if `tfobject.dt` is None, continuous time + :class:`scipy.signal.lti` objects are returned + + Returns + ------- + out : list of list of :class:`scipy.signal.TransferFunction` + continuous time (inheriting from :class:`scipy.signal.lti`) + or discrete time (inheriting from :class:`scipy.signal.dlti`) + SISO objects """ + if strict and self.dt is None: + raise ValueError("with strict=True, dt cannot be None") - # TODO: implement for discrete time systems - if self.dt != 0 and self.dt is not None: - raise NotImplementedError("Function not \ - implemented in discrete time") + if self.dt: + kwdt = {'dt': self.dt} + else: + # scipy convention for continuous time lti systems: call without + # dt keyword argument + kwdt = {} # Preallocate the output. - out = [[[] for j in range(self.inputs)] for i in range(self.outputs)] + out = [[[] for j in range(self.ninputs)] for i in range(self.noutputs)] - for i in range(self.outputs): - for j in range(self.inputs): - out[i][j] = lti(self.num[i][j], self.den[i][j]) + for i in range(self.noutputs): + for j in range(self.ninputs): + out[i][j] = signalTransferFunction(self.num[i][j], + self.den[i][j], + **kwdt) return out @@ -850,7 +850,7 @@ def _common_den(self, imag_tol=None, allow_nonproper=False): Returns ------- num: array - n by n by kd where n = max(sys.outputs,sys.inputs) + n by n by kd where n = max(sys.noutputs,sys.ninputs) kd = max(denorder)+1 Multi-dimensional array of numerator coefficients. num[i,j] gives the numerator coefficient array for the ith output and jth @@ -860,7 +860,7 @@ def _common_den(self, imag_tol=None, allow_nonproper=False): order of the common denominator, num will be returned as None den: array - sys.inputs by kd + sys.ninputs by kd Multi-dimensional array of coefficients for common denominator polynomial, one row per input. The array is prepared for use in slycot td04ad, the first element is the highest-order polynomial @@ -879,7 +879,7 @@ def _common_den(self, imag_tol=None, allow_nonproper=False): # Machine precision for floats. eps = finfo(float).eps - real_tol = sqrt(eps * self.inputs * self.outputs) + real_tol = sqrt(eps * self.ninputs * self.noutputs) # The tolerance to use in deciding if a pole is complex if (imag_tol is None): @@ -887,7 +887,7 @@ def _common_den(self, imag_tol=None, allow_nonproper=False): # A list to keep track of cumulative poles found as we scan # self.den[..][..] - poles = [[] for j in range(self.inputs)] + poles = [[] for j in range(self.ninputs)] # RvP, new implementation 180526, issue #194 # BG, modification, issue #343, PR #354 @@ -898,9 +898,9 @@ def _common_den(self, imag_tol=None, allow_nonproper=False): # do not calculate minreal. Rory's hint .minreal() poleset = [] - for i in range(self.outputs): + for i in range(self.noutputs): poleset.append([]) - for j in range(self.inputs): + for j in range(self.ninputs): if abs(self.num[i][j]).max() <= eps: poleset[-1].append([array([], dtype=float), roots(self.den[i][j]), 0.0, [], 0]) @@ -909,8 +909,8 @@ def _common_den(self, imag_tol=None, allow_nonproper=False): poleset[-1].append([z, p, k, [], 0]) # collect all individual poles - for j in range(self.inputs): - for i in range(self.outputs): + for j in range(self.ninputs): + for i in range(self.noutputs): currentpoles = poleset[i][j][1] nothave = ones(currentpoles.shape, dtype=bool) for ip, p in enumerate(poles[j]): @@ -935,20 +935,20 @@ def _common_den(self, imag_tol=None, allow_nonproper=False): # figure out maximum number of poles, for sizing the den maxindex = max([len(p) for p in poles]) - den = zeros((self.inputs, maxindex + 1), dtype=float) - num = zeros((max(1, self.outputs, self.inputs), - max(1, self.outputs, self.inputs), + den = zeros((self.ninputs, maxindex + 1), dtype=float) + num = zeros((max(1, self.noutputs, self.ninputs), + max(1, self.noutputs, self.ninputs), maxindex + 1), dtype=float) - denorder = zeros((self.inputs,), dtype=int) + denorder = zeros((self.ninputs,), dtype=int) havenonproper = False - for j in range(self.inputs): + for j in range(self.ninputs): if not len(poles[j]): # no poles matching this input; only one or more gains den[j, 0] = 1.0 - for i in range(self.outputs): + for i in range(self.noutputs): num[i, j, 0] = poleset[i][j][2] else: # create the denominator matching this input @@ -958,7 +958,7 @@ def _common_den(self, imag_tol=None, allow_nonproper=False): denorder[j] = maxindex # now create the numerator, also padded on the right - for i in range(self.outputs): + for i in range(self.noutputs): # start with the current set of zeros for this output nwzeros = list(poleset[i][j][0]) # add all poles not found in the original denominator, @@ -1025,7 +1025,7 @@ def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None): Returns ------- - sysd : StateSpace system + sysd : TransferFunction system Discrete time system, with sampling rate Ts Notes @@ -1055,53 +1055,45 @@ def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None): numd, dend, _ = cont2discrete(sys, Twarp, method, alpha) return TransferFunction(numd[0, :], dend, Ts) - def dcgain(self): + def dcgain(self, warn_infinite=False): """Return the zero-frequency (or DC) gain For a continous-time transfer function G(s), the DC gain is G(0) For a discrete-time transfer function G(z), the DC gain is G(1) + Parameters + ---------- + warn_infinite : bool, optional + By default, don't issue a warning message if the zero-frequency + gain is infinite. Setting `warn_infinite` to generate the warning + message. + Returns ------- - gain : ndarray - The zero-frequency gain + gain : (noutputs, ninputs) ndarray or scalar + Array or scalar value for SISO systems, depending on + config.defaults['control.squeeze_frequency_response']. + The value of the array elements or the scalar is either the + zero-frequency (or DC) gain, or `inf`, if the frequency response + is singular. + + For real valued systems, the empty imaginary part of the + complex zero-frequency response is discarded and a real array or + scalar is returned. """ - if self.isctime(): - return self._dcgain_cont() - else: - return self(1) - - def _dcgain_cont(self): - """_dcgain_cont() -> DC gain as matrix or scalar - - Special cased evaluation at 0 for continuous-time systems.""" - gain = np.empty((self.outputs, self.inputs), dtype=float) - for i in range(self.outputs): - for j in range(self.inputs): - num = self.num[i][j][-1] - den = self.den[i][j][-1] - if den: - gain[i][j] = num / den - else: - if num: - # numerator nonzero: infinite gain - gain[i][j] = np.inf - else: - # numerator is zero too: give up - gain[i][j] = np.nan - return np.squeeze(gain) + return self._dcgain(warn_infinite) - def is_static_gain(self): - """returns True if and only if all of the numerator and denominator - polynomials of the (possibly MIMO) transfer function are zeroth order, + def _isstatic(self): + """returns True if and only if all of the numerator and denominator + polynomials of the (possibly MIMO) transfer function are zeroth order, that is, if the system has no dynamics. """ - for list_of_polys in self.num, self.den: + for list_of_polys in self.num, self.den: for row in list_of_polys: for poly in row: - if len(poly) > 1: + if len(poly) > 1: return False return True - + # c2d function contributed by Benjamin White, Oct 2012 def _c2d_matched(sysC, Ts): # Pole-zero match method of continuous to discrete time conversion @@ -1235,14 +1227,14 @@ def _convert_to_transfer_function(sys, **kw): return sys elif isinstance(sys, StateSpace): - if 0 == sys.states: + if 0 == sys.nstates: # Slycot doesn't like static SS->TF conversion, so handle # it first. Can't join this with the no-Slycot branch, # since that doesn't handle general MIMO systems - num = [[[sys.D[i, j]] for j in range(sys.inputs)] - for i in range(sys.outputs)] - den = [[[1.] for j in range(sys.inputs)] - for i in range(sys.outputs)] + num = [[[sys.D[i, j]] for j in range(sys.ninputs)] + for i in range(sys.noutputs)] + den = [[[1.] for j in range(sys.ninputs)] + for i in range(sys.noutputs)] else: try: from slycot import tb04ad @@ -1254,17 +1246,17 @@ def _convert_to_transfer_function(sys, **kw): # Use Slycot to make the transformation # Make sure to convert system matrices to numpy arrays tfout = tb04ad( - sys.states, sys.inputs, sys.outputs, array(sys.A), + sys.nstates, sys.ninputs, sys.noutputs, array(sys.A), array(sys.B), array(sys.C), array(sys.D), tol1=0.0) # Preallocate outputs. - num = [[[] for j in range(sys.inputs)] - for i in range(sys.outputs)] - den = [[[] for j in range(sys.inputs)] - for i in range(sys.outputs)] + num = [[[] for j in range(sys.ninputs)] + for i in range(sys.noutputs)] + den = [[[] for j in range(sys.ninputs)] + for i in range(sys.noutputs)] - for i in range(sys.outputs): - for j in range(sys.inputs): + for i in range(sys.noutputs): + for j in range(sys.ninputs): num[i][j] = list(tfout[6][i, j, :]) # Each transfer function matrix row # has a common denominator. @@ -1272,7 +1264,7 @@ def _convert_to_transfer_function(sys, **kw): except ImportError: # If slycot is not available, use signal.lti (SISO only) - if sys.inputs != 1 or sys.outputs != 1: + if sys.ninputs != 1 or sys.noutputs != 1: raise TypeError("No support for MIMO without slycot.") # Do the conversion using sp.signal.ss2tf @@ -1300,19 +1292,16 @@ def _convert_to_transfer_function(sys, **kw): # If this is array-like, try to create a constant feedthrough try: - D = array(sys) + D = array(sys, ndmin=2) outputs, inputs = D.shape num = [[[D[i, j]] for j in range(inputs)] for i in range(outputs)] den = [[[1] for j in range(inputs)] for i in range(outputs)] return TransferFunction(num, den) - except Exception as e: - print("Failure to assume argument is matrix-like in" - " _convertToTransferFunction, result %s" % e) + except: + raise TypeError("Can't convert given type to TransferFunction system.") - raise TypeError("Can't convert given type to TransferFunction system.") - -def tf(*args): +def tf(*args, **kwargs): """tf(num, den[, dt]) Create a transfer function system. Can create MIMO systems. @@ -1402,7 +1391,7 @@ def tf(*args): """ if len(args) == 2 or len(args) == 3: - return TransferFunction(*args) + return TransferFunction(*args, **kwargs) elif len(args) == 1: # Look for special cases defining differential/delay operator if args[0] == 's': @@ -1423,7 +1412,7 @@ def tf(*args): raise ValueError("Needs 1 or 2 arguments; received %i." % len(args)) -def ss2tf(*args): +def ss2tf(*args, **kwargs): """ss2tf(sys) Transform a state space system to a transfer function. @@ -1488,7 +1477,7 @@ def ss2tf(*args): from .statesp import StateSpace if len(args) == 4 or len(args) == 5: # Assume we were given the A, B, C, D matrix and (optional) dt - return _convert_to_transfer_function(StateSpace(*args)) + return _convert_to_transfer_function(StateSpace(*args, **kwargs)) elif len(args) == 1: sys = args[0] @@ -1569,12 +1558,11 @@ def _clean_part(data): for i in range(len(data)): for j in range(len(data[i])): for k in range(len(data[i][j])): - if isinstance(data[i][j][k], (int, np.int)): + if isinstance(data[i][j][k], (int, np.int32, np.int64)): data[i][j][k] = float(data[i][j][k]) return data - # Define constants to represent differentiation, unit delay TransferFunction.s = TransferFunction([1, 0], [1], 0) TransferFunction.z = TransferFunction([1, 0], [1], True) diff --git a/doc/classes.rst b/doc/classes.rst index 0981843ca..fdf39a457 100644 --- a/doc/classes.rst +++ b/doc/classes.rst @@ -16,18 +16,28 @@ these directly. TransferFunction StateSpace FrequencyResponseData - ~iosys.InputOutputSystem + InputOutputSystem Input/output system subclasses ============================== -.. currentmodule:: control.iosys - Input/output systems are accessed primarily via a set of subclasses that allow for linear, nonlinear, and interconnected elements: .. autosummary:: :toctree: generated/ + InterconnectedSystem + LinearICSystem LinearIOSystem NonlinearIOSystem - InterconnectedSystem + +Additional classes +================== +.. autosummary:: + + flatsys.BasisFamily + flatsys.FlatSystem + flatsys.LinearFlatSystem + flatsys.PolyFamily + flatsys.SystemTrajectory + optimal.OptimalControlProblem diff --git a/doc/control.rst b/doc/control.rst index d44de3f04..e8a29deb9 100644 --- a/doc/control.rst +++ b/doc/control.rst @@ -42,6 +42,7 @@ Frequency domain plotting :toctree: generated/ bode_plot + describing_function_plot nyquist_plot gangof4_plot nichols_plot @@ -85,6 +86,7 @@ Control system analysis :toctree: generated/ dcgain + describing_function evalfr freqresp margin @@ -139,12 +141,15 @@ Nonlinear system support .. autosummary:: :toctree: generated/ - ~iosys.find_eqpt - ~iosys.linearize - ~iosys.input_output_response - ~iosys.ss2io - ~iosys.tf2io - flatsys.point_to_point + describing_function + find_eqpt + interconnect + linearize + input_output_response + ss2io + summing_junction + tf2io + flatsys.point_to_point .. _utility-and-conversions: @@ -154,6 +159,7 @@ Utility functions and conversions :toctree: generated/ augw + bdschur canonical_form damp db2mag @@ -162,6 +168,7 @@ Utility functions and conversions issiso issys mag2db + modal_form observable_form pade reachable_form diff --git a/doc/conventions.rst b/doc/conventions.rst index 99789bc9e..4a3d78926 100644 --- a/doc/conventions.rst +++ b/doc/conventions.rst @@ -80,27 +80,24 @@ Discrete time systems A discrete time system is created by specifying a nonzero 'timebase', dt. The timebase argument can be given when a system is constructed: -* dt = None: no timebase specified (default) -* dt = 0: continuous time system +* dt = 0: continuous time system (default) * dt > 0: discrete time system with sampling period 'dt' * dt = True: discrete time with unspecified sampling period +* dt = None: no timebase specified Only the :class:`StateSpace`, :class:`TransferFunction`, and :class:`InputOutputSystem` classes allow explicit representation of discrete time systems. -Systems must have compatible timebases in order to be combined. A system -with timebase `None` can be combined with a system having a specified -timebase; the result will have the timebase of the latter system. -Similarly, a discrete time system with unspecified sampling time (`dt = -True`) can be combined with a system having a specified sampling time; -the result will be a discrete time system with the sample time of the latter -system. For continuous time systems, the :func:`sample_system` function or -the :meth:`StateSpace.sample` and :meth:`TransferFunction.sample` methods +Systems must have compatible timebases in order to be combined. A discrete time +system with unspecified sampling time (`dt = True`) can be combined with a system +having a specified sampling time; the result will be a discrete time system with the sample time of the latter +system. Similarly, a system with timebase `None` can be combined with a system having a specified +timebase; the result will have the timebase of the latter system. For continuous +time systems, the :func:`sample_system` function or the :meth:`StateSpace.sample` and :meth:`TransferFunction.sample` methods can be used to create a discrete time system from a continuous time system. See :ref:`utility-and-conversions`. The default value of 'dt' can be changed by -changing the values of ``control.config.defaults['statesp.default_dt']`` and -``control.config.defaults['xferfcn.default_dt']``. +changing the value of ``control.config.defaults['control.default_dt']``. Conversion between representations ---------------------------------- diff --git a/doc/descfcn.rst b/doc/descfcn.rst new file mode 100644 index 000000000..05f6bd94a --- /dev/null +++ b/doc/descfcn.rst @@ -0,0 +1,86 @@ +.. _descfcn-module: + +******************** +Describing functions +******************** + +For nonlinear systems consisting of a feedback connection between a +linear system and a static nonlinearity, it is possible to obtain a +generalization of Nyquist's stability criterion based on the idea of +describing functions. The basic concept involves approximating the +response of a static nonlinearity to an input :math:`u = A e^{j \omega +t}` as an output :math:`y = N(A) (A e^{j \omega t})`, where :math:`N(A) +\in \mathbb{C}` represents the (amplitude-dependent) gain and phase +associated with the nonlinearity. + +Stability analysis of a linear system :math:`H(s)` with a feedback +nonlinearity :math:`F(x)` is done by looking for amplitudes :math:`A` +and frequencies :math:`\omega` such that + +.. math:: + + H(j\omega) N(A) = -1 + +If such an intersection exists, it indicates that there may be a limit +cycle of amplitude :math:`A` with frequency :math:`\omega`. + +Describing function analysis is a simple method, but it is approximate +because it assumes that higher harmonics can be neglected. + +Module usage +============ + +The function :func:`~control.describing_function` can be used to +compute the describing function of a nonlinear function:: + + N = ct.describing_function(F, A) + +Stability analysis using describing functions is done by looking for +amplitudes :math:`a` and frequencies :math`\omega` such that + +.. math:: + + H(j\omega) = \frac{-1}{N(A)} + +These points can be determined by generating a Nyquist plot in which the +transfer function :math:`H(j\omega)` intersections the negative +reciprocal of the describing function :math:`N(A)`. The +:func:`~control.describing_function_plot` function generates this plot +and returns the amplitude and frequency of any points of intersection:: + + ct.describing_function_plot(H, F, amp_range[, omega_range]) + + +Pre-defined nonlinearities +========================== + +To facilitate the use of common describing functions, the following +nonlinearity constructors are predefined: + +.. code:: python + + friction_backlash_nonlinearity(b) # backlash nonlinearity with width b + relay_hysteresis_nonlinearity(b, c) # relay output of amplitude b with + # hysteresis of half-width c + saturation_nonlinearity(ub[, lb]) # saturation nonlinearity with upper + # bound and (optional) lower bound + +Calling these functions will create an object `F` that can be used for +describing function analysis. For example, to create a saturation +nonlinearity:: + + F = ct.saturation_nonlinearity(1) + +These functions use the +:class:`~control.DescribingFunctionNonlinearity`, which allows an +analytical description of the describing function. + +Module classes and functions +============================ +.. autosummary:: + :toctree: generated/ + + ~control.DescribingFunctionNonlinearity + ~control.friction_backlash_nonlinearity + ~control.relay_hysteresis_nonlinearity + ~control.saturation_nonlinearity diff --git a/doc/describing_functions.ipynb b/doc/describing_functions.ipynb new file mode 120000 index 000000000..14bcb69a4 --- /dev/null +++ b/doc/describing_functions.ipynb @@ -0,0 +1 @@ +../examples/describing_functions.ipynb \ No newline at end of file diff --git a/doc/examples.rst b/doc/examples.rst index b1ffdfce5..91476bc9d 100644 --- a/doc/examples.rst +++ b/doc/examples.rst @@ -42,5 +42,7 @@ using running examples in FBS2e. :maxdepth: 1 cruise + describing_functions + mpc_aircraft steering pvtol-lqr-nested diff --git a/doc/flatsys.rst b/doc/flatsys.rst index f085347a6..b6d2fe962 100644 --- a/doc/flatsys.rst +++ b/doc/flatsys.rst @@ -132,37 +132,42 @@ and their derivatives up to order :math:`q_i`: The number of flat outputs must match the number of system inputs. For a linear system, a flat system representation can be generated using the -:class:`~control.flatsys.LinearFlatSystem` class: +:class:`~control.flatsys.LinearFlatSystem` class:: - flatsys = control.flatsys.LinearFlatSystem(linsys) + sys = control.flatsys.LinearFlatSystem(linsys) -For more general systems, the `FlatSystem` object must be created manually +For more general systems, the `FlatSystem` object must be created manually:: - flatsys = control.flatsys.FlatSystem(nstate, ninputs, forward, reverse) + sys = control.flatsys.FlatSystem(nstate, ninputs, forward, reverse) -In addition to the flat system descriptionn, a set of basis functions +In addition to the flat system description, a set of basis functions :math:`\phi_i(t)` must be chosen. The `FlatBasis` class is used to represent the basis functions. A polynomial basis function of the form 1, :math:`t`, :math:`t^2`, ... can be computed using the `PolyBasis` class, which is -initialized by passing the desired order of the polynomial basis set: +initialized by passing the desired order of the polynomial basis set:: polybasis = control.flatsys.PolyBasis(N) Once the system and basis function have been defined, the :func:`~control.flatsys.point_to_point` function can be used to compute a -trajectory between initial and final states and inputs: +trajectory between initial and final states and inputs:: - traj = control.flatsys.point_to_point(x0, u0, xf, uf, Tf, basis=polybasis) + traj = control.flatsys.point_to_point( + sys, Tf, x0, u0, xf, uf, basis=polybasis) The returned object has class :class:`~control.flatsys.SystemTrajectory` and can be used to compute the state and input trajectory between the initial and -final condition: +final condition:: xd, ud = traj.eval(T) where `T` is a list of times on which the trajectory should be evaluated (e.g., `T = numpy.linspace(0, Tf, M)`. +The :func:`~control.flatsys.point_to_point` function also allows the +specification of a cost function and/or constraints, in the same +format as :func:`~control.optimal.solve_ocp`. + Example ======= @@ -241,7 +246,7 @@ the endpoints. poly = fs.PolyFamily(6) # Find a trajectory between the initial condition and the final condition - traj = fs.point_to_point(vehicle_flat, x0, u0, xf, uf, Tf, basis=poly) + traj = fs.point_to_point(vehicle_flat, Tf, x0, u0, xf, uf, basis=poly) # Create the trajectory t = np.linspace(0, Tf, 100) @@ -256,6 +261,7 @@ Flat systems classes :toctree: generated/ BasisFamily + BezierFamily FlatSystem LinearFlatSystem PolyFamily diff --git a/doc/index.rst b/doc/index.rst index b6c44d387..98b184286 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -8,6 +8,7 @@ implements basic operations for analysis and design of feedback control systems. .. rubric:: Features - Linear input/output systems in state-space and frequency domain +- Nonlinear input/output system modeling, simulation, and analysis - Block diagram algebra: serial, parallel, and feedback interconnections - Time response: initial, step, impulse - Frequency response: Bode and Nyquist plots @@ -28,6 +29,8 @@ implements basic operations for analysis and design of feedback control systems. matlab flatsys iosys + descfcn + optimal examples * :ref:`genindex` @@ -54,6 +57,10 @@ Your contributions are welcome! Simply fork the `GitHub repository `_. + +Module usage +============ + +The optimal control module provides a means of computing optimal +trajectories for nonlinear systems and implementing optimization-based +controllers, including model predictive control. It follows the basic +problem setup described above, but carries out all computations in *discrete +time* (so that integrals become sums) and over a *finite horizon*. + +To describe an optimal control problem we need an input/output system, a +time horizon, a cost function, and (optionally) a set of constraints on the +state and/or input, either along the trajectory and at the terminal time. +The optimal control module operates by converting the optimal control +problem into a standard optimization problem that can be solved by +:func:`scipy.optimize.minimize`. The optimal control problem can be solved +by using the :func:`~control.obc.solve_ocp` function:: + + res = obc.solve_ocp(sys, horizon, X0, cost, constraints) + +The `sys` parameter should be an :class:`~control.InputOutputSystem` and the +`horizon` parameter should represent a time vector that gives the list of +times at which the cost and constraints should be evaluated. + +The `cost` function has call signature `cost(t, x, u)` and should return the +(incremental) cost at the given time, state, and input. It will be +evaluated at each point in the `horizon` vector. The `terminal_cost` +parameter can be used to specify a cost function for the final point in the +trajectory. + +The `constraints` parameter is a list of constraints similar to that used by +the :func:`scipy.optimize.minimize` function. Each constraint is a tuple of +one of the following forms:: + + (LinearConstraint, A, lb, ub) + (NonlinearConstraint, f, lb, ub) + +For a linear constraint, the 2D array `A` is multiplied by a vector +consisting of the current state `x` and current input `u` stacked +vertically, then compared with the upper and lower bound. This constrain is +satisfied if + +.. code:: python + + lb <= A @ np.hstack([x, u]) <= ub + +A nonlinear constraint is satisfied if + +.. code:: python + + lb <= f(x, u) <= ub + +By default, `constraints` are taken to be trajectory constraints holding at +all points on the trajectory. The `terminal_constraint` parameter can be +used to specify a constraint that only holds at the final point of the +trajectory. + +The return value for :func:`~control.optimal.solve_ocp` is a bundle object +that has the following elements: + + * `res.success`: `True` if the optimization was successfully solved + * `res.inputs`: optimal input + * `res.states`: state trajectory (if `return_x` was `True`) + * `res.time`: copy of the time horizon vector + +In addition, the results from :func:`scipy.optimize.minimize` are also +available. + +To simplify the specification of cost functions and constraints, the +:mod:`~control.ios` module defines a number of utility functions: + +.. autosummary:: + + ~control.optimal.quadratic_cost + ~control.optimal.input_poly_constraint + ~control.optimal.input_range_constraint + ~control.optimal.output_poly_constraint + ~control.optimal.output_range_constraint + ~control.optimal.state_poly_constraint + ~control.optimal.state_range_constraint + +Example +======= + +Consider the vehicle steering example described in FBS2e. The dynamics of +the system can be defined as a nonlinear input/output system using the +following code:: + + import numpy as np + import control as ct + import control.optimal as opt + import matplotlib.pyplot as plt + + def vehicle_update(t, x, u, params): + # Get the parameters for the model + l = params.get('wheelbase', 3.) # vehicle wheelbase + phimax = params.get('maxsteer', 0.5) # max steering angle (rad) + + # Saturate the steering input + phi = np.clip(u[1], -phimax, phimax) + + # Return the derivative of the state + return np.array([ + np.cos(x[2]) * u[0], # xdot = cos(theta) v + np.sin(x[2]) * u[0], # ydot = sin(theta) v + (u[0] / l) * np.tan(phi) # thdot = v/l tan(phi) + ]) + + def vehicle_output(t, x, u, params): + return x # return x, y, theta (full state) + + # Define the vehicle steering dynamics as an input/output system + vehicle = ct.NonlinearIOSystem( + vehicle_update, vehicle_output, states=3, name='vehicle', + inputs=('v', 'phi'), outputs=('x', 'y', 'theta')) + +We consider an optimal control problem that consists of "changing lanes" by +moving from the point x = 0 m, y = -2 m, :math:`\theta` = 0 to the point x = +100 m, y = 2 m, :math:`\theta` = 0) over a period of 10 seconds and with a +with a starting and ending velocity of 10 m/s:: + + x0 = [0., -2., 0.]; u0 = [10., 0.] + xf = [100., 2., 0.]; uf = [10., 0.] + Tf = 10 + +To set up the optimal control problem we design a cost function that +penalizes the state and input using quadratic cost functions:: + + Q = np.diag([0.1, 10, .1]) # keep lateral error low + R = np.eye(2) * 0.1 + cost = opt.quadratic_cost(vehicle, Q, R, x0=xf, u0=uf) + +We also constraint the maximum turning rate to 0.1 radians (about 6 degees) +and constrain the velocity to be in the range of 9 m/s to 11 m/s:: + + constraints = [ opt.input_range_constraint(vehicle, [8, -0.1], [12, 0.1]) ] + +Finally, we solve for the optimal inputs:: + + horizon = np.linspace(0, Tf, 20, endpoint=True) + bend_left = [10, 0.01] # slight left veer + + result = opt.solve_ocp( + vehicle, horizon, x0, cost, constraints, initial_guess=bend_left, + options={'eps': 0.01}) # set step size for gradient calculation + + # Extract the results + u = result.inputs + t, y = ct.input_output_response(vehicle, horizon, u, x0) + +Plotting the results:: + + # Plot the results + plt.subplot(3, 1, 1) + plt.plot(y[0], y[1]) + plt.plot(x0[0], x0[1], 'ro', xf[0], xf[1], 'ro') + plt.xlabel("x [m]") + plt.ylabel("y [m]") + + plt.subplot(3, 1, 2) + plt.plot(t, u[0]) + plt.axis([0, 10, 8.5, 11.5]) + plt.plot([0, 10], [9, 9], 'k--', [0, 10], [11, 11], 'k--') + plt.xlabel("t [sec]") + plt.ylabel("u1 [m/s]") + + plt.subplot(3, 1, 3) + plt.plot(t, u[1]) + plt.axis([0, 10, -0.15, 0.15]) + plt.plot([0, 10], [-0.1, -0.1], 'k--', [0, 10], [0.1, 0.1], 'k--') + plt.xlabel("t [sec]") + plt.ylabel("u2 [rad/s]") + + plt.suptitle("Lane change manuever") + plt.tight_layout() + plt.show() + +yields + +.. image:: steering-optimal.png + + +Module classes and functions +============================ +.. autosummary:: + :toctree: generated/ + + ~control.optimal.OptimalControlProblem + ~control.optimal.solve_ocp + ~control.optimal.create_mpc_iosystem + ~control.optimal.input_poly_constraint + ~control.optimal.input_range_constraint + ~control.optimal.output_poly_constraint + ~control.optimal.output_range_constraint + ~control.optimal.state_poly_constraint + ~control.optimal.state_range_constraint diff --git a/doc/steering-optimal.png b/doc/steering-optimal.png new file mode 100644 index 000000000..6ff50c0f4 Binary files /dev/null and b/doc/steering-optimal.png differ diff --git a/examples/bode-and-nyquist-plots.ipynb b/examples/bode-and-nyquist-plots.ipynb index 8aa0df822..4568f8cd0 100644 --- a/examples/bode-and-nyquist-plots.ipynb +++ b/examples/bode-and-nyquist-plots.ipynb @@ -6,7 +6,6 @@ "metadata": {}, "outputs": [], "source": [ - "import seaborn as sns\n", "import scipy as sp\n", "import matplotlib.pyplot as plt\n", "import control as ct" @@ -10012,7 +10011,7 @@ ], "source": [ "fig = plt.figure()\n", - "mag, phase, omega = ct.nyquist_plot([pt1_w001hzis, pt2_w001hz])" + "ct.nyquist_plot([pt1_w001hzis, pt2_w001hz])" ] }, { @@ -10025,9 +10024,9 @@ ], "metadata": { "kernelspec": { - "display_name": "Python (pctest)", + "display_name": "Python 3", "language": "python", - "name": "pctest" + "name": "python3" }, "language_info": { "codemirror_mode": { diff --git a/examples/cruise-control.py b/examples/cruise-control.py index 8e59c79c7..505b4071c 100644 --- a/examples/cruise-control.py +++ b/examples/cruise-control.py @@ -141,8 +141,8 @@ def motor_torque(omega, params={}): cruise_tf = ct.InterconnectedSystem( (control_tf, vehicle), name='cruise', connections = ( - ('control.u', '-vehicle.v'), - ('vehicle.u', 'control.y')), + ['control.u', '-vehicle.v'], + ['vehicle.u', 'control.y']), inplist = ('control.u', 'vehicle.gear', 'vehicle.theta'), inputs = ('vref', 'gear', 'theta'), outlist = ('vehicle.v', 'vehicle.u'), @@ -279,8 +279,8 @@ def pi_output(t, x, u, params={}): cruise_pi = ct.InterconnectedSystem( (vehicle, control_pi), name='cruise', connections=( - ('vehicle.u', 'control.u'), - ('control.v', 'vehicle.v')), + ['vehicle.u', 'control.u'], + ['control.v', 'vehicle.v']), inplist=('control.vref', 'vehicle.gear', 'vehicle.theta'), outlist=('control.u', 'vehicle.v'), outputs=['u', 'v']) @@ -404,9 +404,9 @@ def sf_output(t, z, u, params={}): cruise_sf = ct.InterconnectedSystem( (vehicle, control_sf), name='cruise', connections=( - ('vehicle.u', 'control.u'), - ('control.x', 'vehicle.v'), - ('control.y', 'vehicle.v')), + ['vehicle.u', 'control.u'], + ['control.x', 'vehicle.v'], + ['control.y', 'vehicle.v']), inplist=('control.r', 'vehicle.gear', 'vehicle.theta'), outlist=('control.u', 'vehicle.v'), outputs=['u', 'v']) diff --git a/examples/describing_functions.ipynb b/examples/describing_functions.ipynb new file mode 100644 index 000000000..766feb2e2 --- /dev/null +++ b/examples/describing_functions.ipynb @@ -0,0 +1,404 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Describing function analysis\n", + "Richard M. Murray, 27 Jan 2021\n", + "\n", + "This Jupyter notebook shows how to use the `descfcn` module of the Python Control Toolbox to perform describing function analysis of a nonlinear system. A brief introduction to describing functions can be found in [Feedback Systems](https://fbsbook.org), Section 10.5 (Generalized Notions of Gain and Phase)." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import control as ct\n", + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "import math" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Built-in describing functions\n", + "The Python Control Toobox has a number of built-in functions that provide describing functions for some standard nonlinearities. " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Saturation nonlinearity\n", + "\n", + "A saturation nonlinearity can be obtained using the `ct.saturation_nonlinearity` function. This function takes the saturation level as an argument." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "saturation=ct.saturation_nonlinearity(0.75)\n", + "x = np.linspace(-2, 2, 50)\n", + "plt.plot(x, saturation(x))\n", + "plt.xlabel(\"Input, x\")\n", + "plt.ylabel(\"Output, y = sat(x)\")\n", + "plt.title(\"Input/output map for a saturation nonlinearity\");" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "amp_range = np.linspace(0, 2, 50)\n", + "plt.plot(amp_range, ct.describing_function(saturation, amp_range))\n", + "plt.xlabel(\"Amplitude A\")\n", + "plt.ylabel(\"Describing function, N(A)\")\n", + "plt.title(\"Describing function for a saturation nonlinearity\");" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Backlash nonlinearity\n", + "A friction-dominated backlash nonlinearity can be obtained using the `ct.friction_backlash_nonlinearity` function. This function takes as is argument the size of the backlash region." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "backlash = ct.friction_backlash_nonlinearity(0.5)\n", + "theta = np.linspace(0, 2*np.pi, 50)\n", + "x = np.sin(theta)\n", + "plt.plot(x, [backlash(z) for z in x])\n", + "plt.xlabel(\"Input, x\")\n", + "plt.ylabel(\"Output, y = backlash(x)\")\n", + "plt.title(\"Input/output map for a friction-dominated backlash nonlinearity\");" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "amp_range = np.linspace(0, 2, 50)\n", + "N_a = ct.describing_function(backlash, amp_range)\n", + "\n", + "plt.figure()\n", + "plt.plot(amp_range, abs(N_a))\n", + "plt.xlabel(\"Amplitude A\")\n", + "plt.ylabel(\"Amplitude of describing function, N(A)\")\n", + "plt.title(\"Describing function for a backlash nonlinearity\")\n", + "\n", + "plt.figure()\n", + "plt.plot(amp_range, np.angle(N_a))\n", + "plt.xlabel(\"Amplitude A\")\n", + "plt.ylabel(\"Phase of describing function, N(A)\")\n", + "plt.title(\"Describing function for a backlash nonlinearity\");" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### User-defined, static nonlinearities\n", + "\n", + "In addition to pre-defined nonlinearies, it is possible to computing describing functions for static nonlinearities. The describing function for any suitable nonlinear function can be computed numerically using the `describing_function` function." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "# Define a saturation nonlinearity as a simple function\n", + "def my_saturation(x):\n", + " if abs(x) >= 1:\n", + " return math.copysign(1, x)\n", + " else:\n", + " return x\n", + "\n", + "amp_range = np.linspace(0, 2, 50)\n", + "plt.plot(amp_range, ct.describing_function(my_saturation, amp_range).real)\n", + "plt.xlabel(\"Amplitude A\")\n", + "plt.ylabel(\"Describing function, N(A)\")\n", + "plt.title(\"Describing function for a saturation nonlinearity\");" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Stability analysis using describing functions\n", + "Describing functions can be used to assess stability of closed loop systems consisting of a linear system and a static nonlinear using a Nyquist plot." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Limit cycle position for a third order system with saturation nonlinearity\n", + "\n", + "Consider a nonlinear feedback system consisting of a third-order linear system with transfer function $H(s)$ and a saturation nonlinearity having describing function $N(a)$. Stability can be assessed by looking for points at which \n", + "\n", + "$$\n", + "H(j\\omega) N(a) = -1", + "$$\n", + "\n", + "The `describing_function_plot` function plots $H(j\\omega)$ and $-1/N(a)$ and prints out the the amplitudes and frequencies corresponding to intersections of these curves. " + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[(3.343977839598768, 1.4142156916757294)]" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "# Linear dynamics\n", + "H_simple = ct.tf([8], [1, 2, 2, 1])\n", + "omega = np.logspace(-3, 3, 500)\n", + "\n", + "# Nonlinearity\n", + "F_saturation = ct.saturation_nonlinearity(1)\n", + "amp = np.linspace(00, 5, 50)\n", + "\n", + "# Describing function plot (return value = amp, freq)\n", + "ct.describing_function_plot(H_simple, F_saturation, amp, omega)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The intersection occurs at amplitude 3.3 and frequency 1.4 rad/sec (= 0.2 Hz) and thus we predict a limit cycle with amplitude 3.3 and period of approximately 5 seconds." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "# Create an I/O system simulation to see what happens\n", + "io_saturation = ct.NonlinearIOSystem(\n", + " None,\n", + " lambda t, x, u, params: F_saturation(u),\n", + " inputs=1, outputs=1\n", + ")\n", + "\n", + "sys = ct.feedback(ct.tf2io(H_simple), io_saturation)\n", + "T = np.linspace(0, 30, 200)\n", + "t, y = ct.input_output_response(sys, T, 0.1, 0)\n", + "plt.plot(t, y);" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Limit cycle prediction with for a time-delay system with backlash\n", + "\n", + "This example demonstrates a more complicated interaction between a (non-static) nonlinearity and a higher order transfer function, resulting in multiple intersection points." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[(0.6260158833531679, 0.31026194979692245),\n", + " (0.8741930326842812, 1.215641094477062)]" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "# Linear dynamics\n", + "H_simple = ct.tf([1], [1, 2, 2, 1])\n", + "H_multiple = H_simple * ct.tf(*ct.pade(5, 4)) * 4\n", + "omega = np.logspace(-3, 3, 500)\n", + "\n", + "# Nonlinearity\n", + "F_backlash = ct.friction_backlash_nonlinearity(1)\n", + "amp = np.linspace(0.6, 5, 50)\n", + "\n", + "# Describing function plot\n", + "ct.describing_function_plot(H_multiple, F_backlash, amp, omega, mirror_style=False)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.7.9" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/examples/kincar-flatsys.py b/examples/kincar-flatsys.py index 17a1b71b9..ca2a946ed 100644 --- a/examples/kincar-flatsys.py +++ b/examples/kincar-flatsys.py @@ -10,7 +10,11 @@ import matplotlib.pyplot as plt import control as ct import control.flatsys as fs +import control.optimal as opt +# +# System model and utility functions +# # Function to take states, inputs and return the flat flag def vehicle_flat_forward(x, u, params={}): @@ -59,7 +63,6 @@ def vehicle_flat_reverse(zflag, params={}): return x, u - # Function to compute the RHS of the system dynamics def vehicle_update(t, x, u, params): b = params.get('wheelbase', 3.) # get parameter values @@ -70,6 +73,38 @@ def vehicle_update(t, x, u, params): ]) return dx +# Plot the trajectory in xy coordinates +def plot_results(t, x, ud): + plt.subplot(4, 1, 2) + plt.plot(x[0], x[1]) + plt.xlabel('x [m]') + plt.ylabel('y [m]') + plt.axis([x0[0], xf[0], x0[1]-1, xf[1]+1]) + + # Time traces of the state and input + plt.subplot(2, 4, 5) + plt.plot(t, x[1]) + plt.ylabel('y [m]') + + plt.subplot(2, 4, 6) + plt.plot(t, x[2]) + plt.ylabel('theta [rad]') + + plt.subplot(2, 4, 7) + plt.plot(t, ud[0]) + plt.xlabel('Time t [sec]') + plt.ylabel('v [m/s]') + plt.axis([0, Tf, u0[0] - 1, uf[0] + 1]) + + plt.subplot(2, 4, 8) + plt.plot(t, ud[1]) + plt.xlabel('Ttime t [sec]') + plt.ylabel('$\delta$ [rad]') + plt.tight_layout() + +# +# Approach 1: point to point solution, no cost or constraints +# # Create differentially flat input/output system vehicle_flat = fs.FlatSystem( @@ -86,7 +121,7 @@ def vehicle_update(t, x, u, params): poly = fs.PolyFamily(6) # Find a trajectory between the initial condition and the final condition -traj = fs.point_to_point(vehicle_flat, x0, u0, xf, uf, Tf, basis=poly) +traj = fs.point_to_point(vehicle_flat, Tf, x0, u0, xf, uf, basis=poly) # Create the desired trajectory between the initial and final condition T = np.linspace(0, Tf, 500) @@ -97,36 +132,51 @@ def vehicle_update(t, x, u, params): vehicle_flat, T, ud, x0, return_x=True) # Plot the open loop system dynamics -plt.figure() +plt.figure(1) plt.suptitle("Open loop trajectory for kinematic car lane change") +plot_results(t, x, ud) -# Plot the trajectory in xy coordinates -plt.subplot(4, 1, 2) -plt.plot(x[0], x[1]) -plt.xlabel('x [m]') -plt.ylabel('y [m]') -plt.axis([x0[0], xf[0], x0[1]-1, xf[1]+1]) - -# Time traces of the state and input -plt.subplot(2, 4, 5) -plt.plot(t, x[1]) -plt.ylabel('y [m]') - -plt.subplot(2, 4, 6) -plt.plot(t, x[2]) -plt.ylabel('theta [rad]') - -plt.subplot(2, 4, 7) -plt.plot(t, ud[0]) -plt.xlabel('Time t [sec]') -plt.ylabel('v [m/s]') -plt.axis([0, Tf, u0[0] - 1, uf[0] + 1]) - -plt.subplot(2, 4, 8) -plt.plot(t, ud[1]) -plt.xlabel('Ttime t [sec]') -plt.ylabel('$\delta$ [rad]') -plt.tight_layout() +# +# Approach #2: add cost function to make lane change quicker +# + +# Define timepoints for evaluation plus basis function to use +timepts = np.linspace(0, Tf, 10) +basis = fs.PolyFamily(8) + +# Define the cost function (penalize lateral error and steering) +traj_cost = opt.quadratic_cost( + vehicle_flat, np.diag([0, 0.1, 0]), np.diag([0.1, 1]), x0=xf, u0=uf) + +# Solve for an optimal solution +traj = fs.point_to_point( + vehicle_flat, timepts, x0, u0, xf, uf, cost=traj_cost, basis=basis, +) +xd, ud = traj.eval(T) + +plt.figure(2) +plt.suptitle("Lane change with lateral error + steering penalties") +plot_results(T, xd, ud) + +# +# Approach #3: optimal cost with trajectory constraints +# +# Resolve the problem with constraints on the inputs +# + +constraints = [ + opt.input_range_constraint(vehicle_flat, [8, -0.1], [12, 0.1]) ] + +# Solve for an optimal solution +traj = fs.point_to_point( + vehicle_flat, timepts, x0, u0, xf, uf, cost=traj_cost, + constraints=constraints, basis=basis, +) +xd, ud = traj.eval(T) + +plt.figure(3) +plt.suptitle("Lane change with penalty + steering constraints") +plot_results(T, xd, ud) # Show the results unless we are running in batch mode if 'PYCONTROL_TEST_EXAMPLES' not in os.environ: diff --git a/examples/mpc_aircraft.ipynb b/examples/mpc_aircraft.ipynb new file mode 100644 index 000000000..5da812eb0 --- /dev/null +++ b/examples/mpc_aircraft.ipynb @@ -0,0 +1,201 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Model Predictive Control: Aircraft Model\n", + "\n", + "RMM, 13 Feb 2021\n", + "\n", + "This example replicates the [MPT3 regulation problem example](https://www.mpt3.org/UI/RegulationProblem)." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "import control as ct\n", + "import numpy as np\n", + "import control.optimal as opt\n", + "import matplotlib.pyplot as plt" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "# model of an aircraft discretized with 0.2s sampling time\n", + "# Source: https://www.mpt3.org/UI/RegulationProblem\n", + "A = [[0.99, 0.01, 0.18, -0.09, 0],\n", + " [ 0, 0.94, 0, 0.29, 0],\n", + " [ 0, 0.14, 0.81, -0.9, 0],\n", + " [ 0, -0.2, 0, 0.95, 0],\n", + " [ 0, 0.09, 0, 0, 0.9]]\n", + "B = [[ 0.01, -0.02],\n", + " [-0.14, 0],\n", + " [ 0.05, -0.2],\n", + " [ 0.02, 0],\n", + " [-0.01, 0]]\n", + "C = [[0, 1, 0, 0, -1],\n", + " [0, 0, 1, 0, 0],\n", + " [0, 0, 0, 1, 0],\n", + " [1, 0, 0, 0, 0]]\n", + "model = ct.ss2io(ct.ss(A, B, C, 0, 0.2))\n", + "\n", + "# For the simulation we need the full state output\n", + "sys = ct.ss2io(ct.ss(A, B, np.eye(5), 0, 0.2))\n", + "\n", + "# compute the steady state values for a particular value of the input\n", + "ud = np.array([0.8, -0.3])\n", + "xd = np.linalg.inv(np.eye(5) - A) @ B @ ud\n", + "yd = C @ xd" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "# computed values will be used as references for the desired\n", + "# steady state which can be added using \"reference\" filter\n", + "# model.u.with('reference');\n", + "# model.u.reference = us;\n", + "# model.y.with('reference');\n", + "# model.y.reference = ys;\n", + "\n", + "# provide constraints on the system signals\n", + "constraints = [opt.input_range_constraint(sys, [-5, -6], [5, 6])]\n", + "\n", + "# provide penalties on the system signals\n", + "Q = model.C.transpose() @ np.diag([10, 10, 10, 10]) @ model.C\n", + "R = np.diag([3, 2])\n", + "cost = opt.quadratic_cost(model, Q, R, x0=xd, u0=ud)\n", + "\n", + "# online MPC controller object is constructed with a horizon 6\n", + "ctrl = opt.create_mpc_iosystem(model, np.arange(0, 6) * 0.2, cost, constraints)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "System: sys[7]\n", + "Inputs (2): u[0], u[1], \n", + "Outputs (5): y[0], y[1], y[2], y[3], y[4], \n", + "States (17): sys[1]_x[0], sys[1]_x[1], sys[1]_x[2], sys[1]_x[3], sys[1]_x[4], sys[6]_x[0], sys[6]_x[1], sys[6]_x[2], sys[6]_x[3], sys[6]_x[4], sys[6]_x[5], sys[6]_x[6], sys[6]_x[7], sys[6]_x[8], sys[6]_x[9], sys[6]_x[10], sys[6]_x[11], \n" + ] + } + ], + "source": [ + "# Define an I/O system implementing model predictive control\n", + "loop = ct.feedback(sys, ctrl, 1)\n", + "print(loop)" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Computation time = 8.28132 seconds\n" + ] + } + ], + "source": [ + "import time\n", + "\n", + "# loop = ClosedLoop(ctrl, model);\n", + "# x0 = [0, 0, 0, 0, 0]\n", + "Nsim = 60\n", + "\n", + "start = time.time()\n", + "tout, xout = ct.input_output_response(loop, np.arange(0, Nsim) * 0.2, 0, 0)\n", + "end = time.time()\n", + "print(\"Computation time = %g seconds\" % (end-start))" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "array([-0.15441833, 0.00362039, 0.07760278, 0.00675162, 0.00698118])" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAagAAAEYCAYAAAAJeGK1AAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjMuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8vihELAAAACXBIWXMAAAsTAAALEwEAmpwYAAA9UUlEQVR4nO3deXhU1fnA8e87a/YEkrAl7LK4oyKKirUVKypqXUCrUqkLFreiaAXF2v7EBRUES8GtSutStahVUFsF676ComyCIEsSIAshezLr+f1xh5CQsIRkuJPk/TzPfe5y7r3zThjmnXPuueeKMQallFIq1jjsDkAppZRqjCYopZRSMUkTlFJKqZikCUoppVRM0gSllFIqJmmCUkopFZM0QSmllIpJmqCUOkhExIjIIbF6PqVijSYopZRSMUkTlFJNJCKHisgHIlIiIitF5LzI9g9E5Jo6+40VkU8iyx9FNn8nIhUicomInCYiuSJyp4gUichGEbm8zvFNPV+GiCyMxFUsIh+LiP4fV62Wy+4AlGpNRMQNLACeAX4JnAK8ISKD93acMeZUETHA0caYdZFznQZ0ATKALOBE4G0RWWKMWXMA53sAyAUyI7udCOhYZqrV0l9XSjXNiUAS8KAxxm+MeR9YCPy6Gee82xjjM8Z8CLwFjD7A8wSArkBPY0zAGPOx0cE2VSumCUqppukG5BhjwnW2bcKqAR2IHcaYyt3O1e0Az/UwsA54V0R+EpFJB3gepWKCJiilmmYL0H23azs9gDygEkios73Lfpyvg4gk7nauLZHlJp3PGFNujJlojOkDnAvcKiKn70cMSsUkTVBKNc2XWInjDyLijlxHOhd4CVgGXCgiCZHu31fvdmw+0KeRc/5ZRDwiMgwYCfwrsr1J5xORkSJyiIgIUAaEIpNSrZImKKWawBjjB84DzgKKgDnAb4wxPwCPAn6sxPF34IXdDv8T8PdIL7ud15m2ATuwak0vAL+LnIsDOF8/YBFQAXwOzDHGfND8d62UPUSvoSplj0jt63ljTLbNoSgVk7QGpZRSKiZpglJKKRWTtIlPKaVUTNIalFJKqZgUU0MdZWRkmF69etkdhlJKqYNo6dKlRcaYzN23x1SC6tWrF0uWLLE7DKWUUgeRiGxqbLs28SmllIpJmqCUUkrFJE1QSimlYpImKKWUUjFJE5RSSqmYpAlKKaVUTNIEpZRSKiZpglJKKRWTYupG3ZZy2mmnNdg2evRorr/+eqqqqjj77LMblI8dO5axY8dSVFTExRdf3KB8/PjxXHLJJeTk5DBmzJgG5RMnTuTcc89lzZo1XHfddQ3Kp0yZwvDhw1m2bBkTJkxoUH7//fdz0kkn8dlnn3HnnXc2KJ85cyaDBg1i0aJFTJ06tUH5E088wYABA1iwYAHTp09vUP7cc8/RvXt3Xn75ZebOndugfP78+WRkZDBv3jzmzZvXoPztt98mISGBOXPm8MorrzQo/+CDDwB45JFHWLhwYb2y+Ph43nnnHQDuvfdeFi9eXK88PT2dV199FYDJkyfz+eef1yvPzs7m+eefB2DChAksW7asXnn//v158sknARg3bhxr166tVz5o0CBmzpwJwBVXXEFubm698qFDh/LAAw8AcNFFF7F9+/Z65aeffjp33303AGeddRbV1dX1ykeOHMltt90GtK/PnsFgjOGxvz5G3/59WbhgIbNnzbbKjMFgjfM5/fHpdMnqwpuvvsmLz74Ihtoyg+Ghpx8itWMqb770JgteXrDz5LX7PPj3B/HEeXj9H6/z4cIPa4/bOb//hfsxGF5/6nW+/t/X9co9Xg+TnpxEmDCvzXmNFV+sqHf+pLQkbnj0BgyG+TPns/679bXxA6R1TmPsfWOt8ofmk7s2d9f5DWT2zGTUXaPAwCv3vULh5sJ6f59u/box8taRGGN4+Z6XKS0orfe37X5Ed4aPH47B8PLkl6kqq6qND6DX4F6ccuUpAPzztn8S9AXrHd93aF9O+PUJGAwv3vxi/X84A/1/3p9jfnUMgZoAr97xaoN/28NGHMbhIw6nurSahfcsrP277XTUeUfR/+f9Kcsv490H321w/OiZo5lx2owG21tK1BOUiIwAZgFO4GljzIPRfs3WwNT9T2qgrDpAUYWP0io/wbDBISAIIjYHqmwXNmGqAlWU1JTgD/kJm7A1Yc1XFK7As9HDT5t/orC6kLAJY4yx5hheWfMKXyV/xebVm9lQuqF2+84kct8X99GhpANbV25l5faVtduNMYQJc9V/riJudRzF3xeTl59XW7bT6IWj8Xb1UvZtGUUFRQ3i/+1/f4sn3UPp0lK2F21vUH7D4htwJbvYsXwHO4p3NCi/9YNbcXgdbF+7ndKS0gbld35iJdWijUWUlZbVK3N4HEz90kqqBbkFVJRV1Ct3GRczllpfsNu2baOqvKpeeYGngLnfWT/othZtpaaypl55yY4SXlj1AiLCppJN+Kp8uwoFqkureXPdmyCQV5GHv8YfKbL+Y4fKQizevBhBKKwubJCAcspy+HLblwhCqa+UsD9crzyvIo9lBcsAqAxUNvjb5Ffm80PxD4R8IaqCVbWvW/v+qgqIL4nHV+ajOljd4Pii6iLiyuOoqqzCF/I1KN9SsaXBtpYU1dHMRcQJrAXOAHKBr4FfG2NWNbb/4MGDTWsY6igcNpTXBCmp9lNSFaCkOkBJlZ+y6kDtemlkubwmQJU/RKU/SJUvMveHCIX3/+/udAgJbicJXieJXheJHheJXidJXhcp8W5S492kxXtIS3CTlmCtpyd6yUj20DHRg9fljOJfQ+20M5FUBauoDFTWLu9tXh2spjpYTVVw13J1sJqaYE3tvCZUs+8X3wOnOPE4PbgdbjxODx6Hx1p3unE7dk0uh6ve8s6p7rpTnDgdTlziqp2DA4c4cOBExAHGgVN2LTvEATiQyH6CA+vreddy7T5GEHEgCCayh0TKapcNOMT6PFvHya7ziUS+gK1jjdQ5h5HIDz7rC1pqXzdyxM59hF0x1lu2jqv7dbmn7849/c/e01ft7rWWvZ2kqeeONhE4+ZCMFjiPLDXGDN59e7RrUEOAdcaYnyJBvAScDzSaoJqrZPtW3ht/Dv7qhr8kcHvY1rUHeRnJfJm/msI4Q231xECHbr3o2K0XQb+fjd99Zm0OW//w4bAhqUtfPOk9qKmsYseqLzAhMEEgBCYE3h7H4skYQLC0jPKv38IRFCTkwBEQHAFh8NlXc/yxwyjN+5H3/jYNt/Xpj/znEC6+7jYGHD2Ytd8v5V+PPxz5FRupaRnDyVffQWpWP1Z/8ylfvfY04bAhFDYEw4ZgOEzaGTfgTs+mat2XlH31er237nQIR10xhezsbEpWfMiPH7yG2+nA7XLgdgpup4O//eNF+vXsxisvPt9umviMGIzbcOwJx3LTxJuoDFRyx5Q7KK0uxbitMuMy9Ozfk+NOPI6qYBXvLH6HoCOIcZnafTxJHsQjjf4C3aMQSFCQgJCWmEbXjK64jIsfv/sRggIBICAQhGOPGMphhx5DZWkVb770MgSsz144AAThlOEXcOgxp5Cfs4X5c6YTDoIJQDhoMGHhlFHXkn3ECeSuW8378yKfLauFirAxHH7edaT0OoLCdd/xw4KnGnz2ss4aj7dLH0p/XEr+R89Zfzuz62s1/cwbcad3afSzB5AxciKulEwqV39E+bdvNyjP/NVknAmpVCxfRMXyRQ3KO436Ew53HOXfvEXlDx83KO9ymdUoU/rla1Sv/6pembi8dB79ZwBKPv0nNZu+q1fujE8h8wKrBrbjw3n48n6oV+5KziDjXKv5tnjRk/gLfqpX7u6YRfqImwDY/p+/ECjOq1fu6dSHjsPHAVC04BGC5fVrmN6sgXT42VgACl+/n1B1/RpgXM+jSTv51wDkv3IPJli/FhPfdwipJ1wIwLYXJ7G7xIHDSD72HMKBGgr+9acG5UlHDifpyOGEqkop/PcDDcqTjzmbxENPJVhWSNHChpcOeox5iLX3ndVge0uJdoLKAnLqrOcCJ9TdQUTGAeMAevTo0awX89dU0X1dNY31/XCGgxy7yvpwXQtUuyGnI2xKFxYf7mB9Wi7VgQIMBveAcG3yQKzTiWsjQedmnIkh0js39urfWFM2JB8O1n//UG3pBh5nm+/veDO8JF3tw+F3IH5BfIL4hWDW15BSRYeehaQf4bC2+wSHz9rvdz/ry6BBR7EorYCpnyU3ePXZt5xK1x59ePXflTyR+z7BUJhA2BAIhQmEwvTvnITPIWwrraGo0k8wVL+pYORfPsGZkIpv1XKqNpfgdgoupwOXQ3A5hdnv/0inDil8l1PCjio/TofgFMERmZfXBIhzO/f4q7K5jDHUhGrwOX0Ek0OEXWFwGcIuQ35aIc+vmE9FoJINXTZTllCJcYcxLjBuw1cdlnLhvy+jOljF1qGbCTmC4DG1n/7FLGbxG5GkeVLD115tVrPuh424HfFUJfsI+wz4BVNhJQJvSgZdex2FuLwsf/dNjF8I+w3GJ4T90Knv8WQNOhNfjWHpU1MJ+Q0mJNaPH2NIO/p4Co4cTnX5Dra99l2D189Z34EP1/awviTeb5gE8z3VJGwpI7C9hO2bd5WLCA6Bz9ZvJ91ZgD+/lEpf0Go+Fqn9ceRyCqnxbkIJXuLcjjplVv3i5EPSyerbjY2Sw8ffxVnnZtc5fn1Kb7r27MuKz/NYtD6x9v/NzsakG84aSGaXbnz+3kbey01q0Gw9+cKjSO3YkcVv/Mjigoaf7Xt/fQzx8QkscC/no5KG5Y+OHYwgvBz+mi8q6ycYb1wc0357PADPVX/MUv+G2vgBUtI6cO9VQwB4cse7rAzXb7LK7JLO3VcPQRAe2/YmP66un2B69O7EHddYX2nTNr5MzobyeuWHHNaFCdda5X9enUHhNn+98iOOzmL8tSciApOXdaS0pP57O35wd64adyIAEz5Pw+errtdMd/LQnlxx7VAAxn+Q2uBvM3xYby4eM5Sa6iomfNqwfOTP+zLy4qGUFG9n0lcNyy86oz9njBxK/pY87vmmYfkT157QYFtLinYT3yjgTGPMNZH1McAQY8xNje0f1Sa+Dx8m9J/78A19CF9VKr516/CtX0fN8hWEKypIGDKE9GuvJfGUk2ur83tijCFoggRCAQJha/KFfLXNMnWbaSoDlVQEKqzJb83L/eVU+Cso9ZdS5iujzF9Gub+88ap+RLwrnhRPCqneVFI8KdbkTSHZk0ySO4kkd5K17LGW413xJLgTiHfFW8uuBOJccZFmDfAHw2yv9FFYbk3bK/1sr/CzvcJHcaWfoko/xZW+Ok2VwT3GFvmrgIRAAnjcITyuEG5XCJcriDgCIH5wBDDijyz7Mfgx4rMm/BiHr942HD5rf4cPxI/I/n1WjXFA2IMJezFhL0TmJuyp3V5/m3fXviEPxngxoV37YKymrJ1EwO1w4HIKTodVA3U6BLcjktSdUlvucjpwOwSPy2HVWp0OPC6ps+zAE5m7nYLH6cTtEjxOB15XpNzlwON01i7Xbm+wT/3lfX2OlYoVdjXx5QLd66xnA9G9qrYnJ92Ec9kLJGx6koTxn4HLA0CoopKSf/2L4nnzyLn2WryHHkr6NVeTcuaZiKvxP4+I4Barvb6lhE2Ycn85Zf4yynxllPpKKfWXWnNfKWX+stp5mb+MzeWbKdteRoW/gqpg1b5fIMLlcO26DhG5LuF2uK1f2zvb8eMFR7yDuAzwECY9bF2MD4RDBCNTIBwgEPITNEFCJkDINExgwci0Nw7cOPHiEi8uvDglDrfE4ZQ03BKH2xFXO/c64vE44/E64vE6rSnOEU+cK5KInYkkuBLwOD21icLlsGqBTqdY8zoJZee6y+HA6SAyl9rE4xQrwdTdd2etUSkVfdGuQbmwOkmcDuRhdZK4zBizsrH9o95JYu278OIoGP5nOGVCvSLj91O6YCHbn34a/4YNuHv2oNv995Nw3HHRi6eFhMKherW0cn95vZpc3ckf8hMIBxrMDaa291ftMganOK2L4OLAgQOHw7oIXnuB3enG4/DgcrjwOr14nV7iXHHWsstLnDOOOFcccc642tpcvCueOJe17nK0yTsdlFJNsKcaVFQTVOSFzwZmYnUzf8YYc9+e9j0ovfhevBQ2fAQ3fg2pWQ2KTThM+eLFFDz8CIHcXNKvG0fm9dcj7parLSmllNplTwkq6iNJGGPeNsb0N8b03VtyOmhGPADhILw7pdFicThIOeMMer/2Gqm/+hXb5z7OxsuvwL9x48GNUyml2rn2N9RRx95wyi2w8jWrJrUHzqREut1/H1kzZ+LftImfLryIkvnzo9ZLTSmlVH3tL0GBdf0prSe8fTuEAnvdNWXEmfR549/EH3kkW6fcTd6EWwhX7X+nBKWUUgemfSYodzyMeBAKf4Avn9j37l260OPZZ+h020TK33uPzb+9iuCOhkOyKKWUajntM0EBDDgL+v0SPngQSvP2ubs4HKRfcw1Zs2ZSs3o1my6/gsAWe3rMK6VUe9Am+/ju94jSwRrIK4R/HMXYPzzE2Kuu3ueI0qUDB/JIUiLjN2wgf/gZzO7QgS1ul45m3gqHOtpJRzPXzx60o8+eMVjDuRvGXn4JYy+/hKLCfC4ec23t9p3z8b8ZxSXnDicnbwtjfv/HXYP+Rfb54Pnp0G94g9dtKW0yQe03Vxyk94WitbDmHeDq/TpsncfD9I4duGlHCROLi5nTIS2qYSqlWhET3jXVlEHhGghUQ2UR1JREBjmMlOevhK//BiE/bF8HJRXWdiIDga7xwxvbrGvl25aDLxApjySSL7fAkwshFIS873cdtzPJ/Pc7KLkPqv2wsYQGw82+swS23gVVYchtZCzJRSth231QGoatjZS/9Gu4u7Dh9hYS9fugmsK20cxfHw/f/ROufBN6n7rfh/lz88i55hoCW7eSNWM6yaefHsUglVItJhQAX7k1+SvAVwH+cvBX1pkqIvMqCOycV1nbAtXWcqDamoLVu5b3MmTZ/hFwecHptUa8cXrA6bbmDre1zRFZd7p2bXe6weHabb5zu9NadrisY8S5az+Hq065c9e67L7s3FVedz2r+YMZ2HajblPYlqB8FfDkadaH9XefQFLmfh8a3LGDnOt+R82qVWT/5TGSf/7z6MWplLKEw1ZtpHoHVBVHlkvqz2tKoKbUqsX4ynbNfeVW8/7+EAe4E8GTAO7I5EmwOlq568xdcZHl+F3LrrjIcmTu8u7a5vTU2eaNrEeSksNJgxF12zhNUPuybQU89QurBnXZK+DY//4joYoKNo/9Lb61a+n+5BMknnhiFANVqg0Kh6BqO1TkQ0WB1RxWWQhVRdZy1fZd8+piKwntrabiToC4VGvypkBcSp15MnhTwZsEnqTIehJ4ds4Tre2eRCuJtLNkYQdNUPvj66fhrYlwxr1w8s1NOjS4Ywebf3Ml/rw8evztaRKOOSZKQSrVioTDUFkAZXlQvg3Kt+4232YlpKqiyLWV3ThckJABiRmQ0NFaTugI8R0gvmNkeed62q6k5PIe9LeqDpwmqP1hDLzyG1jzNlz1X8hu8Pfaq0BBAZvGjCFUvIOe//g7cYceGqVAlYoRgRoozYGSTVCy2ZpKc61bN8pyoWxr5MmKdYgDkjpDchdI6gJJnaz1pE7WlBiZJ6RbyUZrMG2eJqj9VV0CTwyzlq/9ABLTm3R4IC+PjVeMwfh89Hz+Obx9+rR4iEodNMZYzWrFP0HxBmu+Y4O1XLLJapKry+GGlG6QkmUNxpySBanZ1jylKyR3hcRM6zqLUhGaoJoidynMOxs6Hw5XLrDaopvAt2EDm8b8BnE66fnC83iys6MUqFItJFBjdXPe/iMU7Zz/aG3z1X0MuVgJp0Mva0rrCWk9rKlDT6smpMlHNZEmqKZavRBeGQOHDIdLX7S6ZDZBzZo1bPrNlTjTUun10ku4OnSIUqBKNUHQb933V/gDFKzeNd+xof41oJRsyDgE0vtZ9wp27AMdeltJSK/vqBamCepALHkGFt4Cgy6H8//a5Lbwqm++ZfPYscQdeSQ9nn0Gh8cTpUCVakRVsXVz584pf4WVkMKR5xyL00o8nQZC5qGQOQAy+kH6IU1uNVCqOex65HvrNvgqKM+HDx+0Luie/scmHZ5w7DF0feB+tky8ja1TptBt2jREL/iqaKjeAVuWwZZvYcs31nJpzq7y5K7Q+Qhr/MnOh0PmQCsZaW1IxTBNUPty2iSo2AYfT7d6HJ0wrkmHp55zDoGcHApnzsLToyeZN94QpUBVuxEOWUPk5HwJOV9B3hKr88JOHXpD9vEw5FrociR0PrJJN58rFSs0Qe2LCJw9HSoK4Z0/WN1fD/9Vk06Rft11+Dduomj2bDw9upN63nnRiVW1Tf4qKxlt+sya5y21huEBq1NC9vFwzBXQ7RjoOsi6N0ipNkAT1P5wuuDiv8E/zodXr7Ha8I9sOOr0nogIXf/vzwS2bGHrXVNwd+tGwuCm3WOl2pFADeR+DRs/hg0fWzWkkN+6f6jzEXD0r6H7CdB9iNV7TpuNVRulnSSaoroEXroMNn0KZz4AQ69v0uGh0lI2XvprQsXF9Hr5JTy9ekUlTNXKGAPb18O69+DHd2HjpxDyWQmp69HQa5g1BFePE61heZRqY7QXX0sJ1MBr18LqN+Gkm2H4n5s0bp9/82Y2XnIpzrQ0er3yMs5k/cJpl4I+2PCRlZB+fBd2bLS2p/ezbm3o8zPoeZI1koJSbZwmqJYUDlnXo75+Go66xOqC3oT7pKq+/ppNv72KpGHDyP7rbKQJCU61YoFqWLcYVr0Ba/9j3QDrirdqR/3OsKYOveyOUqmDTruZtySHE85+xOp6/v5Ua5Tl0f+wRkLeDwnHH0/nyZPIv3cqRbNnk3lz0wamVa1I0Gclo5X/hrX/tZ4rFN8BDjvfmnoNsx7HoJRqQBPUgRKBU2+3up4v+D08OwIuegYy++/X4R0uu4yaVasomjMX78CBpPzyl1EOWB00xsDWZfDtC7D8X9ZziRIy4KjRkaR0SpNHJlGqPdIE1VzHjrFqUq+NgydOhTOnwuCr99mzSkTo8sc/4lu3ji2TJuPp1Yu4/vuX3FSMqiyC71+2ElPBSuvhc4eOtEYi6XOajlGnVBPpNaiWUr4N3rgB1i2y7tY/bzYkd97nYYH8fDZcfDGO+AR6/+sVnKl6UbzV2bIMvnwcVrxqdQfPOs5KSkdcaDXnKaX2SjtJHAzGWB0n3p1ijWV23l9g4Dn7PKzqm2/ZdOWVJJ5wAt2feBxx6i/tmBcKwpq34Iu5sPlz6wmsgy6zhsfqpM8BU6op9pSgtPtYSxKxhpe57iPr+TcvXQavXWc9xG0vEo49hi53T6Hyk08onDnrIAWrDoivHD77Czw2yHq4ZdkWOPN+uHUVnP2wJielWpBeg4qGzAFwzWL4cJr1ZbbiVThuLJx6m3W9qhEdRo+mZsVKtj/1FPGDjib59NMPbsxq76qK4csnrKa8mhKr991Z06D/CL22pFSUaBNftJXmwUcPwbfPW08bHXItnHJLo+OlhX0+Nl1+Bf6NG+k9/1860kQsKN8Gn8+GJc9a498NHAmn3ArZx9kdmVJthl6DslvxT/DBg/D9K9b1isFjrZt8Ox9Rr8dfIC+PDRdehKtzZ3q99E8cCQn2xdye7dgEn86yfliEA3DERVZi6nyY3ZEp1eZogooV+aus50v98JY16Gynw+DIUdaU1h2Aik8+Jefaa0kZOZJuD+kzpA6qwrXwyQzrh4Q4rI4Pp0ywHuynlIqKg56gRORPwLVAYWTTncaYt/d2TLtIUDtVboeVr1k3cuZ8aW3rebLVRT37eAoXLKHor4/T+e4pdLz8cntjbQ+2LLOe+bV6AbjiYPBvYeiNkJpld2RKtXl2JagKY8wj+3tMu0pQdRVvgOXzYcV865HcgMFJ7pfZVGwO0utPY4k/8TTrqajJXVp+FAJjIFBl9VDzlVtjxPnKwV9pjR8XqIZgza55KAAmDBhrbsLWORxO6+ZUV2Ryeqwve2+SNehpXCrEpUWmVOsxJnYK1MAPC+Gbv1sDt3pTrGuEJ14PiRn2xqZUO6IJqrWoLLIeSJf7NaEfv2DDUz9iQobeZxbiigsDYj2kLqUrJHcDT0IkKXjqzD3WDaNBvzUP+azlYI11od9XDr6KXQnJXx5JOE0gjl0TYl1HC4es6zX7Ky7NegBkYifria+Jnaz1lG5WN/3UbGvZHd+02PYlfxV88w/4/iXrUempPaxrgsdfo6OHK2UDuxLUWKAMWAJMNMbsaGS/ccA4gB49ehy3adOmqMTTWtWsWMHGyy4n/tBe9LjtfKRiG5TlWffflG+zaj6hgDUoachvzcMBq8dgbS2mztybbE2eJKvG4E2yluNS6mzbuU+ilRxccdbcHW+Nvr23mk84HEmIO+OpsZJhTanVPbum1HquVk2JlYwrC6AiMlUWWrW33SWkWwkrrYc12nfdKbX73gdbNQZKc2DbCshfYQ3YmrfE+vscOhKO/Q30Pq1Jj0xRSrWsqCQoEVkENHZjz13AF0ARYIB7ga7GmKv2dj6tQTWu5NVX2XrXFDKuv57Mm2+yO5zoClRbybcsz+qiX5ZrzUtzrRueSzZZSa8ub6rVJJeYGamJZVo1u/xVkL8SfKW79u10OBxzudWDUpvxlIoJUXnchjFm+H6++FPAwua8VnuWdtFFVC1ZStHcucQfM4ikYcPsDil63PGQ3teaGmMMVORb3cB3bITSzZGaWKFVCytaB5s+s2qVnQ6FIy+yuvJ3OdJa1yfSKtVqRO0qtYh0NcZsjaxeAKyI1mu1B13+eDc1K1ey5fY/0Pu1V3F362Z3SPYQsTqKJHeBHifYHY1SKoqi2fD+kIgsF5HvgZ8Dt0Txtdo8R3w8WbNmYgIBcm+5BeP32x2SUkpFVdQSlDFmjDHmSGPMUcaY8+rUptQB8vbuTdf77qPmu+/Jf3i/O0cqpVSrpF2XWpmUEWfS8crfsOO55yh75x27w1FKqajRBNUKdZo4kfhBg9h61xR8P22wOxyllIoKTVCtkHg8ZM18FPF6yfv97wlXV9sdklJKtThNUK2Uu0sXuj38ML5169j2pz8TS4P+KqVUS9AE1YolnXIyGTfcQOkbb1Ayf77d4SilVIvSBNXKZYz/HYknn0z+vVOpWbXK7nCUUqrFaIJq5cTppNvDD+Hs2JHc308gVNbIWHZKKdUKaYJqA1wdO5L16AwCW7eyZfKdej1KKdUmaIJqIxKOOYbOf7idisWLKX7mWbvDUUqpZtME1YZ0GDOG5DPPpGDGDKq+/trucJRSqlk0QbUhIkLX+6bi6d6d3FtvJVBQYHdISil1wDRBtTHOpCSyHptFuKKSvFtvxQSa8IRbpZSKIZqg2qC4/v3p+n9/pnrJUgoenWl3OEopdUA0QbVRqeeeS4fLLqP4mWcoe/ddu8NRSqkm0wTVhnWadAdxRx/F1sl34tugg8oqpVoXTVBtmMPjIXvmTMTtJu/m3xOuqrI7JKWU2m+aoNo4d9eudJv+CL5169h6z5/0Jl6lVKuhCaodSDr5ZDJvvomyBQvY8cKLdoejlFL7RRNUO5F+3XUk/fzn5D/4IFXffGN3OEoptU+aoNoJcTjoNu1B3FndyPv9BL2JVykV8zRBtSPOlBSyH/sLoYoK8m7Rm3iVUrFNE1Q7EzegP12n3kv10qXkP/Sw3eEopdQeuewOQB18qeecQ833yyn++9+JP+ooUs8daXdISinVgNag2qlOt00kYfBgtt59NzVr1tgdjlJKNaAJqp0St5usmY/iTEkh98abCJWW2h2SUkrVowmqHXNlZJD92CwC27aRN/E2TChkd0hKKVVLE1Q7Fz9oEF3unkLlJ59QOHOW3eEopVQt7SSh6DB6NDWrVrH9qaeIO+xQUs46y+6QlFJKa1DK0uXOO4k/9li23HmXdppQSsUETVAKAPF4yJ41E2dyMrk33EiopMTukJRS7ZwmKFXLlZlJ9l8eI5ifr50mlFK20wSl6ok/+mi63PNHKj/9lIIZM+wORynVjjUrQYnIKBFZKSJhERm8W9lkEVknImtE5MzmhakOprSLL6bDZb+m+G/PULpgod3hKKXaqebWoFYAFwIf1d0oIocBlwKHAyOAOSLibOZrqYOo86RJ1kgTU6ZQvXyF3eEopdqhZiUoY8xqY0xjXb7OB14yxviMMRuAdcCQ5ryWOrjE4yHrsVm40tPJvfFGgoWFdoeklGpnonUNKgvIqbOeG9nWgIiME5ElIrKkUL8EY4qrY0ey5/yVUFkZuTfdTNjvtzskpVQ7ss8EJSKLRGRFI9P5ezuskW2msR2NMU8aYwYbYwZnZmbub9zqIIkbOJBuDzxA9bJlbPvznzGm0X9GpZRqcfscScIYM/wAzpsLdK+zng1sOYDzqBiQMuJMfNePp2jOXOIGDKTjb8bYHZJSqh2IVhPfm8ClIuIVkd5AP+CrKL2WOggybryRpNNPJ3/aNCo//9zucJRS7UBzu5lfICK5wFDgLRH5L4AxZiXwCrAK+A9wgzFG7/psxcThoNu0aXj79CZ3wi34N22yOySlVBsnsXRNYfDgwWbJkiV2h6H2wp+Tw8ZRo3Gmp9PrpX/iTE62OySlVCsnIkuNMYN3364jSagm8XTvTtasWfg3bSJv4kQdDkkpFTWaoFSTJZ4whC5TplD50ccUPDLd7nCUUm2UPg9KHZAOl16C78cfKX72Wbz9+pF24QV2h6SUamO0BqUOWOfJk0g8aSjb7rmHqm++sTscpVQbowlKHTBxuciaMQNXt67k3nQzgbw8u0NSSrUhmqBUszjT0ug+dy7G7yfn+hsIV1baHZJSqo3QBKWazdunD1kzZuD78Ufy7rgDEw7bHZJSqg3QBKVaRNKwU+g86Q4qFi2mcOYsu8NRSrUB2otPtZgOY8bgW7ee7U8+ifeQvqSed57dISmlWjGtQakWIyJ0uXsKCUOGsPWuKVR9+63dISmlWjFNUKpFidtN1qyZuLp2JffGmwhs0UHslVIHRhOUanGuDh3oPncOxufTnn1KqQOmCUpFhbdvX7IenYFv7Vrt2aeUOiCaoFTUJA0bRudJk7Rnn1LqgGgvPhVVHcZcgW99pGdf3z6knn++3SEppVoJrUGpqBIRuky5i4QTTmDrlLup+kZ79iml9o8mKBV14naTNfNRa8y+G2/UMfuUUvtFE5Q6KKyefXMxgQA5468nVKE9+5RSe6cJSh003j59yJr5KL7169ly2236NF6l1F5pglIHVdLJJ9P5zslUfPABBTNm2B2OUiqGaS8+ddB1vPxyfOvWUfy3Z/D2PUSfxquUapTWoJQtutx5JwlDT2TrPfdQtXSp3eEopWKQJihlC3G7yZ45E0+3buTeeBP+XO3Zp5SqTxOUso0zNZXsuXMxoRC548drzz6lVD2aoJStvH16kz3zUXw//cSW22/Xnn1KqVqaoJTtEk86yerZ97//Ufjoo3aHo5SKEdqLT8WEjpdfjn/9erY//Tc8fQ8h7YJf2R2SUspmWoNSMaPz5MkkDD2RbX/8o47Zp5TSBKVih7jdZD8aGbPvJn0ar1LtnSYoFVOcaWnWmH1+vz6NV6l2ThOUijnePn3ImjEd39q1bJk0WZ/Gq1Q7pQlKxaSkYcPo9IfbKX/vPYpmz7Y7HKWUDZqVoERklIisFJGwiAyus72XiFSLyLLI9HjzQ1XtTccrryT1ogspmjOXsrfftjscpdRB1txu5iuAC4EnGilbb4wZ1Mzzq3ZMROhyzz34N25iy+Q7cXfvQfyRR9gdllLqIGlWDcoYs9oYs6alglFqdw6Ph+zHZuFKT7eexltQYHdISqmDJJrXoHqLyLci8qGIDNvTTiIyTkSWiMiSwsLCKIajWitXejrZc/5KqKyM3JtuIuzz2R2SUuog2GeCEpFFIrKiken8vRy2FehhjDkGuBV4UURSGtvRGPOkMWawMWZwZmbmgb0L1ebFDRxItwcfpOa779l2z58wxtgdklIqyvZ5DcoYM7ypJzXG+ABfZHmpiKwH+gNLmhyhUhEpZ/4S3403UjR7Nt6BA0gfO9bukJRSURSVJj4RyRQRZ2S5D9AP+Ckar6Xal4zrx5P8y19S8NDDVHz8id3hKKWiqLndzC8QkVxgKPCWiPw3UnQq8L2IfAfMB35njCluXqhKgTgcdHvgfrz9+pF36634NmywOySlVJRILLXlDx482CxZoq2Aat/8uXlsHDUKZ1oavV5+CWdKo5c4lVKtgIgsNcYM3n27jiShWiVPdhbZj83Cn5NDnj7oUKk2SROUarUSjj+eLlOmUPnhRxTOnGl3OEqpFqYPLFStWodLL6Hmh9Vsf+ppvAMGkjryHLtDUkq1EK1BqVavy513Ej/4OLbedRfVK1baHY5SqoVoglKtnng8ZM+ahTO9I7k33kiwqMjukJRSLUATlGoTXOnpdJ89m1BJCbk3/x7j99sdklKqmTRBqTYj7rDD6Hb/fVR/8w3b7p2qwyEp1cppJwnVpqScfTY1a9ay/Ykn8A4YQMcrLrc7JKXUAdIalGpzMn9/M0mnn07+Aw9Q8emndoejlDpAmqBUmyMOB92mTcPbty95t+hwSEq1VpqgVJvkTEoke84cxOkkd/z1hEpL7Q5JKdVEmqBUm+XJziL7L4/hz8sj75ZbMcGg3SEppZpAE5Rq0xIGD6brn+6h8rPPyJ/2kN3hKKWaQHvxqTYv7aKL8P24juJ58/AecggdLhltd0hKqf2gNSjVLnS6/TYSTx3GtnvvpeIT7dmnVGugCUq1C+J0kjVjBt5DDiHv5pupWb3a7pCUUvugCUq1G86kJLo/8QSO1FRyxl1HYMsWu0NSSu2FJijVrrg7d6LHk08Qrqlh87hx2v1cqRimCUq1O95+/ciePZvAps3k3ngTYR1YVqmYpAlKtUuJJwyh6wMPUPX112ydNBkTDtsdklJqN9rNXLVbqSPPIbB1C4XTZ+Dq0oVOt9+GiNgdllIqQhOUatfSr7mG4NZtFD/zDI7EBDJvuMHukJRSEZqgVLsmInSechfhqiqK/jIbR1wc6VdfbXdYSik0QSmFOBx0vW8qxu+j4OFHEG+cPkdKqRigCUoprBt5u02bRtjnJ3/qVMTrocOoUXaHpVS7pr34lIoQt5usR2eQOGwY2/54D6Vvvml3SEq1a5qglKrD4fGQ/ZfHSBgyhC2TJlP2n//YHZJS7ZYmKKV244iLo/ucvxI/aBB5t06k5NXX7A5JqXZJE5RSjXAkJtLj6adIPPFEtt51F9vnzbM7JKXaHU1QSu2BIyGB7MfnkvzLX1Lw4DQKZs3CGGN3WEq1G5qglNoLh8dD1ozppF50IdvnPk7+vVN1WCSlDhLtZq7UPojLRdepU3GmpFL87LOEysvpdv99iNttd2hKtWnNqkGJyMMi8oOIfC8ir4tIWp2yySKyTkTWiMiZzY5UKRuJCJ3+cDuZEyZQtmABOeOvJ1RebndYSrVpzW3iew84whhzFLAWmAwgIocBlwKHAyOAOSLibOZrKWUrESHjd9fR5d7/o/KLL9h4yaX4N22yOyyl2qxmJShjzLvGmGBk9QsgO7J8PvCSMcZnjNkArAOGNOe1lIoVHUaNosff/kZo+3Y2jL6Eyi++tDskpdqkluwkcRXwTmQ5C8ipU5Yb2daAiIwTkSUisqSwsLAFw1EqehJPGEKv+f/ClZnB5muuYcdLL9sdklJtzj4TlIgsEpEVjUzn19nnLiAIvLBzUyOnarR/rjHmSWPMYGPM4MzMzAN5D0rZwtO9O71eeonEk09i25/+xLap92GCwX0fqJTaL/vsxWeMGb63chG5EhgJnG523SSSC3Svs1s2sOVAg1QqVjmTkug+Zw4Fj0yn+Nln8f3wA92mP4K7c2e7Q1Oq1WtuL74RwB3AecaYqjpFbwKXiohXRHoD/YCvmvNaSsUqcTrpfMcf6PbQNKpXrWLD+b+i4sMP7Q5LqVavudegZgPJwHsiskxEHgcwxqwEXgFWAf8BbjDGhJr5WkrFtNTzzqP3/Pm4Oncm57rfkf/Qw5hAwO6wlGq1JJaGbhk8eLBZsmSJ3WEo1Szhmhryp02j5J8vEXf0UWRNn4Enu9E+QkopQESWGmMG775dhzpSqoU54uLoes89ZM18FP/6n9hwwQWULlig4/gp1USaoJSKkpQRI+j9+mt4+/Rhy+1/IPfGmwgUFNgdllKthiYopaLI0707PV98gU63307lJ5/w07nnUfrGG1qbUmo/aIJSKsrE6ST96qvo/frrVm3qjknkjr+eQL7WppTaG01QSh0k3j696fn8c3SadAeVn3/OTyNHUvzii5iQdnBVqjGaoJQ6iMTpJH3sWPq88W/iDjuM/P+7lw0Xj6Lqm2/tDk2pmKMJSikbeHr1ose8Z8maMZ1QcTGbLruMLZMmEywqsjs0pWKGJiilbCIipJx9Nn3ffov0a6+l9K23WD/iLIr/8Q+M3293eErZThOUUjZzJCbSaeKt9HnjDeKPPpr8+x9g/chzKX3rLX28vGrXNEEpFSO8fXrT/emn6P7E4zji49ky8TY2XHwxFZ98qt3SVbukCUqpGCIiJP3sZ/R+/TW6PTSNcGkZOddcw+bfXkX18uV2h6fUQaUJSqkYJA4HqeedR5933qbznXfiW7OGjaNGs3ncOKq++cbu8JQ6KDRBKRXDHB4PHX8zhr7vvUvmLbdQs3wFmy67nE2/uZLKzz/Xpj/VpmmCUqoVcCYlkXHdOA5ZvIjOkyfh37iRzb+9io2XXkr5++9rZwrVJunjNpRqhcJ+P6Wvvc72p54ikJeHu2cPOl5+BakXXoAzKcnu8JRqkj09bkMTlFKtmAkEKF+0iOK//4PqZctwJCaSetGFdLziCjw9etgdnlL7RROUUm1c9fLlFP/jOcreeQdCIZJOPZW00aNIOvVUxO22Ozyl9kgTlFLtRCC/gJKXX6LkX/MJFhbizMwg7VcXkHbxRXh69rQ7PKUa0ASlVDtjgkEqPvqIkn/Np+LDDyEcJmHIEFIvvIDk4WfgTEq0O0SlAE1QSrVrgfx8Sl//NyWvvkogJwfxekn6xc9JHTmSxGHDcHg8doeo2jFNUEopjDFUf7uMsoULKXvnHUI7duBISSHlzF+SPGIEiccfj2iyUgeZJiilVD0mEKDyiy8oW7iQ8vcWEa6qwpGcTNJpp5E8fDhJw07BkZBgd5iqHdAEpZTao3BNDZWffUb5e4uoeP99QqWliNdL4sknk3Taz0gaNgx31652h6naqD0lKJcdwSilYosjLo7kX/yC5F/8AhMMUrVkKeWLF1O+2EpYAN5+h5A47FSSTh1G/LHH6nUrFXVag1JK7ZExBv+6dVR8/AkVH39E1ZKlEAggCQkkDD6OxBNOIGHICcQddijidNodrmqltIlPKdVs4cpKKr/8ispPPqbyiy/x//QTAI7kZBIGDybhhCEkHHcccQMH6s3Bar9pE59SqtkciYkk/+LnJP/i5wAECgqo+uprqr78ksqvvqTif/8DQOLiiD/ySOKPOYb4YwYRP2gQrg4d7AxdtUJag1JKtZhAfj7V335L1TffUP3tMmpWr4ZgEAB3jx7EH3E4cYcfQdwRRxB3+GE6sK0CtIlPKWWDcHU1NStWULVsGTUrVlKzfDmBLVusQhE8vXrhHTiAuAED8Q7oT9zAgbi6dEFE7A1cHVTaxKeUOugc8fEkHH88CccfX7stWFxMzcqVVC9fTs3KVdQsX0H5O//ZdUxqKnH9+uHpdwjePn3xHtIXT9++uDIzNXG1M5qglFIHlatjR5KGDSNp2LDabaHycnxr11KzZg2+H9bgW7uWsoVvES4vr93HkZKCt3dvPL164enV05r37ImnZ08ciTquYFukTXxKqZhkjCFYWIh//Xp863/Ct34d/g0b8W/cSHDbtnr7OjMy8GRn487Oxt09O7LcHXdWFu7OnbRHYYyLShOfiDwMnAv4gfXAb40xJSLSC1gNrIns+oUx5nfNeS2lVPsiIrg7dcLdqROJQ4fWKwtXV+PfvBn/xk34N27En7OZQG4e1d9+W/s8rDonwpWZibtrV1zduuLu2g13l864OnXG1akT7s6drOZDvfE45jS3ie89YLIxJigi04DJwB2RsvXGmEHNPL9SSjXgiI8nbsAA4gYMaFBmAgEC27YRyMkhsHUrgS1brfnWLfhWraZi8fsYv7/Bcc70dFyZmbgyMqwp05o70zNwZaTj7NARV3pHnGlpiEuvjhwMzforG2PerbP6BXBx88JRSqnmEbcbT/fueLp3b7TcGEOopIRgfj7BggIC+fkE8wus9aIigkVF+NavJ1hUBIFAIy8gOFNTcXbsiLNDB5xpaTjTUnHVLqfhSE3FmZKKMzXF2jclBUlI0E4eTdSSPwOuAl6us95bRL4FyoApxpiPW/C1lFLqgIgIrg4drBuHBw7c437GGMKlpQQLCwkW7yC0o5jg9u2EincQLN5OaHsxoZISAjk51CxfTmjHDkxjCW0nlwtncjKO5GRrnpKMM8ladyQl4kxKwpGYhCMpCUdSIo7ERJyJiUhCQv15fDzicEThLxN79pmgRGQR0KWRoruMMW9E9rkLCAIvRMq2Aj2MMdtF5Djg3yJyuDGmrJHzjwPGAfTo0ePA3oVSSrUwEamtEXn3Y39jDKaqilBJCaGyMkKlZYRKSwmVlRIuKyNUUkqoopxweQWh8jLC5RX4izYQKisnXFlJuLIS9rPTmsTH49g5JcQjCQk44uJxxMUhCfHWcnwc4o1D4rw4ds7jrG2OOK9V5vXg8HqRyOTweBCPx1rfuex225YQm92LT0SuBH4HnG6MqdrDPh8Atxlj9tpFT3vxKaXaKxMOY6qrCVVUEq6IJK2qqvrzykrCVdWEq6sJV1Viqqt3rddUY6prGiybmprmB+d243C7rWS1M2l5PEh8PH1ef63Zp49WL74RWJ0iflY3OYlIJlBsjAmJSB+gH/BTc16rKU477bQG20aPHs31119PVVUVZ599doPysWPHMnbsWIqKirj44oaX0saPH88ll1xCTk4OY8aMaVA+ceJEzj33XNasWcN1113XoHzKlCkMHz6cZcuWMWHChAbl999/PyeddBKfffYZd955Z4PymTNnMmjQIBYtWsTUqVMblD/xxBMMGDCABQsWMH369Ablzz33HN27d+fll19m7ty5Dcrnz59PRkYG8+bNY968eQ3K3377bRISEpgzZw6vvPJKg/IPPvgAgEceeYSFCxfWK4uPj+edd94B4N5772Xx4sX1ytPT03n11VcBmDx5Mp9//nm98uzsbJ5//nkAJkyYwLJly+qV9+/fnyeffBKAcePGsXbt2nrlgwYNYubMmQBcccUV5Obm1isfOnQoDzzwAAAXXXQR27dvr1d++umnc/fddwNw1llnUV1dXa985MiR3HbbbYB+9vSzd+CfvYtHjWrZz57Xw+gxVzB+/HgqS0o459xzIWzAhDHhMITDXH7OOVxx5pkUFhQw5u4/Yoy1HWMw4TBjhw3jV0cdTW5BPje88IJVw9t5DmOYP2o00dTca1CzAS/wXuTi387u5KcC/yciQSAE/M4YU9zM11JKKdVEImI149Xpebizq4a3Vy8Shw6luqgIZ8eGg/mmDB9O5iWXUJOTg+fjht0IsmY0/EHSkvRGXaWUUrbaUxNf++gKopRSqtXRBKWUUiomaYJSSikVkzRBKaWUikmaoJRSSsUkTVBKKaVikiYopZRSMUkTlFJKqZikCUoppVRMiqmRJESkENjUAqfKAIpa4Dytgb7Xtqe9vE/Q99pWNfW99jTGZO6+MaYSVEsRkSWNDZvRFul7bXvay/sEfa9tVUu9V23iU0opFZM0QSmllIpJbTVBPWl3AAeRvte2p728T9D32la1yHttk9eglFJKtX5ttQallFKqldMEpZRSKia1qQQlIiNEZI2IrBORSXbHEy0i0l1E/iciq0VkpYj83u6Yok1EnCLyrYgstDuWaBKRNBGZLyI/RP59h9odU7SIyC2Rz+8KEfmniMTZHVNLEZFnRKRARFbU2dZRRN4TkR8j84bPWG+F9vBeH458hr8XkddFJO1Azt1mEpSIOIG/AmcBhwG/FpHD7I0qaoLARGPMocCJwA1t+L3u9Htgtd1BHASzgP8YYwYCR9NG37OIZAE3A4ONMUcATuBSe6NqUfOAEbttmwQsNsb0AxZH1tuCeTR8r+8BRxhjjgLWApMP5MRtJkEBQ4B1xpifjDF+4CXgfJtjigpjzFZjzDeR5XKsL7Ese6OKHhHJBs4BnrY7lmgSkRTgVOBvAMYYvzGmxNagossFxIuIC0gAttgcT4sxxnwEFO+2+Xzg75HlvwO/OpgxRUtj79UY864xJhhZ/QLIPpBzt6UElQXk1FnPpQ1/ae8kIr2AY4AvbQ4lmmYCfwDCNscRbX2AQuDZSHPm0yKSaHdQ0WCMyQMeATYDW4FSY8y79kYVdZ2NMVvB+pEJdLI5noPlKuCdAzmwLSUoaWRbm+5DLyJJwKvABGNMmd3xRIOIjAQKjDFL7Y7lIHABxwJzjTHHAJW0nWageiLXX84HegPdgEQRucLeqFRLE5G7sC5JvHAgx7elBJULdK+znk0bajLYnYi4sZLTC8aY1+yOJ4pOBs4TkY1Yzba/EJHn7Q0panKBXGPMztrwfKyE1RYNBzYYYwqNMQHgNeAkm2OKtnwR6QoQmRfYHE9UiciVwEjgcnOAN9y2pQT1NdBPRHqLiAfrguubNscUFSIiWNcpVhtjZtgdTzQZYyYbY7KNMb2w/k3fN8a0yV/axphtQI6IDIhsOh1YZWNI0bQZOFFEEiKf59Npox1C6ngTuDKyfCXwho2xRJWIjADuAM4zxlQd6HnaTIKKXJC7Efgv1gf9FWPMSnujipqTgTFYtYllkelsu4NSLeIm4AUR+R4YBNxvbzjREaklzge+AZZjfRe1maGAROSfwOfAABHJFZGrgQeBM0TkR+CMyHqrt4f3OhtIBt6LfD89fkDn1qGOlFJKxaI2U4NSSinVtmiCUkopFZM0QSmllIpJmqCUUkrFJE1QSimlYpImKKWUUjFJE5RSSqmY9P/Yemvdpb3iwgAAAABJRU5ErkJggg==\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "# Plot the results\n", + "# plt.subplot(2, 1, 1)\n", + "for i, y in enumerate(C @ xout):\n", + " plt.plot(tout, y)\n", + " plt.plot(tout, yd[i] * np.ones(tout.shape), 'k--')\n", + "plt.title('outputs')\n", + "\n", + "# plt.subplot(2, 1, 2)\n", + "# plt.plot(t, u);\n", + "# plot(np.range(Nsim), us*ones(1, Nsim), 'k--')\n", + "# plt.title('inputs')\n", + "\n", + "plt.tight_layout()\n", + "\n", + "# Print the final error\n", + "xd - xout[:,-1]" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.5" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/examples/pvtol-lqr-nested.ipynb b/examples/pvtol-lqr-nested.ipynb index 9fff756ff..59e97472a 100644 --- a/examples/pvtol-lqr-nested.ipynb +++ b/examples/pvtol-lqr-nested.ipynb @@ -20,9 +20,9 @@ "## System Description\n", "This example uses a simplified model for a (planar) vertical takeoff and landing aircraft (PVTOL), as shown below:\n", "\n", - "![PVTOL diagram](http://www.cds.caltech.edu/~murray/wiki/images/7/7d/Pvtol-diagram.png)\n", + "![PVTOL diagram](https://murray.cds.caltech.edu/images/murray.cds/7/7d/Pvtol-diagram.png)\n", "\n", - "![PVTOL dynamics](http://www.cds.caltech.edu/~murray/wiki/images/b/b7/Pvtol-dynamics.png)\n", + "![PVTOL dynamics](https://murray.cds.caltech.edu/images/murray.cds/b/b7/Pvtol-dynamics.png)\n", "\n", "The position and orientation of the center of mass of the aircraft is denoted by $(x,y,\\theta)$, $m$ is the mass of the vehicle, $J$ the moment of inertia, $g$ the gravitational constant and $c$ the damping coefficient. The forces generated by the main downward thruster and the maneuvering thrusters are modeled as a pair of forces $F_1$ and $F_2$ acting at a distance $r$ below the aircraft (determined by the geometry of the thrusters).\n", "\n", @@ -217,7 +217,7 @@ "outputs": [ { "data": { - "image/png": "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\n", + "image/png": "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\n", "text/plain": [ "
" ] @@ -276,7 +276,7 @@ "outputs": [ { "data": { - "image/png": "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\n", + "image/png": "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\n", "text/plain": [ "
" ] @@ -307,11 +307,10 @@ "\n", "To design a controller for the lateral dynamics of the vectored thrust aircraft, we make use of a \"inner/outer\" loop design methodology. We begin by representing the dynamics using the block diagram\n", "\n", - "\n", - "where\n", - " \n", + "\n", + "\n", "The controller is constructed by splitting the process dynamics and controller into two components: an inner loop consisting of the roll dynamics $P_i$ and control $C_i$ and an outer loop consisting of the lateral position dynamics $P_o$ and controller $C_o$.\n", - "\n", + "\n", "The closed inner loop dynamics $H_i$ control the roll angle of the aircraft using the vectored thrust while the outer loop controller $C_o$ commands the roll angle to regulate the lateral position.\n", "\n", "The following code imports the libraries that are required and defines the dynamics:" @@ -329,7 +328,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 11, "metadata": {}, "outputs": [], "source": [ @@ -343,7 +342,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 12, "metadata": {}, "outputs": [], "source": [ @@ -361,7 +360,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 13, "metadata": {}, "outputs": [], "source": [ @@ -381,7 +380,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 14, "metadata": {}, "outputs": [], "source": [ @@ -397,7 +396,7 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 15, "metadata": {}, "outputs": [], "source": [ @@ -418,7 +417,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 16, "metadata": {}, "outputs": [], "source": [ @@ -429,12 +428,12 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 17, "metadata": {}, "outputs": [ { "data": { - "image/png": "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\n", + "image/png": "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\n", "text/plain": [ "
" ] @@ -464,12 +463,12 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 18, "metadata": {}, "outputs": [ { "data": { - "image/png": "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\n", + "image/png": "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\n", "text/plain": [ "
" ] @@ -487,12 +486,12 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 19, "metadata": {}, "outputs": [ { "data": { - "image/png": "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\n", + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAYcAAAEGCAYAAACO8lkDAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjMuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8vihELAAAACXBIWXMAAAsTAAALEwEAmpwYAABFsUlEQVR4nO3dd3zV9fX48de5GTd770XIYG/CFlBQcVCxVi21WmfVatW2Wqu23+5ha3+21tZW6qwL9144QUT23gQImZCE7HWT3Pv+/XEvMZggAXJz703O8/HgkXs/n0/u57xJcs99bzHGoJRSSnVm8XQASimlvI8mB6WUUl1oclBKKdWFJgellFJdaHJQSinVhb+nA+gNcXFxJiUlhcDAQE+HcspaW1t9vhz9oQzQP8qhZfAe3liOdevWVRpj4rs71y+SQ2ZmJosXLyYnJ8fToZyy/Px8ny9HfygD9I9yaBm8hzeWQ0QOHOucNisppZTqQpODUkqpLjQ5KKWU6kKTg1JKqS40OSillOpCk4NSSqkuNDkopZTqol/Mc1DqRBhjaGq109xmJy7MCsC20lrK62002ew0tbbTZjc01tRxZFj682sKKalu/vJFREiJDGLh5AwAFq8upLa5jQA/CwH+FgIsQkpUMLOGOOcX7TlUT4CfhbAgf8Ks/lj9LYhIn5ZbqROhyUH1C82tdg7VtVBeb6O8voXyOhvNbXZuPsP57n7vuzv5cMchapraqG1upc1uSI0K5vO75nSc/2xP5VGvmRkdyPfnOR8vXlPEhsIaRODIFigTMqI6ksOjy/ezp7zhqO+fPSS+Izlc+dhqSmtbOs4F+AnfGJvC/ZeOA+CmZ9YR4GchPsxKXLiV+DArw5LDGZkSCYDDYbBYNJmovqPJQfmE+pY2Dhxuori6ieLqZoqqmiitbeHhyydisQi/fWs7z60uPOp7QgL9uOn0bESEyOAAchPCiAoJIDI4kKiQABLCrR3X3nPecFra7IRa/QkO8CPQ30JJ4ZeTR1+6cToW4Zif9t++dSbtDgdt7YZWu4M2uwP/Tm/mf/jmaKqbWmmwtVPf0k6jrZ2chDDAWZOpqLdxsK6FinobLW0OAL43bRC/XRBJm93B8P97j5jQQFKjg0mPDiE9JpgzhiaQlxmDMQa7w+Dvp63EqvdoclBew+4wFFU1sbeigfxy57+fnz+cqJBAHltewN8+3N1xbbjVn7SYEOpb2okMCeCiCalMHBRNQriVhAgrCeFBRAUHdLyZ/+D07K+99/DkiC7H6oP8Oh77HedTe6C/hUAscIylc84YlnDM7xURXrxxOuBMFI2tdirqbVj9nW/27XbDjbOzOVTXQnF1M+sLq3l7SxnhQQHkZcZQUtPM6fd9SnJUEBkxIWTHh5GbGM7s3G6XzFGqRzQ5KI+oaWplW2kdw5LCiQ2z8s6WMn70/EZa2x0d18SFWbnmtMFEhQRy3ugkhiaFkRYdQnp0CBHB/kd9ip+UGcOkzBhPFKVXiQhhVme/xBHBgX7cMW/oUde12x20O5ztW4H+Fm6cnU1hVRMHqpp4ZX0JDbZ2Hlg4jpFhsLWklt+9tZ3cxDByE8IZnhzByJQIQq3656+OTX87VJ+oqLfx7KpCtpXWsq20jpIaZ+fuAwvHsWBcKrkJYVw9PZPs+DCyE8LIjg8lKuTLj+G5ieHkJoZ7Knyv4+9nwd9VsUkIDzoqeRhjOFjXQpjVn0PFB2hus9Nmd/D6xlLqW9oBEHE2lU0cFM2Bw40crG1hZGrkUUlJDWz6m6B6lcNh2F9lY9WqA6wtqGZmbhwXTUijze7g7x/tZnBcKBMGRXPFtEGMSolkTLqzwzU3MZy7zxvu4ej7BxEhOTIYgEM4a1Wv3DQDYwyH6mxsL6tlS3EduYnOPo+X15fwj4/2IAJZcaFMyIgmLzOaC8enYvX3+5o7qf5Mk4M6JcYYRASHw3D9U+tYvf8wda5Pp3FhVka42vKTI4PY+ut52pThQSJCUmQQSZFBzBmW2HH8ymmDGJceyZbiOraU1PDhjkO8u/UgF09MB+C51YXUt7QxcVAMY9IiCdCO7wHBo3+pIhIFPAKMAgxwDbALeB7IBAqAS40x1Z6JUH2VMYb88gaW7q5g2Z5KrP4W/vu9PCwWwepv4bzRyWQEt3H+lGFkxIR09AuIiCYGLxUbZmXOsMSOhHGkWepIJ/xHO8r5cMchAEID/Zg8OIZzRiXx7UkZHotZuZ+n/1ofAN4zxlwsIoFACHAP8JEx5l4RuQu4C/iZJ4NUTouW7eXxzwsoc43Xz44P5awRSR3n//XdCYBzU5NBsaEeiVGdus7NUgCPXJlHRb2NtQVVfLHvMMvzK/k8/3BHcvjV61sZmRLJ6UPjSYgI8lTYqpd5LDmISAQwC7gKwBjTCrSKyALgdNdlTwKfosmhTxlj2H2ogfe2HuSzPRU8fvUkwoMC8LdYGJsWxS1z4pk1JI606BBPh6r6SHy4lXNHJ3Pu6GQAbO12AKobW3l7y0Ge/MI5J2RsWiRzhiWyYFwKmXH6AcGXiTky3bOvbywyDlgEbAfGAuuA24ASY0xUp+uqjTHR3Xz/9cD1ACkpKROXLFmC1Wr96mU+x2azeawcFY1tvLWjlmX76ymqbUWAofFB/Gx2MulRPd/71pNl6E39oRx9UQZjDPuqbKwqamRlYQM7ylu454xkzsiOoKKxjf1VNsanhBLgd3IzvPvDzwG8sxy5ubnrjDF53Z3zZLOSPzABuMUYs0pEHsDZhNQjxphFOJMLeXl5xmq1et3+rCejL/eZNcawrbQOq7+F3MRw2g/W8dzifUzNiuX6M5I5Z2QS8eEn/svsjXvlnoz+UI6+KkMuMG+K8/HhBhshgf4EB/rx6Wf7+P37+4gI8uesEUmcNzqJ03LjTmgUVH/4OYDvlcOTyaEYKDbGrHI9fwlncjgkIsnGmDIRSQbKPRZhP2SMYWtJHW9vKeOdLWUUVjVx0YRU7r90HEMTw1n7i7OICe15LUGpr4oN+/IDxRXTBpEVH8rbmw/ywfaDvLy+mOiQAL64ey5BATpM1pt5LDkYYw6KSJGIDDXG7ALm4mxi2g5cCdzr+vq6p2Lsj658fA3LdlfgbxGm58Rx8xnZHZ3KIqKJQfUqq79fx0io1vbRrNhbyZ5DDR2J4ZbnNhAXFsg3x6cyOjVSV6r1Ip4erXQL8IxrpNI+4Gqce0y8ICLXAoXAJR6Mz6cZY1hTUM3bm0v55TdG4mcR5o1M5OwRicwfk3zUDGSl3C3Q38LpQxM4fahznSm7w+BwGJ5ZWcjjnxeQFR/KJRPTuXhi2tc2Z24sqmFcelQfRT1weTQ5GGM2At11hszt41D6lcMNNl5ZX8LiNYXsrWgkzOrPZVMGMTQpnO9OGeTp8JQCnIsZ/uu7E6htauPdrWW8sr6EP7+3kwA/4bqZWbTbHVi+UpMoqGzk0v98wVPXTmZKVqyHIh8YPF1zUL1sW2ktF/7rc9rshgkZUfzl4jHMH5NMSKD+qJV3igwJYOHkDBZOziC/vIF4V5/FaxtLefDjPZyVFcINienEh1v5z9K9tNod/PC5Dbx9y2k6r8KN9B3Dx9na7byyvoR2u4MrpmUyLCmCG2dnM39MCkOTdKE65VuO7HEBkBhhJSkiiEfWVPLk+o+YlRvPp7ud41Mq6m3c/Ox6nv3+VF3Ow000Ofio5lY7z60uZNGyfRysa2FmbhyXTx2En0W4/eyhx38BpbzczNx4ZubG8/Habaw4KPzviwPYv1zRnTUF1dz77k7+b/4IzwXZj2ly8EHvbS3j569u5XBjK5MHx/CXi8cwMzdOR3qofikjysqYYek8tbKgy7lHl+9neHJ4xyKBqvdocvARVY2ttDscJIQHkRARxKjUSH44J6dfbHCj1PE8tnw/tvbuV3O448XNrD9QzR3zhulQ7F6kycHLHaprYdGyfTy7qpAF41K491tjmJARzZPXTPZ0aEr1maZWO5fmpTk3ObIIfhYhwM9CRb2NLcU1PLe6iNc3lvLJHadrJ3Uv0eTgpYqqmvj30r28tLYYuzEsGJvCtacN9nRYSnnEry8Y+bXndx+q5+Od5R2J4e3NZUzJiiEuzLvWMvIlmhy81IMf7+G1DaVcnJfGjbOyyYjVFVCVOpYhieEMcW0jW93Yyo9f2IhF4Mppmdx0eg6RIQEejtD3aHLwEsYY3t92EBpbyMmBu88dzk/OGkpSpFaRlToR0aGBvHfbTB78OJ9Fn+1j8Zoibj4jm+9Ny9T1nE6ADhD2AgWVjVz1+BpufHo9r25zbnoXHRqoiUGpk5QVH8bfvj2Ot2+Zydj0KO57fxcV9TZPh+VTtObgQS1tdv796V7+vXQvgX4Wfjl/BDMS2j0dllL9xoiUCP53zWQKKhtJj3E2zf7+re2cPTKJyYN1pN/X0ZqDBz298gAPfLSHc0Ym8dHts7nmtMEd+/YqpXrPkV3pqhtbeWdLGZc+/AW3Ld7AQdeWt6orrTn0sZKaZg7VtTAhI5rLpw5iVGokU3UBMaX6RHRoIB/dfjr//jSf/yzbxwfbD3Hr3FyumTGYQH/9rNyZ/m/0kdZ2B//+dC9n/r+l3PnSZhwOQ1CAnyYGpfpYcKAfPzl7KB/+eDbTs+NYtGwfza12T4fldbTm0AdW7K3kl69vI7+8gbNHJPLLb4zAos1HSnlURmwIj1yZx6G6FiJDAmi3O1j02T6umDqI8CAd+qo1Bzdbte8wl/13FbZ2O49dlcei7+WRFq1zFpTyFomuiXNrCqr56/u7OOv+ZXy4/ZCHo/I8TQ5uYoxzHZjJg2P44zdH88GPZzNnWKKHo1JKHcu07FheuWkGUSEBXPe/tfzk+Y3UtbR5OiyP0eTgBrsP1XP+P5aTX96AiHDZlAydfKOUDxiXHsWbt5zGrXNzeX1TKTc9vd7TIXmM9jn0si/2Hub6p9YSHOBHa7vj+N+glPIqAX4WfnLWEOYMS+BI12BLmx0RsPoPnA95mhx60RubSrnjhU1kxIbwxNWTtG9BKR82Lj2q4/Ef39nB+sJq/nXZBAbFhnouqD6kzUq95IPth7j1uQ2MS4/ipRunaWJQqh+ZmRtP4eEm5v9jOe9tLfN0OH3C48lBRPxEZIOIvOV6HiMiH4jIHtfXaE/H2BMzc+P40Zm5/O/ayUSF6IYjSvUnZ41I5O1bZ5KVEMaNT6/n129s6/fNxh5PDsBtwI5Oz+8CPjLG5AIfuZ57pZY2O398Zwe1zW0EBfjxozOHaMezUv1UekwIL94wjatnZPLi2iJKapo9HZJbeTQ5iEgacD7wSKfDC4AnXY+fBC7s47B6pLqxlcsfWcV/P9vH5/mVng5HKdUHAv0t/OobI/no9tMZHBeKMYaiqiZPh+UWcmQ8vkduLvIS8CcgHLjDGDNfRGqMMVGdrqk2xnRpWhKR64HrAVJSUiYuWbIEq7Vvdn0qq2/lnvdKONjQxl2zk5mdFd5rr22z2fqsHO7SH8oA/aMcWgb3endXLQ+uOMQds5KYkx3xtdd6Yzlyc3PXGWPyujvnsdFKIjIfKDfGrBOR00/0+40xi4BFAHl5ecZqtZKTk9O7QXZjR1kdP168mja74Znrpvb6sr/5+fl9Ug536g9lgP5RDi2De12WZOOzolb++EkZdYTyk7OGHHNpHG8uR3c82aw0A7hARAqAxcAcEXkaOCQiyQCur+WeC7Gr6JBAsuJCefkH03Q9eKUGuNgwK09fN4WFk9L55yf53P7ipn7TUe2x5GCMudsYk2aMyQQWAh8bYy4H3gCudF12JfC6h0I8yrLdFdgdhqTIIJ6/YSo5Cb3XlKSU8l2B/hb+dNFofjpvKK9vLGHtgSpPh9QrvGG00lfdC5wlInuAs1zPPcYYw98/3M33HlvNM6sOACCiK6oqpb4kItx8Rg5LfjyL6dlxALTbfbsG4RXJwRjzqTFmvuvxYWPMXGNMruurx9Jwm93BXS9v4e8f7uFbE9L4zuQMT4WilPIBR1oUlu2uYP6DyzlU57s7zXlFcvBGjbZ2vv+/tTy/tohb5+Tw10vGEOCn/11KqeMLCvCjqKqJhYtWUlbrm/Mh9N3uGPZXNrK2oJo/fnM0Pzl7qDYlKaV6bPLgGP537WQq6m18++GVPjlhTpPDV9Q0tQIwKjWSZXeewWVTtClJKXXiJg6K4alrJ1Pd1MrCRV9Q29Lu6ZBOiCaHTupb2jjz/qW8uLYIgJhQXSNJKXXyxmdE8/S1Uzh3VDLhVt9aWkeX7O7k3a0HqWxoJSNGV1RVSvWOselRjE2PIj8/n9KaZiKCAwizev9br9YcOnltQwmZsSE6uU0p1eta2x1c+vAX3PDUWmztdk+Hc1yaHFzKapv5Yt9hLhyfqp3PSqleF+hv4cdnDuHz/MPc9fIWPLmuXU94f92mj7yxsRRj4MJxqZ4ORSnVT31rYhqlNc38vw92kx0fyg/n5Ho6pGPS5OBy3uhkIoIDyIwbGFsAKqU844dzcthb0cBfl+xmeHIEc4cnejqkbmlycEmPCdEZ0EoptxMR7v3WGBIjgpjkxf2b2ucAvLqhmCXbDno6DKXUABEU4Mfd5w0nIigAW7ud+pY2T4fUxYBPDg6H4c/v7mLxmiJPh6KUGmDsDsNl/13FT17Y5HUd1AM+Oazcf5iDdS1cOF47opVSfcvPIpw3OpkPth/ikc/2ezqcowz45PDahhJCA/0462s6hVra7KzIr+Rgre+usKiU8k7XzMhk3shE/vL+TraX1nk6nA4DOjm0tNl5YW0x07JjCQ78cmp7a7uD1fureODDPSxc9AVjfrOEyx5ZhWVA/28ppdxBRPjTRWOIDA7kJy9s9JoJcgN6tFJdcxshgX7cNncI1Y2t/OzlzWwqruFwYyvt9qPb/9Kig0kID/JQpEqp/iwmNJD7Lh7Dve/upKLeRlq055fwGdDJISEiiNU/P5Mwqz/GGJpa7Ryqs3V77bj0KGztdqz+vrV4llLKN5wxLIGZuXH4e8m+MQM6OQAdC2CJCE9fN4Vlu8u56ZkNNNiOXl53fEY0l/7nC+pa2hmfEcX4jGjGp0cxLCnca36YSinf5u9noba5jae+KODG2dkefW85oTuLiEVEItwVjDeYNSSBt289jaGJ4UcdH58RxTfGppCbEMay3ZX832tbmf/gcn70/MaOaz7ZWU65D28LqJTyvJX7DvPXJbt5YkWBR+M4bs1BRJ4FbgTswDogUkTuN8bc5+7gPGVQbCiv3DSdO17cxLtbDxLoZ2FkSgQTMqK5bmYWxhhKappZX1hDnGvPh/L6Fq5+Yg0AqVHBHbWLucMSdEkOpVSPnT0ikTnDEvjbB7s5f0wyyZHBHomjJzWHEcaYOuBC4B0gA7jiVG8sIuki8omI7BCRbSJym+t4jIh8ICJ7XF+jT/VeJyPU6s9D353AT+cNZWRqxFF9DSJCWnQIF4xNYXpOHADRIYG8ctN0/m/+CMZlRLGhsIbfvbWdNQVVABRUNvLbN7fz5qZSiqubvG7Ci1LKO4gIv7lgJO0Ow5/f3emxOHrS5xAgIgE4k8M/jTFtItIb72ztwO3GmPUiEg6sE5EPgKuAj4wx94rIXcBdwM964X4nTES4+YwcLpmYdtxrA/wsTMiIZkJGNNcyGIBDdS0dQ2Tzyxt4dvUBHvvcOdElPtzK+PQofnH+CDJiPT8yQSnlPdJjQrj2tME89OlerpuZxajUyD6PoSfJ4WGgANgELBORQcApz9QwxpQBZa7H9SKyA0gFFgCnuy57EvgUDyWHIxIiTm4Ia2Kn7ztzRCJbfj2PXQfr2VBYzYbCGjYU1RAW5PwRLFq2l9c3lpIVaeH02iDGZ0QxOC5U95ZQaoC68fRsDtXZCA/yzLghOZnmDRHxN8b02m7ZIpIJLANGAYXGmKhO56qNMV2alkTkeuB6gJSUlIlLlizBarX2Vkh97qP8Ot7fXcuOimaa25w/k9gQf55dmIWfRSiubSUq2I+wQO8fSmuz2Xz6Z3FEfyiHlsF7eGM5cnNz1xlj8ro7d8yUJCKXG2OeFpGfHOOS+3sjOBEJA14GfmSMqevpJ2VjzCJgEUBeXp6xWq3k5OT0RkgekZMDN5wDu3bvQSKT2FBYTWVDK0OHOMv0s3+vYH1hNTnxYR2d3ZMyo8lJCD/OK/e9/Px8n/5ZHNEfyqFl8B4nW47dh+p5b+tBbp3btxsDfV195cgQG7e9+7j6Ml4GnjHGvOI6fEhEko0xZSKSDJS76/7eyM8i5CSGM+QrQ2nvOHsoawuq2FBUwwfbD/HC2mLOHJ7AI1dOAuC/y/aRFR/KuPQoYsO869OJUurkLd9Tyf0f7GZ6dix5mX23/8Mxk4Mx5mHX19989ZyIBJ7qjcVZRXgU2GGM6VwLeQO4ErjX9fX1U71XfzAtO5Zp2bEAGGM4cLgJW7sDgPqWNv783k7aHc7mqEGxIYxPj+KSvHRmuEZTKaV808LJ6fzzk3z+9Uk+j189uc/u25N5Dp8CVxljClzPJwGPAGNP8d4zcA6J3SIiG13H7sGZFF4QkWuBQuCSU7xPvyMiR82dCA8KYMuv57GlpLajs3vF3sNMyYplBrC/spE7X9rUMat7fEY0SZG6TpRSviAk0J9rZmTy1yW7yS+v77Om5J50g/8JeE9E/oFzNNG5wNWnemNjzHLgWB0Mc0/19Qea4EA/Jg+OYbJr20FjDK6KBPUtbdgdhic+L2CR3VnbSI4M4qHvTmB8RjQNtnb8LUJQgPd3dis1EC2cnMEDH+3h6ZWF/PqCkX1yz+MmB2PM+yJyI/ABUAmMN8bonppeTkTwc6XeMWlRvHLTDGztdnaUfTmUNjXKOfPyuVWF3Pf+LkalRjApM4aJg6KZOCha+y6U8hJxYVa+PSmdiD4c1tqTZqX/Ay4FZgFjgE9F5HZjzNvuDk71Lqu/H+PSoxiXHsXVM748PmlwDFfPyGRNQRWPf17Aw8v2EeAnbPn1PIIC/NhaUktIoJ/Ou1DKg35/4eg+vV9P0lAcMNkY0wx8ISLv4exz0OTQTxxJGODcAGlLSS37Kxs7mpl++9Z2Vu+vIjY0kAmDoskbFM3UrFjGur5HKdU3HA7DrkP1DE92//qnx11byRhzmysxHHl+wBhzlnvDUp4SFODHpMwYLs1L7zj2x2+O5k8Xjeb0oQnsPlTPn97dyf0f7O44/8hn+/hoxyFqmlo9EbJSA8Yjy/dx3j8+o6y2+fgXn6KeNCvF41y+YgTQMcTFGDPHjXEpL5KTEEZOQhjfmZwBOFegrW9xTpBvtLXzl/d20erq6M5NCGNYrD9XBcQwcVDfjclWaiA4c3gif3xnJ29vLuO6mVluvVdPVmV9BtgBDAZ+g3OdpTVujEl5uYTwILLjwwDn6rWbfnU2i6+fyk/nDSUlKpgP82s7NkovrWnmnle38PrGEg7pXhdKnZKs+DBGpUbw9pYyt9+rJ30OscaYR0XkNmPMUmCpiCx1d2DKdwQH+jE1K5apWbHcfIZzCZBBg53NUvsrG3lzYynPrioEIDM2xHVdDukxuhqtUifqzOGJPPDRHqobW4kOPeX5yMfUk5pDm+trmYicLyLjgeOvYa0GLL9OcyZm5MSx8Vdn8+YPT+MX5w8nJyGMd7aUEeDa/vD1jSXc+dImXt1QrLvoKdUDs4bEYwx8ll/p1vv0pObwexGJBG4HHgQigB+7NSrVr/hZhNFpkYxOi+S6mVk4HAaLxTkk9mBtC0tca0UBDE0MZ/bQeO4+d5gOm1WqG2PTovjfNZM7Jry6S08mwb3lelgLnOHWaNSAcCQxANwwO5vvz8xie1kdy/MrWb6nkvUHqjsSw5/f20mY1Z+ZuXGMTInEz6IJQw1sfhZh1pB4t9/HM7tIKNWJxSKMSo1kVGokN87O7thC1RjDqn2HWV9Yw33v7yIqJICZufFcMjGtT/44lPJWRVVNvLi2iCumZRIf7p6VDDQ5KK9zpNYgIrxy0wwq6m18nl/Jsj0VLNtdQW5CGLOGxNPU2s7TKw8wZ1gi2fE6e1sNHBUNNv7xcT4jUiI5Z1SSW+7Rk3kOfsYYu1vurlQPxIdbuXB8KheOT8XhMB1zKjYU1vDHd3byx3d2khkbwpnDEzl/TDLj0qM0Uah+bURyBAF+woaiarclh56MVsoXkftEZIRbIlDqBFi+MhLq87vm8LsFIxkUG8qTXxTwzYdWsM01x6Kptb2jiUqp/iQowI8hieHsLKt32z160qw0BlgIPCIiFuAxYLExps5tUSnVQ6lRwVwxLZMrpmVS29zG0t0VjExxrjvzmze2s2r/YS4cn8qleemkuFahVao/yI4PY31htdtevydrK9UbY/5rjJkO3An8CuechydFxPc3dlX9RmRwABeMTeloUpqeE0tqdDB//3APp/35Y65+fDWf7BpQu86qfiwrPpTqxlZaXTtC9rYe9TkA5+Pc4CcT+H84l9SYCbwDDHFLZEqdogXjUlkwLpWiqiZeWFvEC2uLWLqrgjOGJmCMobnNTkigjslQvunG2dncOif3qKHhvaknfxl7gE+A+4wxKzodf0lEZrklKqV6UXpMCLefPZTb5ubS3OYcW7FqfxU3PLWOq2dkcv2sLE0Syue4e+fGr21WctUanjDGXPuVxACAMeZWt0WmVC/z97MQHhQAQHRIIFMGx/D3D/cw569LeW1DiXZeK59S29TGHS9uYvke9yyj8bXJwTWEVWdFq35naFI4i76Xx8s/mEZChJUfPb+R21/c5OmwlOqxAH/hpXXFbCmpdcvr96QuvUJE/gk8DzQeOWiMWe+WiJTqQxMHxfDaTTN4ZPk+okPct8KlUr0tJNCfoAAL1W7aZKsnyWG66+tvOx0zgFs3+xGRc4AHAD/gEWPMve68nxq4LBbh+lnZzie//jXbr/8JQ5PCPRuUUj0QFOBHS5t75ij3ZOG9Pm9WcvV1/As4CygG1ojIG8aY7X0dixpgfvMb5tsmseiKPDK1IqG8nNXfgq3NQ0NZAUTkfGAkR28T+ttjf8cpmwzkG2P2ue6/GFgAaHJQbhcU4Mfdr2zhl3MSyNGZPMqL+Ylw0E37oPRknsN/gBCcHdOPABcDq90SzZdSgaJOz4uBKV+J63rgeoCUlBRsNhv5+fluDsv9+kM5fK0MMf/4BzEPPtjxfPvvzgVg487ryE/4mafC6hW+9rPoTn8oA7inHGLs+Nlb3PL/06M+B2PMGBHZbIz5jYj8P+CVXo/kaN3N6jhqnKExZhGwCCAvL89YrVZy+sHHvPz8fJ8vh8+V4R//cP4DECHzZ2/x92+PY1R4s2+Voxs+97PoRn8oA7inHA45QFx0pFv+f3qy8F6z62uTiKTg3DZ0cK9HcrRiIL3T8zSg1M33VAPc3ooGALLiQt220qVSvcnW7sDq757JcD1JDm+JSBRwH7AeKAAWuyWaL60BckVksIgE4lz47w0331MNYB9sP8TF/17BojMu5+ErJrp99qlSvaGlzY7Vvydv4yeuJ6OVfud6+LKIvAUEGWPcM+viy3u2i8gPgfdxDmV9zBizzZ33VANTWW0zf353J69tLGVkSgRnvfAfBseFejospY6rpc1OU6udqJAAt7x+T0crTce56J6/6znGmP+5JSIXY8w7OBf2U6rXVdTbePzz/Tz+eQF2Y/jhGTncMjfHbVV0pXpbZYMNwHPbhIrIU0A2sBE4MtvCAG5NDkq5w76KBh5dvp8X1xXTZncwf0wKd84bSnpMiKdDU+qEVNR7ODkAecAIo6uSKR/V0mbnva0HeW51Iav2VxHoZ+FbE1P5/swssuLDPB2eUiflYK1zfkNCeNBxrjw5PUkOW4EkoMwtESjlBg6HYV1hNW9uKuX1jaXUNreRERPCT+cN5ZKJaSREuOcPSqm+sq/SudTdoFj31Hp7khzigO0ishqwHTlojLnALREpdZKMMWwoquHtzWW8s6WMstoWrP4Wzh6ZxHcmpTM1K9ZtG6Mo1df2VTSSGGHtWIa+t/UkOfzaLXdWqhe02R2s2V/FhzvKeX/bQUpqmgn0szBrSDw/O2cYZ45IJMyqG/mo/mdvRQNZce5rFu3JUNalbru7UiehurGVT3eX8+GOcpbtqqDe1k6gn4UZObH8+KwhnDUikchg93yaUsobtNsd7DpYz7cnpR//4pN0zOQgIsuNMaeJSD1HL10hgDHGRLgtKqU6cTgM20rr+Cy/gk92lrPuQDUOA3FhVs4dncTc4YmclhNHqNYQ1ACx+1ADzW12xmdEue0ex/xrMsac5vqqC9urPldS08zyPRUs21PJivxKqpvaABieHMHNZ+Qwd3giY1IjtQ9BDUgbi2oAGJce5bZ79GSeQ0w3h+uNMW1uiEcNUPUtbazcV8VneypYvqeyYyRGQriVM4YlMDM3jhk5cW4btqeUL1lfWE1MaCAZbpyf05N6+Hqci+BV42xSigLKRKQc+L4xZp3bolP9VqOtnbUHqlm57zCr9h1mU3EtdochOMCPKVkxfHfqIGbmxpGbEIaI1g6UOsIYw/I9lUzNinHr30ZPksN7wKvGmPcBRORs4BzgBeAhvrLPglLdqW9pY21BNSv3H2blviq2ljiTgb9FGJsexY2zszgtJ54Jg6J0CQulvsae8gYO1rUwe0i8W+/ToxnSxpgbjzwxxiwRkT8aY34iIu6Zt618Xm1zG2sLqpw1g/3OZOAwEOAnjEuP4qbTs5kyOJYJg6IICdSOZKV6aumuCgBmeUFyqBKRn/HlMt3fBqpd+zy7Z/NS5XMq6m2sO1DFmoJqlu4oZW/VLoyBQH8L49Oj+OGcXKYOjmF8RjTBgVozUOpkfbjjEEMTw0mODHbrfXqSHC4DfgW8hrPPYbnrmB9wqdsiU17L4TDsrWhg7YFq1hZUs/ZAFQcONwHODc+Hxwdx29xcpmbFMi49SvdGUKqXHKprYXVBFbfNzXX7vXoyCa4SuOUYp31/Y1d1XC1tdjYX17L2QBXrCqpZV1hNjWtoaWxoIBMHRXP5lEFMzIxmVEokhQX7+sW2jkp5m7c2l2EMzB+T4vZ79WQoazxwJzAS6BhHaIyZ48a4lAcdbrCx9kA16w5Us7agiq0ldbTanS2I2fGhzBuRxMTMaCZlxpAZG6KjiZTqI29sKmVEcgQ5Ce5fTbgnzUrPAM8D84EbgSuBCncGpfqOw2HYf7iRdQXVrCmoYt2B6o45BoF+FkanRXL1jEzyMmOYOCiamNBAD0es1MC082Adm4pquOe8YX1yv54kh1hjzKMicptrnaWlIqLrLfmo2uY2NhbVsKGwmg2FNWwsqqG22dlEFBUSQN6gaC7JSycvM5rRqZHaX6CUl3h65QEC/S1cMtF96yl11pPkcGQmdJmInA+UAmnuC0n1FrvDsPtQPRsKXcmgqIb88gYARGBIQjjnjkpiXHoUeZnRZMWF6XIUSnmhBls7r64vYf7oZKL7qPbek+TwexGJBG4HHgQigB+7NSp1UiobbGwsrGFDkbNWsKmohsZW586u0SEBjM+I5sJxKYzPiGZMWqTb1oFXSvWuVzeU0Nhq5/Jpg/rsnj0ZrfSW62EtcIZ7w1E91druYOfBOtYfcNYINhTWUFjlHE7qZxFGJEfwrYlpjM+IYnx6NIO041gpn9Rud7Bo2V7Gpkcx3o0L7X1VT0YrDcY5lDWz8/WnshOciNwHfANoBfYCVxtjalzn7gauBezArUeW7Rjoymqbv2weKqxhS0kttnbnCKKEcCsTMqL57pQMxmc4+wp0oplS/cObm0spqmrml/NH9ukHvJ40K70GPAq8Se/NiP4AuNsY0y4ifwbuBn4mIiOAhTiHzaYAH4rIEGOMvZfu6xNa2uxsLal1JgNXE1GZazPxQH8Lo1MjuWLqIMZnRDM+I4rkyCCtFSjVDzkchoc+2cvQxHDmDkvo03v3JDm0GGP+0Zs3NcYs6fR0JXCx6/ECYLExxgbsF5F8YDLwRW/e35sYYyiqamZDUTXrD1TzxZ5D7KvaTbvDub9SekwwkzJjnM1DGdEMTw7XhemUGiDe3FzKnvIGHlg4rs8Hi/QkOTwgIr8ClgC2IweNMet7KYZrcM6jAEjFmSyOKHYd60JErgeuB0hJScFms5Gf7/0TtptaHeyqbGZHeQs7yp1fa1qcFaMgfyE31srFo6MZkRDM8PggokOO/IjsYKukqKDSc8H3kK/8LI6nP5RDy+A9TrQcrXYHf3qrgOxYK8NDm/r8/6AnyWE0cAUwhy+blYzr+TGJyIdAUjenfm6Med11zc+BdpwT7cC5dtNXmW6OYYxZBCwCyMvLM1ar1euWbHA4DPsqG1hfWNPRX7D7UD2uSgHZ8aGcOTK5o9N4SGIYBft9f+mJ/Px8ny8D9I9yaBm8x4mW49Hl+znY0Mb/Lp3MkFz3rsDanZ4kh28CWcaY1hN5YWPMmV93XkSuxDnreq4x5kgCKMa5sdARaTjnVfiEmqZW1wSzGtYXVrOxqIb6lnYAIoL8GZcRzbyRSYzPiGJcehRRITrbWCnVVXVjKw9+vIfTcuLcvjT3sfQkOWzCuftbeW/dVETOAX4GzDbGNHU69QbwrIjcj7NDOhdY3Vv37U3tdge7OiaYOTuO91U4l52wCAxJDGf+mBQmuPoKsuJCdYKZUqpH/vzeTupb2vnF/OEei6EnySER2Ckiazi6z+Gkh7IC/wSswAeuUTYrjTE3GmO2icgLwHaczU03e8tIpYp6W8cs4w2F1WwurqXJNcEsNjSQ8RnRfGuCc17BmLQowqy6gY1S6sStLahi8ZoibpiVxbCkCI/F0ZN3sF/19k2NMcdseDPG/AH4Qy/fj5++tJmx6VFMy4olOz70a4d+trY72F5W1zGnYH1hNcXVzQD4W4SRKRFcmpfe0VeQHhOsQ0mVUqesze7g569uJSUyiFv7YM+Gr9OTGdI+v8ieiFBY1cRL64oB56SxqVmxTM+OZWpWDAF+FjYW1bK+sJoNhdVsLa2j1TXBLDkyiPEZUVw1PZPxGVGMTNHF6JRS7vHvT/ey61A9D18xkVAPtz4c8+4iUk/3I4UEMMYYz9V3TsL49ChW768CoLzexhubSnlj09F93VZ/C2PSIp2JID2KcRlRbt+KTymlALYU1/KPj/bwjbEpzBvZ3UDPvnXM5GCMCe/LQNxtfEbUMc9lxYXy94XjGJ4cQYCfpe+CUkopnKsi/Oj5DcSFWfndgpGeDgfoWZ9DvzAuPbrLsYyYEDJiQ9hRWqeJQSnlMfe+u5O9FY08de1krxniPmDeDZMig0iO7NjllJm5cbzxwxl8b+ogDje2sjzf+2ceK6X6n/e2lvHEigKump7JTA9MdjuWAZMc4MumpRtmZfH4VZOICgnk9KEJRIUE8NqGEs8Gp5QacAoqG/npi86RlHf30fafPTVgmpUApmXFMm9kEgvGfblcU6C/hfNHJ/Py+mIabO06P0Ep1Sda2uz84Jn1+PkJ/7psvNctqDmgag6XTx10VGI44sLxqbS0OViy7aAHolJKDTQOh+H2Fzex82Adf/v2ONKiQzwdUhcDKjkca6LaxIxo0qKDeVWblpRSfeDvH+3h7c1l3HXOMM4Y2rf7NPTUgEoOx2KxCBeOS+Xz/ErK61o8HY5Sqh97bUMJ//hoD5fmpXH9rCxPh3NMmhxcLhyfgsPQZWKcUkr1li/2HubOlzczZXAMv79wtFcvu6PJwSUnIZzRqZG8tlGblpRSvW9XRQvf/99aBsWE8J/LJxLo791vv94dXR+7cHwqW0vq2HOo3tOhKKX6kfzyeu5+r5jI4ACeunYK0aHeMdHt62hy6OQbY5Ox+lu4+dn1lNQ0ezocpVQ/UFzdxBWPrsbPAs9cN4WkTpNxvZkmh04SwoN4/OpJlNW0cNFDn7O9tM7TISmlfFhRVRPf+e9KGmzt3HtOGplxoZ4Oqcc0OXzF9Ow4XvzBNATh0oe/4HNdVkMpdRIKDzexcNFKapvaePraKWTH+kaN4QhNDt0YlhTBqzdPJzUqmCsfW82rG4o9HZJSyocUVDaycNEXNLa28+z3pzI2PcrTIZ0wTQ7HkBwZzIs/mMakzBh+/Pwm/vVJPsZ0t72FUkp9aVtpLZc8/AXNbXaevW4qo1IjPR3SSdHk8DUiggJ44ppJLBiXwn3v7+L/Xt+K3aEJQinVvRV7K1n48Er8LcILN0xjRIpP7Yl2FF1l7jis/n787dJxJEcG85+lezlYa+PB74wnONC7FslSSnnW25vL+PHzG8mMC+HJayb7/C6SHq05iMgdImJEJK7TsbtFJF9EdonIPE/Gd4TFItx17jB+u2AkH+08xHf+u5LDDTZPh6WU8gLGGB75bB8/fG49Y9IiefGG6T6fGMCDyUFE0oGzgMJOx0YAC4GRwDnAQyLiNR/Rvzctk/9cPpEdZXV8698rOHC40dMhKaU8yNZu56cvbeb3b+/gnJFJPH3dFCJDAjwdVq/wZM3hb8CdQOdG/AXAYmOMzRizH8gHJnsiuGOZNzKJZ78/ldrmNi56aAUbi2o8HZJSygMqG2xc9t9VvLSumNvm5vKvyyYQFOA1n2VPmXhiBI6IXADMNcbcJiIFQJ4xplJE/gmsNMY87bruUeBdY8xL3bzG9cD1ACkpKROXLFmC1WrtszIU17Zy13vF1DS384s5KUzNCOuV17XZbH1aDnfoD2WA/lEOLYN77Kls4VcfllDbbOfO2cnMzgo/7vd4Yzlyc3PXGWPyujvntg5pEfkQSOrm1M+Be4Czu/u2bo51m72MMYuARQB5eXnGarWSk5NzktGeuBzgzSHZXPvkGn75QQm/v3A0l03JOOXXzc/P79NyuEN/KAP0j3JoGXqXMYZnVhXy27f2EBsayMs39XyoqjeVoyfclhyMMWd2d1xERgODgU2u5WrTgPUiMhkoBtI7XZ4GeO0a2vHhVp77/lR++Ox67nl1C2W1zfzkrCFevQyvUurkNNjaueeVLbyxqZTZQ+K5/9KxxIZ5V02gN/V5n4MxZosxJsEYk2mMycSZECYYYw4CbwALRcQqIoOBXGB1X8d4IkKt/vz3e3ksnJTOgx/nc8eLm2mzOzwdllKqF+0oq+OCB5fz1uZSfjpvKI9fNalfJwbwsnkOxphtIvICsB1oB242xtg9HNZx+ftZ+NNFo0mJCub+D3ZTXt/CQ9+dQHhQ/xi1oNRA5XAYnlp5gD++s4PI4ACe/f5UpmbFejqsPuHx5OCqPXR+/gfgD56J5uSJCLfOzSUpMoi7X9nCtx9eyeNXTyIxwrcW21JKOR2sbeGnL23isz2VnD40nr9eMpa4fl5b6EyXz+hll+al89hVkzhwuJGLHlpBfrluHKSUr3ljUyln/20pawuq+cM3R/H4VZMGVGIATQ5uMXtIPM/fMI1Wu4OLHlrB6xtLdNE+pXzAoboWbnhqLbc+t4HshDDevW0m350yaEAOMtHk4CajUiN55QfTGRwXym2LN3LZf1dpLUIpL+VwGJ5dVciZ/28pn+6q4K5zh/HiDdN8anOe3ubxPof+LD0mhFdumsFzqwv5y3s7OfeBz7huZha3zMkhJFD/65XyBvnlDdzz6hZW769ienYsf/zm6AGdFI7Qdyg387MIl08dxDmjkrj33Z38+9O9vLGxlF9+YwRnj0gckNVVpbxBg62dBz/aw2Of7yc4wI+/fGsMl+Sl6d+kizYr9ZG4MCt/vWQsL944jfAgf254ah3XPLGGwsNNng5NqX6rvK6lyzFjDK9vLGHOXz/l4WX7uHBcKh/dfjqXTkrXxNCJJoc+NikzhjdvOY1fnD+c1furOPNvS3ngwz20tHn9dA6lfMpbm0v5/lPrjhoMsrWklm8/vJLbFm8kKTKIV2+azn2XjCU+fGCNROoJbVbygAA/C9fNzGL+mBR+//Z2/vbhbl7dUMyvLxhJWv9Z1FEpj8kvr+fOlzbT1GpneX4lmbGh/HXJLl7fWEpMaCD3XjSaS/PSsVi0pnAsmhw8KCkyiH9eNoGFkyr55etbuerxNczMDOPPcamkRPn+ZiFKeUKDrZ0bnlpHU6uzNv7TFzdR1diGxQI3nZ7NjadnE6GrFxyXNit5gdNy43j3RzP56byhrC5q5Mz7l/Lw0r3Y2rWpSakTYYzhZy9vZm/FlxtxHayzMTM3jk/vOIM7zxmmiaGHNDl4Cau/HzefkcOjFw9menYcf3p3J7P/8imPLt9PU2u7p8NTyif865N83t5c1uW4wxiSInUpmxOhycHLJIUH8MiVeTx97RQGxYbwu7e2c9qfP+Ffn+RT19Lm6fCU8kq1zW3c+dIm/rpkd7fnP9lVwbbS2j6Oyrdpn4OXOi03jtNy41hbUMU/P8nnvvd38Z+le7lyWibXnDaYmNBAT4eolMdV1Nv43xcFPLGigPqWduLCAsmICSEk0J82uwO7w9DuMLQ7HLy/9SAjU3q2MY/S5OD18jJjeOLqyWwtqeWhT/P516f5PLp8P5dNyeD6WVm66qsakHYfqufRz/bz6sYS2uwO5o1I4pa5Ofrm34s0OfiIUamRPPTdieSX1/PQp3t5YkUBT31xgIvz0vjB7GzSY0I8HaJSbmWMYXl+JY98tp+luysICrBwaV4a18wYTFZ87+zhrr6kycHH5CSEc/+l4/jxmUP4z9K9vLi2mOfXFLFgbArXnDa4x/vZKuUrmtscPLe6kCdXFLDzYD3x4VbuOHsI350yiGhtXnUbTQ4+Kj0mhD98czS3zs3lv8v28cyqQl7ZUMKo1AgWTspgwbgU3YlO+bStJbU8u7qQV9cX0dxmGJYUzn0Xj+GCcSlY/XW2qLtpcvBxiRFB/GL+CG6Zk8trG0t4bnUhv3htK394ewfzxySzcHIGEzKidM0Y5RMabO28uamU51YXsrm4lqAAC7Mzw7n+zFH6e9zHNDn0E5EhAVw5PZPvTRvEpuJaFq8u5I1Npby4rpghiWEsnJTBRRNSiQrRarjyLg6HYV1hNa+sL+aNjaU0ttoZmhjOby4YyYXjU6koOUDOoGhPhzngaHLoZ0SEcelRjEuP4hfzR/DmplIWry7kt29t5973dnLuqCQWTspgyuAYXVdGeVR+eQOvbSjhtY0lFFc3Exzgx3mjk7lsytG13QoPxzlQaXLox8Ks/nxncgbfmZzB9tI6Fq8p5NUNJby+sZTkyCDOGZXE+aOTmZARrYlC9Ymiqibe3VrGW5vL2Fxci0XgtNx4bj97CGePSCLUqm9J3sJjPwkRuQX4IdAOvG2MudN1/G7gWsAO3GqMed9TMfYnI1Ii+O2CUdx97nDe33aQt7eU8cyqQh7/vIDECCvnjkrm3FFJ5GXG4KeJQvWigspG3tlaxrtbDrKlxDlLeXRqJL84fzgXjE0hQefqeCWPJAcROQNYAIwxxthEJMF1fASwEBgJpAAfisgQY4yuQNdLggP9uHB8KheOT6W+pY2Pd5bzzpYynltdyBMrCogPt3LOyCTOG53M5MGaKNSJczgMm0tq+XjHIT7YUc6OsjoAxqVHcc95wzh3VLLOy/EBnqo5/AC41xhjAzDGlLuOLwAWu47vF5F8YDLwhWfC7N/CgwJYMC6VBeNSabS18/HOct7dWsaL64p4auUB4sICmT0kgVlD4piZG69LdqhjarC1s3xPBR/tKOeTXeVUNrRiEZg4KJpfnD+cc0cnk6rL0PsU6bxLUp/dVGQj8DpwDtAC3GGMWSMi/wRWGmOedl33KPCuMealbl7jeuB6gJSUlIlLlizBavX93ZxsNpvHy9Hc5mBNcSOf7a9nbUkj9TYHAuTGBZGXFsKktFCGJwTjf4xahTeUoTf0h3K4qwx2h2FXZQvrS5pYX9LI9vJm2h0QFmhhUnooU9PDmJQWSkTQqc9H6A8/B/DOcuTm5q4zxuR1d85tNQcR+RBI6ubUz133jQamApOAF0QkC+ju3abb7GWMWQQsAsjLyzNWq5WcnJzeCN2j8vPzvaIco4fDNTjfBLaU1LJsdwXLdlfw/OZqnt1YRbjVn2nZscwaEs+MnDgyY0M6Rpd4SxlOVX8oR2+VweEw7K1oYMXewyzPr2Tl3sPU29oRgZEpEVx7WjJnDEtg4qBoAvx6d7Hn/vBzAN8rh9uSgzHmzGOdE5EfAK8YZ7VltYg4gDigGEjvdGkaUOquGNXx+Vm+HBp769xcapvb+GJvJUt3V7JsdwVLth8CIC4skImDopmUGUOiXzMZmQ4C/XVFeF/V0mZnS0ktawuqWVtQxbrCamqanEvGZ8SEMH9sCqflxDEtO1abG/spT/U5vAbMAT4VkSFAIFAJvAE8KyL34+yQzgVWeyhG1Y3I4ADOGZXMOaOSMcawr7KRVfuqWHugirUF1by/zZksgt4tZmxaFHmZ0eRlxjA2LUrfRLyUMYaiqmY2l9SwpbiWdQeq2VxcS6vdAUBWfCjzRiQxMTOaaVmx2pk8QHgqOTwGPCYiW4FW4EpXLWKbiLwAbMc5xPVmHankvUSE7PgwsuPDuGxKBgDldS28tWoHxS1W1h6o4j9L92H/ZC8AKZFBjEiJZFRqBCNdX5MignRJhD5kjKGwqoktJbVsKalla0ktW0vqqG121goC/ITRqZFcPSOTiYOimTgomtgw72onV33DI8nBGNMKXH6Mc38A/tC3EanekhARxKzB4R1tq422djYV1bC11PkmtLW0lo92HuLIOIiY0EBGpkQwPDmC7PhQchLCyIkPJzJEFw08FcYYSmtbWF3UwCel+9hTXs+e8gbyDzVQb3NuOxvoZ2FoUjjnjU5mTFoko1MjGZIYrs2BCtAZ0srNQq3+TM+JY3pOXMexRls7O8rq2FZaxzZX0nhiRQGt7Y6Oa+LCAp21koQwcuLDyIwLIS06hLToYEIC9dcWnJ3EFQ02iqqaKKxqoqiqmcKqJvLL68kvb6Cx9Uilu4S4MCtDEsO4aEIqw5IjNBGo49K/MtXnQq3+5GXGkJcZ03HM7jAUVTWxt6KB/PKGjq9vbSqlrqX9qO+PCwskNTqE9OjgjoSREG4lISKIhHArcWFWn3/TczgMNc1tVNTbnP8aWiivs1FU7UwCRdVNFFc3H5VQARIjrOQkhHFJXjq5iWGEtNVy+vhhuu+BOmGaHJRX8LMImXGhZMaFMnd4YsdxYwyVDa0UVjVR7HpDLHa9QW4tqeX9bQdps3cd7RwTGkhCuJX4cCuRwQFEhQQQFRxIVEgAkcEBRIcEEh7kT6jVn+BAP0ID/Qmx+hES4Id/Lw3FNMbQ1Gqn0dZOva2dhpb2ox432Jz/6lvaqWo8kgRsVNa3Utlgo93RtVwRQf5kxIYwNDGcM4cnOhNkTAjpriQZFHD0vIL8/HxNDOqkaHJQXk1EiHe9yU/sZtlmu8NQ2WCjvM5GeX0L5fVHP65ssFFS3UxNcxs1Ta10837bRaCfhUB/C4KDoMACAixCgL8Ff4t0dJ53njxqgHa7oc3uoM3uoLXdQZvdYGu39+h+AX5CTGigs5xhVkYkRxAXZu0od3ynx7qBk+ormhyUT/OzCIkRQSRGBAFfv0Wqw2Got7VT29RGTXMrdc3tNLW209Rqd/1zPm5sbafdbqisqiYkLKLjTb/9qzWUToOsAv0sBPgJAX4WAlzJxepvIczqT1iQv/PrkX9BRz/WXc2UN9LkoAYMi0WIDHY2K2Vw/LH6vjajVane5Nu9dkoppdxCk4NSSqkuNDkopZTqQpODUkqpLjQ5KKWU6kKTg1JKqS40OSillOpCk4NSSqkuPLKHdG8TkQqgEeeGQb4uDt8vR38oA/SPcmgZvIc3lmOQMSa+uxP9IjkAiMjaY22U7Uv6Qzn6Qxmgf5RDy+A9fK0c2qyklFKqC00OSimluuhPyWGRpwPoJf2hHP2hDNA/yqFl8B4+VY5+0+eglFKq9/SnmoNSSqleoslBKaVUF/0iOYjILSKyS0S2ichfOh2/W0TyXefmeTLGnhCRO0TEiEhcp2M+UwYRuU9EdorIZhF5VUSiOp3zpXKc44ozX0Tu8nQ8PSEi6SLyiYjscP0d3OY6HiMiH4jIHtfXrnutehkR8RORDSLyluu5L5YhSkRecv097BCRab5WDp9PDiJyBrAAGGOMGQn81XV8BLAQGAmcAzwkIl67H6OIpANnAYWdjvlUGYAPgFHGmDHAbuBu8K1yuOL6F3AuMAL4jit+b9cO3G6MGQ5MBW52xX0X8JExJhf4yPXc290G7Oj03BfL8ADwnjFmGDAWZ3l8qhw+nxyAHwD3GmNsAMaYctfxBcBiY4zNGLMfyAcmeyjGnvgbcCfO/eqP8KkyGGOWGGPaXU9XAmmux75UjslAvjFmnzGmFViMM36vZowpM8asdz2ux/lmlIoz9iddlz0JXOiRAHtIRNKA84FHOh32tTJEALOARwGMMa3GmBp8rBz9ITkMAWaKyCoRWSoik1zHU4GiTtcVu455HRG5ACgxxmz6yimfKUM3rgHedT32pXL4UqzdEpFMYDywCkg0xpSBM4EACR4MrSf+jvNDkqPTMV8rQxZQATzuah57RERC8bFy+Hs6gJ4QkQ+BpG5O/RxnGaJxVqUnAS+ISBYg3VzvsXG7xynDPcDZ3X1bN8c8Ovb468phjHnddc3PcTZzPHPk27q53lvHUPtSrF2ISBjwMvAjY0ydSHfF8U4iMh8oN8asE5HTPRzOqfAHJgC3GGNWicgDeHkTUnd8IjkYY8481jkR+QHwinFO2FgtIg6cC1wVA+mdLk0DSt0a6Nc4VhlEZDQwGNjk+kNOA9aLyGS8rAzw9T8LABG5EpgPzDVfTqLxunJ8DV+K9SgiEoAzMTxjjHnFdfiQiCQbY8pEJBkoP/YreNwM4AIROQ8IAiJE5Gl8qwzg/B0qNsascj1/CWdy8Kly9IdmpdeAOQAiMgQIxLny4RvAQhGxishgIBdY7akgj8UYs8UYk2CMyTTGZOL8xZpgjDmIj5ThCBE5B/gZcIExpqnTKV8qxxogV0QGi0ggzo70Nzwc03GJ85PFo8AOY8z9nU69AVzpenwl8Hpfx9ZTxpi7jTFprr+DhcDHxpjL8aEyALj+dotEZKjr0FxgOz5WDp+oORzHY8BjIrIVaAWudH1i3SYiL+D8obQDNxtj7B6M84QZY3ytDP8ErMAHrlrQSmPMjb5UDmNMu4j8EHgf8AMeM8Zs83BYPTEDuALYIiIbXcfuAe7F2dR6Lc6RcJd4JrxT4otluAV4xvUBYx9wNc4P4z5TDl0+QymlVBf9oVlJKaVUL9PkoJRSqgtNDkoppbrQ5KCUUqoLTQ5KKaW60OSglIuI2EVko4hsFZE3O68qe4Kvc5WI/LMX4rnAV1aFVf2PJgelvtRsjBlnjBkFVAE3ezIYY8wbxph7PRmDGrg0OSjVvS9wLbgnItki8p6IrBORz0RkmOv4N1wLPm4QkQ9FJPHrXlBEJovICtf1K47MoBWRn4jIY67Ho101l5DONRARucR1fJOILHNryZVCk4NSXbj2dJjLl8tmLMK5iNpE4A7gIdfx5cBUY8x4nEt733mcl94JzHJd/0vgj67jfwdyROSbwOPADV9ZfgTX9fOMMWOBC062bEr1VH9YPkOp3hLsWnoiE1iHcxmQMGA68GKnFU6trq9pwPOuRdQCgf3Hef1I4EkRycW50msAgDHGISJXAZuBh40xn3fzvZ8DT7iWIXmlm/NK9SqtOSj1pWZjzDhgEM43+5tx/o3UuPoijvwb7rr+QeCfxpjRwA04VxL9Or8DPnH1aXzjK9fnAg1ASnffaIy5EfgFzhVjN4pI7MkUUKme0uSg1FcYY2qBW3E2ITUD+0XkEnCufioiY12XRgIlrsdXdnmhrjpff9WRgyISiXNbyVlArIhc/NVvFJFsY8wqY8wvca46nP7Va5TqTZoclOqGMWYDsAnn0tHfBa4VkU3ANr7cNvTXOJubPsP5hn08fwH+JCKf41zx9Yi/AQ8ZY3YD1wL3ishXdwm7T0S2uFYfXuaKTSm30VVZlVJKdaE1B6WUUl1oclBKKdWFJgellFJdaHJQSinVhSYHpZRSXWhyUEop1YUmB6WUUl38fzWOE7ymMk/WAAAAAElFTkSuQmCC\n", "text/plain": [ "
" ] @@ -510,12 +509,12 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 20, "metadata": {}, "outputs": [ { "data": { - "image/png": "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\n", + "image/png": "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\n", "text/plain": [ "
" ] @@ -547,7 +546,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.7" + "version": "3.9.1" } }, "nbformat": 4, diff --git a/examples/pvtol-nested.py b/examples/pvtol-nested.py index 7efce9ccd..7b48d2bb5 100644 --- a/examples/pvtol-nested.py +++ b/examples/pvtol-nested.py @@ -133,17 +133,15 @@ plt.figure(7) plt.clf() nyquist(L, (0.0001, 1000)) -plt.axis([-700, 5300, -3000, 3000]) # Add a box in the region we are going to expand -plt.plot([-400, -400, 200, 200, -400], [-100, 100, 100, -100, -100], 'r-') +plt.plot([-2, -2, 1, 1, -2], [-4, 4, 4, -4, -4], 'r-') # Expanded region plt.figure(8) plt.clf() -plt.subplot(231) nyquist(L) -plt.axis([-10, 5, -20, 20]) +plt.axis([-2, 1, -4, 4]) # set up the color color = 'b' diff --git a/examples/robust_mimo.py b/examples/robust_mimo.py index d4e1335e6..d790b4053 100644 --- a/examples/robust_mimo.py +++ b/examples/robust_mimo.py @@ -43,7 +43,7 @@ def triv_sigma(g, w): g - LTI object, order n w - frequencies, length m s - (m,n) array of singular values of g(1j*w)""" - m, p, _ = g.freqresp(w) + m, p, _ = g.frequency_response(w) sjw = (m*np.exp(1j*p)).transpose(2, 0, 1) sv = np.linalg.svd(sjw, compute_uv=False) return sv @@ -54,11 +54,8 @@ def analysis(): g = plant() t = np.linspace(0, 10, 101) - _, yu1 = step_response(g, t, input=0) - _, yu2 = step_response(g, t, input=1) - - yu1 = yu1 - yu2 = yu2 + _, yu1 = step_response(g, t, input=0, squeeze=True) + _, yu2 = step_response(g, t, input=1, squeeze=True) # linear system, so scale and sum previous results to get the # [1,-1] response @@ -112,8 +109,8 @@ def synth(wb1, wb2): def step_opposite(g, t): """reponse to step of [-1,1]""" - _, yu1 = step_response(g, t, input=0) - _, yu2 = step_response(g, t, input=1) + _, yu1 = step_response(g, t, input=0, squeeze=True) + _, yu2 = step_response(g, t, input=1, squeeze=True) return yu1 - yu2 diff --git a/examples/robust_siso.py b/examples/robust_siso.py index 87fcdb707..17ce10927 100644 --- a/examples/robust_siso.py +++ b/examples/robust_siso.py @@ -50,10 +50,10 @@ # frequency response omega = np.logspace(-2, 2, 101) -ws1mag, _, _ = ws1.freqresp(omega) -s1mag, _, _ = s1.freqresp(omega) -ws2mag, _, _ = ws2.freqresp(omega) -s2mag, _, _ = s2.freqresp(omega) +ws1mag, _, _ = ws1.frequency_response(omega) +s1mag, _, _ = s1.frequency_response(omega) +ws2mag, _, _ = ws2.frequency_response(omega) +s2mag, _, _ = s2.frequency_response(omega) plt.figure(1) # text uses log-scaled absolute, but dB are probably more familiar to most control engineers diff --git a/examples/run_examples.sh b/examples/run_examples.sh index 6f04fe12c..48d481aef 100755 --- a/examples/run_examples.sh +++ b/examples/run_examples.sh @@ -18,6 +18,10 @@ for example in *.py; do fi done +# Get rid of the output files +rm *.log + +# List any files that generated errors if [ -n "${example_errors}" ]; then echo These examples had errors: echo "${example_errors}" diff --git a/examples/run_notebooks.sh b/examples/run_notebooks.sh new file mode 100755 index 000000000..55d9e563b --- /dev/null +++ b/examples/run_notebooks.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# run_notbooks.sh - run Jupyter notebooks +# RMM, 26 Jan 2021 +# +# This script runs all of the Jupyter notebooks to make sure that there +# are no errors. + +# Keep track of files that generate errors +notebook_errors="" + +# Go through each Jupyter notebook +for example in *.ipynb; do + echo "Running ${example}" + if ! jupyter nbconvert --to notebook --execute ${example}; then + notebook_errors="${notebook_errors} ${example}" + fi +done + +# Get rid of the output files +rm *.nbconvert.ipynb + +# List any files that generated errors +if [ -n "${notebook_errors}" ]; then + echo These examples had errors: + echo "${notebook_errors}" + exit 1 +else + echo All examples ran successfully +fi diff --git a/examples/secord-matlab.py b/examples/secord-matlab.py index 25bf1ff79..6cef881c1 100644 --- a/examples/secord-matlab.py +++ b/examples/secord-matlab.py @@ -24,12 +24,12 @@ # Bode plot for the system plt.figure(2) -mag, phase, om = bode(sys, logspace(-2, 2), Plot=True) +mag, phase, om = bode(sys, logspace(-2, 2), plot=True) plt.show(block=False) # Nyquist plot for the system plt.figure(3) -nyquist(sys, logspace(-2, 2)) +nyquist(sys) plt.show(block=False) # Root lcous plot for the system diff --git a/examples/steering-gainsched.py b/examples/steering-gainsched.py index 8f541ead8..7db2d9a73 100644 --- a/examples/steering-gainsched.py +++ b/examples/steering-gainsched.py @@ -143,13 +143,13 @@ def trajgen_output(t, x, u, params): # Interconnections between subsystems connections=( - ('controller.ex', 'trajgen.xd', '-vehicle.x'), - ('controller.ey', 'trajgen.yd', '-vehicle.y'), - ('controller.etheta', 'trajgen.thetad', '-vehicle.theta'), - ('controller.vd', 'trajgen.vd'), - ('controller.phid', 'trajgen.phid'), - ('vehicle.v', 'controller.v'), - ('vehicle.phi', 'controller.phi') + ['controller.ex', 'trajgen.xd', '-vehicle.x'], + ['controller.ey', 'trajgen.yd', '-vehicle.y'], + ['controller.etheta', 'trajgen.thetad', '-vehicle.theta'], + ['controller.vd', 'trajgen.vd'], + ['controller.phid', 'trajgen.phid'], + ['vehicle.v', 'controller.v'], + ['vehicle.phi', 'controller.phi'] ), # System inputs diff --git a/examples/steering-optimal.py b/examples/steering-optimal.py new file mode 100644 index 000000000..5661e0f38 --- /dev/null +++ b/examples/steering-optimal.py @@ -0,0 +1,256 @@ +# steering-optimal.py - optimal control for vehicle steering +# RMM, 18 Feb 2021 +# +# This file works through an optimal control example for the vehicle +# steering system. It is intended to demonstrate the functionality for +# optimal control module (control.optimal) in the python-control package. + +import numpy as np +import math +import control as ct +import control.optimal as opt +import matplotlib.pyplot as plt +import logging +import time +import os + +# +# Vehicle steering dynamics +# +# The vehicle dynamics are given by a simple bicycle model. We take the state +# of the system as (x, y, theta) where (x, y) is the position of the vehicle +# in the plane and theta is the angle of the vehicle with respect to +# horizontal. The vehicle input is given by (v, phi) where v is the forward +# velocity of the vehicle and phi is the angle of the steering wheel. The +# model includes saturation of the vehicle steering angle. +# +# System state: x, y, theta +# System input: v, phi +# System output: x, y +# System parameters: wheelbase, maxsteer +# +def vehicle_update(t, x, u, params): + # Get the parameters for the model + l = params.get('wheelbase', 3.) # vehicle wheelbase + phimax = params.get('maxsteer', 0.5) # max steering angle (rad) + + # Saturate the steering input (use min/max instead of clip for speed) + phi = max(-phimax, min(u[1], phimax)) + + # Return the derivative of the state + return np.array([ + math.cos(x[2]) * u[0], # xdot = cos(theta) v + math.sin(x[2]) * u[0], # ydot = sin(theta) v + (u[0] / l) * math.tan(phi) # thdot = v/l tan(phi) + ]) + + +def vehicle_output(t, x, u, params): + return x # return x, y, theta (full state) + +# Define the vehicle steering dynamics as an input/output system +vehicle = ct.NonlinearIOSystem( + vehicle_update, vehicle_output, states=3, name='vehicle', + inputs=('v', 'phi'), + outputs=('x', 'y', 'theta')) + +# +# Utility function to plot the results +# +def plot_results(t, y, u, figure=None, yf=None): + plt.figure(figure) + + # Plot the xy trajectory + plt.subplot(3, 1, 1) + plt.plot(y[0], y[1]) + plt.xlabel("x [m]") + plt.ylabel("y [m]") + if yf: + plt.plot(yf[0], yf[1], 'ro') + + # Plot the inputs as a function of time + plt.subplot(3, 1, 2) + plt.plot(t, u[0]) + plt.xlabel("t [sec]") + plt.ylabel("velocity [m/s]") + + plt.subplot(3, 1, 3) + plt.plot(t, u[1]) + plt.xlabel("t [sec]") + plt.ylabel("steering [rad/s]") + + plt.suptitle("Lane change manuever") + plt.tight_layout() + plt.show(block=False) + +# +# Optimal control problem +# +# Perform a "lane change" manuever over the course of 10 seconds. +# + +# Initial and final conditions +x0 = [0., -2., 0.]; u0 = [10., 0.] +xf = [100., 2., 0.]; uf = [10., 0.] +Tf = 10 + +# +# Approach 1: standard quadratic cost +# +# We can set up the optimal control problem as trying to minimize the +# distance form the desired final point while at the same time as not +# exerting too much control effort to achieve our goal. +# +print("Approach 1: standard quadratic cost") + +# Set up the cost functions +Q = np.diag([.1, 10, .1]) # keep lateral error low +R = np.diag([.1, 1]) # minimize applied inputs +quad_cost = opt.quadratic_cost(vehicle, Q, R, x0=xf, u0=uf) + +# Define the time horizon (and spacing) for the optimization +horizon = np.linspace(0, Tf, 10, endpoint=True) + +# Provide an intial guess (will be extended to entire horizon) +bend_left = [10, 0.01] # slight left veer + +# Turn on debug level logging so that we can see what the optimizer is doing +logging.basicConfig( + level=logging.DEBUG, filename="steering-integral_cost.log", + filemode='w', force=True) + +# Compute the optimal control, setting step size for gradient calculation (eps) +start_time = time.process_time() +result1 = opt.solve_ocp( + vehicle, horizon, x0, quad_cost, initial_guess=bend_left, log=True, + minimize_method='trust-constr', + minimize_options={'finite_diff_rel_step': 0.01}, +) +print("* Total time = %5g seconds\n" % (time.process_time() - start_time)) + +# If we are running CI tests, make sure we succeeded +if 'PYCONTROL_TEST_EXAMPLES' in os.environ: + assert result1.success + +# Extract and plot the results (+ state trajectory) +t1, u1 = result1.time, result1.inputs +t1, y1 = ct.input_output_response(vehicle, horizon, u1, x0) +plot_results(t1, y1, u1, figure=1, yf=xf[0:2]) + +# +# Approach 2: input cost, input constraints, terminal cost +# +# The previous solution integrates the position error for the entire +# horizon, and so the car changes lanes very quickly (at the cost of larger +# inputs). Instead, we can penalize the final state and impose a higher +# cost on the inputs, resuling in a more graduate lane change. +# +# We also set the solver explicitly. +# +print("Approach 2: input cost and constraints plus terminal cost") + +# Add input constraint, input cost, terminal cost +constraints = [ opt.input_range_constraint(vehicle, [8, -0.1], [12, 0.1]) ] +traj_cost = opt.quadratic_cost(vehicle, None, np.diag([0.1, 1]), u0=uf) +term_cost = opt.quadratic_cost(vehicle, np.diag([1, 10, 10]), None, x0=xf) + +# Change logging to keep less information +logging.basicConfig( + level=logging.INFO, filename="./steering-terminal_cost.log", + filemode='w', force=True) + +# Compute the optimal control +start_time = time.process_time() +result2 = opt.solve_ocp( + vehicle, horizon, x0, traj_cost, constraints, terminal_cost=term_cost, + initial_guess=bend_left, log=True, + minimize_method='SLSQP', minimize_options={'eps': 0.01}) +print("* Total time = %5g seconds\n" % (time.process_time() - start_time)) + +# If we are running CI tests, make sure we succeeded +if 'PYCONTROL_TEST_EXAMPLES' in os.environ: + assert result2.success + +# Extract and plot the results (+ state trajectory) +t2, u2 = result2.time, result2.inputs +t2, y2 = ct.input_output_response(vehicle, horizon, u2, x0) +plot_results(t2, y2, u2, figure=2, yf=xf[0:2]) + +# +# Approach 3: terminal constraints +# +# We can also remove the cost function on the state and replace it +# with a terminal *constraint* on the state. If a solution is found, +# it guarantees we get to exactly the final state. +# +print("Approach 3: terminal constraints") + +# Input cost and terminal constraints +R = np.diag([1, 1]) # minimize applied inputs +cost3 = opt.quadratic_cost(vehicle, np.zeros((3,3)), R, u0=uf) +constraints = [ + opt.input_range_constraint(vehicle, [8, -0.1], [12, 0.1]) ] +terminal = [ opt.state_range_constraint(vehicle, xf, xf) ] + +# Reset logging to its default values +logging.basicConfig( + level=logging.DEBUG, filename="./steering-terminal_constraint.log", + filemode='w', force=True) + +# Compute the optimal control +start_time = time.process_time() +result3 = opt.solve_ocp( + vehicle, horizon, x0, cost3, constraints, + terminal_constraints=terminal, initial_guess=bend_left, log=False, + solve_ivp_kwargs={'atol': 1e-3, 'rtol': 1e-2}, + minimize_method='trust-constr', +) +print("* Total time = %5g seconds\n" % (time.process_time() - start_time)) + +# If we are running CI tests, make sure we succeeded +if 'PYCONTROL_TEST_EXAMPLES' in os.environ: + assert result3.success + +# Extract and plot the results (+ state trajectory) +t3, u3 = result3.time, result3.inputs +t3, y3 = ct.input_output_response(vehicle, horizon, u3, x0) +plot_results(t3, y3, u3, figure=3, yf=xf[0:2]) + +# +# Approach 4: terminal constraints w/ basis functions +# +# As a final example, we can use a basis function to reduce the size +# of the problem and get faster answers with more temporal resolution. +# Here we parameterize the input by a set of 4 Bezier curves but solve +# for a much more time resolved set of inputs. + +print("Approach 4: Bezier basis") +import control.flatsys as flat + +# Compute the optimal control +start_time = time.process_time() +result4 = opt.solve_ocp( + vehicle, horizon, x0, quad_cost, + constraints, + terminal_constraints=terminal, + initial_guess=bend_left, + basis=flat.BezierFamily(4, T=Tf), + # solve_ivp_kwargs={'method': 'RK45', 'atol': 1e-2, 'rtol': 1e-2}, + solve_ivp_kwargs={'atol': 1e-3, 'rtol': 1e-2}, + minimize_method='trust-constr', minimize_options={'disp': True}, + log=False +) +print("* Total time = %5g seconds\n" % (time.process_time() - start_time)) + +# If we are running CI tests, make sure we succeeded +if 'PYCONTROL_TEST_EXAMPLES' in os.environ: + assert result4.success + +# Extract and plot the results (+ state trajectory) +t4, u4 = result4.time, result4.inputs +t4, y4 = ct.input_output_response(vehicle, horizon, u4, x0) +plot_results(t4, y4, u4, figure=4, yf=xf[0:2]) + +# If we are not running CI tests, display the results +if 'PYCONTROL_TEST_EXAMPLES' not in os.environ: + plt.show() diff --git a/examples/steering.ipynb b/examples/steering.ipynb index c0d277f43..217e3b2db 100644 --- a/examples/steering.ipynb +++ b/examples/steering.ipynb @@ -5,23 +5,12 @@ "metadata": {}, "source": [ "# Vehicle steering\n", - "Karl J. Astrom and Richard M. Murray \n", + "Karl J. Astrom and Richard M. Murray\n", "23 Jul 2019\n", "\n", "This notebook contains the computations for the vehicle steering running example in *Feedback Systems*." ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "RMM comments to Karl, 27 Jun 2019\n", - "* I'm using this notebook to walk through all of the vehicle steering examples and make sure that all of the parameters, conditions, and maximum steering angles are consitent and reasonable.\n", - "* Please feel free to send me comments on the contents as well as the bulletted notes, in whatever form is most convenient.\n", - "* Once we have sorted out all of the settings we want to use, I'll copy over the changes into the MATLAB files that we use for creating the figures in the book.\n", - "* These notes will be removed from the notebook once we have finalized everything." - ] - }, { "cell_type": "code", "execution_count": 1, @@ -31,8 +20,8 @@ "import numpy as np\n", "import matplotlib.pyplot as plt\n", "import control as ct\n", - "ct.use_fbs_defaults()\n", - "ct.use_numpy_matrix(False)" + "import control.optimal as opt\n", + "ct.use_fbs_defaults()" ] }, { @@ -98,40 +87,16 @@ "To illustrate the dynamics of the system, we create an input that correspond to driving down a curvy road. This trajectory will be used in future simulations as a reference trajectory for estimation and control." ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "RMM notes, 27 Jun 2019:\n", - "* The figure below appears in Chapter 8 (output feedback) as Example 8.3, but I've put it here in the notebook since it is a good way to demonstrate the dynamics of the vehicle.\n", - "* In the book, this figure is created for the linear model and in a manner that I can't quite understand, since the linear model that is used is only for the lateral dynamics. The original file is `OutputFeedback/figures/steering_obs.m`.\n", - "* To create the figure here, I set the initial vehicle angle to be $\\theta(0) = 0.75$ rad and then used an input that gives a figure approximating Example 8.3 To create the lateral offset, I think subtracted the trajectory from the averaged straight line trajectory, shown as a dashed line in the $xy$ figure below.\n", - "* I find the approach that we used in the MATLAB version to be confusing, but I also think the method of creating the lateral error here is a hart to follow. We might instead consider choosing a trajectory that goes mainly vertically, with the 2D dynamics being the $x$, $\\theta$ dynamics instead of the $y$, $\\theta$ dynamics.\n", - "\n", - "KJA comments, 1 Jul 2019:\n", - "\n", - "0. I think we should point out that the reference point is typically the projection of the center of mass of the whole vehicle.\n", - "\n", - "1. The heading angle $\\theta$ must be marked in Figure 3.17b.\n", - "\n", - "2. I think it is useful to start with a curvy road that you have done here but then to specialized to a trajectory that is essentially horizontal, where $y$ is the deviation from the nominal horizontal $x$ axis. Assuming that $\\alpha$ and $\\theta$ are small we get the natural linearization of (3.26) $\\dot x = v$ and $\\dot y =v(\\alpha + \\theta)$\n", - "\n", - "RMM response, 16 Jul 2019:\n", - "* I've changed the trajectory to be about the horizontal axis, but I am ploting things vertically for better figure layout. This corresponds to what is done in Example 9.10 in the text, which I think looks OK.\n", - "\n", - "KJA response, 20 Jul 2019: Fig 8.6a is fine" - ] - }, { "cell_type": "code", "execution_count": 3, "metadata": { - "scrolled": true + "scrolled": false }, "outputs": [ { "data": { - "image/png": "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\n", + "image/png": "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\n", "text/plain": [ "
" ] @@ -208,10 +173,10 @@ "Linearized system dynamics:\n", "\n", "A = [[0. 1.]\n", - " [0. 0.]]\n", + " [0. 0.]]\n", "\n", "B = [[0.5]\n", - " [1. ]]\n", + " [1. ]]\n", "\n", "C = [[1. 0.]]\n", "\n", @@ -264,20 +229,6 @@ "The unit step responses for the closed loop system for different values of the design parameters are shown below. The effect of $\\omega_c$ is shown on the left, which shows that the response speed increases with increasing $\\omega_\\text{c}$. All responses have overshoot less than 5% (15 cm), as indicated by the dashed lines. The settling times range from 30 to 60 normalized time units, which corresponds to about 3–6 s, and are limited by the acceptable lateral acceleration of the vehicle. The effect of $\\zeta_\\text{c}$ is shown on the right. The response speed and the overshoot increase with decreasing damping. Using these plots, we conclude that a reasonable design choice is $\\omega_\\text{c} = 0.07$ and $\\zeta_\\text{c} = 0.7$. " ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "RMM note, 27 Jun 2019: \n", - "* The design guidelines are for $v_0$ = 30 m/s (highway speeds) but most of the examples below are done at lower speed (typically 10 m/s). Also, the eigenvalue locations above are not the same ones that we use in the output feedback example below. We should probably make things more consistent.\n", - "\n", - "KJA comment, 1 Jul 2019: \n", - "* I am all for maikng it consist and choosing e.g. v0 = 30 m/s\n", - "\n", - "RMM comment, 17 Jul 2019:\n", - "* I've updated the examples below to use v0 = 30 m/s for everything except the forward/reverse example. This corresponds to ~105 kph (freeway speeds) and a reasonable bound for the steering angle to avoid slipping is 0.05 rad." - ] - }, { "cell_type": "code", "execution_count": 5, @@ -285,7 +236,7 @@ "outputs": [ { "data": { - "image/png": "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\n", + "image/png": "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\n", "text/plain": [ "
" ] @@ -312,7 +263,7 @@ " clsys = ct.StateSpace(A - B @ K, B, lateral_normalized.C, 0)\n", " \n", " # Compute the feedforward gain based on the zero frequency gain of the closed loop\n", - " kf = np.real(1/clsys.evalfr(0))\n", + " kf = np.real(1/clsys(0))\n", "\n", " # Scale the input by the feedforward gain\n", " clsys *= kf\n", @@ -385,19 +336,6 @@ "plt.tight_layout()" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "RMM notes, 17 Jul 2019\n", - "* These step responses are *very* slow. Note that the steering wheel angles are about 10X less than a resonable bound (0.05 rad at 30 m/s). A consequence of these low gains is that the tracking controller in Example 8.4 has to use a different set of gains. We could update, but the gains listed here have a rationale that we would have to update as well.\n", - "* Based on the discussion below, I think we should make $\\omega_\\text{c}$ range from 0.5 to 1 (10X faster).\n", - "\n", - "KJA response, 20 Jul 2019: Makes a lot of sense to make $\\omega_\\text{c}$ range from 0.5 to 1 (10X faster). The plots were still in the range 0.05 to 0.1 in the note you sent me.\n", - "\n", - "RMM response: 23 Jul 2019: Updated $\\omega_\\text{c}$ to 10X faster. Note that this makes size of the inputs for the step response quite large, but that is in part because a unit step in the desired position produces an (instantaneous) error of $b = 3$ m $\\implies$ quite a large error. A lateral error of 10 cm with $\\omega_c = 0.7$ would produce an (initial) input of 0.015 rad." - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -445,23 +383,6 @@ "A simulation of the observer for a vehicle driving on a curvy road is shown below. The first figure shows the trajectory of the vehicle on the road, as viewed from above. The response of the observer is shown on the right, where time is normalized to the vehicle length. We see that the observer error settles in about 4 vehicle lengths." ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "RMM note, 27 Jun 2019:\n", - "* As an alternative, we can attempt to estimate the state of the full nonlinear system using a linear estimator. This system does not necessarily converge to zero since there will be errors in the nominal dynamics of the system for the linear estimator.\n", - "* The limits on the $x$ axis for the time plots are different to show the error over the entire trajectory.\n", - "* We should decide whether we want to keep the figure above or the one below for the text.\n", - "\n", - "KJA comment, 1 Jul 2019:\n", - "* I very much like your observation about the nonlinear system. I think it is a very good idea to use your new simulation\n", - "\n", - "RMM comment, 17 Jul 2019: plan to use this version in the text.\n", - "\n", - "KJA comment, 20 Jul 2019: I think this is a big improvement we show that an observer based on a linearized model works on a nonlinear simulation, If possible we could add a line telling why the linear model works and that this is standard procedure in control engineering.\t" - ] - }, { "cell_type": "code", "execution_count": 7, @@ -469,7 +390,7 @@ "outputs": [ { "data": { - "image/png": "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\n", + "image/png": "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\n", "text/plain": [ "
" ] @@ -488,7 +409,7 @@ "tau = v0 * T_curvy / b\n", "\n", "# Simulate the estimator, with a small initial error in y position\n", - "t, y_est, x_est = ct.forced_response(est, tau, [delta_curvy, y_ref], [0.5, 0])\n", + "t, y_est, x_est = ct.forced_response(est, tau, [delta_curvy, y_ref], [0.5, 0], return_x=True)\n", "\n", "# Configure matplotlib plots to be a bit bigger and optimize layout\n", "plt.figure(figsize=[9, 4.5])\n", @@ -528,28 +449,6 @@ "## Output Feedback Controller (Example 8.4)" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "RMM note, 27 Jun 2019\n", - "* The feedback gains for the controller below are different that those computed in the eigenvalue placement example (from Ch 7), where an argument was given for the choice of the closed loop eigenvalues. Should we choose a single, consistent set of gains in both places?\n", - "* This plot does not quite match Example 8.4 because a different reference is being used for the laterial position.\n", - "* The transient in $\\delta$ is quiet large. This appears to be due to the error in $\\theta(0)$, which is initialized to zero intead of to `theta_curvy`.\n", - "\n", - "KJA comment, 1 Jul 2019:\n", - "1. The large initial errors dominate the plots.\n", - "\n", - "2. There is somehing funny happening at the end of the simulation, may be due to the small curvature at the end of the path?\n", - "\n", - "RMM comment, 17 Jul 2019:\n", - "* Updated to use the new trajectory\n", - "* We will have the issue that the gains here are different than the gains that we used in Chapter 7. I think that what we need to do is update the gains in Ch 7 (they are too sluggish, as noted above).\n", - "* Note that unlike the original example in the book, the errors do not converge to zero. This is because we are using pure state feedback (no feedforward) => the controller doesn't apply any input until there is an error.\n", - "\n", - "KJA comment, 20 Jul 2019: We may add that state feedback is a proportional controller which does not guarantee that the error goes to zero for example by changing the line \"The tracking error ...\" to \"The tracking error can be improved by adding integral action (Section7.4), later in this chapter \"Disturbance Modeling\" or feedforward (Section 8,5). Should we do an exercises? \t" - ] - }, { "cell_type": "code", "execution_count": 8, @@ -562,12 +461,12 @@ "output_type": "stream", "text": [ "K = [[0.49 0.7448]]\n", - "kf = [[0.49]]\n" + "kf = 0.4899999999999182\n" ] }, { "data": { - "image/png": "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\n", + "image/png": "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\n", "text/plain": [ "
" ] @@ -596,7 +495,7 @@ " np.zeros((2,1)))\n", "\n", "# Simulate the system\n", - "t, y, x = ct.forced_response(clsys, tau, y_ref, [0.4, 0, 0.0, 0])\n", + "t, y, x = ct.forced_response(clsys, tau, y_ref, [0.4, 0, 0.0, 0], return_x=True)\n", "\n", "# Calcaluate the input used to generate the control response\n", "u_sfb = kf * y_ref - K @ x[0:2]\n", @@ -641,23 +540,6 @@ "To illustrate how we can use a two degree-of-freedom design to improve the performance of the system, consider the problem of steering a car to change lanes on a road. We use the non-normalized form of the dynamics, which were derived in Example 3.11." ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "KJA comment, 1 Jul 2019:\n", - "1. I think the reference trajectory is too much curved in the end compare with Example 3.11\n", - "\n", - "In summary I think it is OK to change the reference trajectories but we should make sure that the curvature is less than $\\rho=600 m$ not to have too high acceleratarion.\n", - "\n", - "RMM response, 16 Jul 2019:\n", - "* Not sure if the comment about the trajectory being too curved is referring to this example. The steering angles (and hence radius of curvature/acceleration) are quite low. ??\n", - "\n", - "KJA response, 20 Jul 2019: You are right the curvature is not too small. We could add the sentence \"The small deviations can be eliminated by adding feedback.\"\n", - "\n", - "RMM response, 23 Jul 2019: I think the small deviation you are referring to is in the velocity trace. This occurs because I gave a fixed endpoint in time and so the velocity had to be adjusted to hit that exact point at that time. This doesn't show up in the book, so it won't be a problem ($\\implies$ no additional explanation required)." - ] - }, { "cell_type": "code", "execution_count": 9, @@ -715,6 +597,55 @@ "vehicle_flat = fs.FlatSystem(vehicle_flat_forward, vehicle_flat_reverse, inputs=2, states=3)" ] }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [], + "source": [ + "# Utility function to plot lane change trajectory\n", + "def plot_vehicle_lanechange(traj):\n", + " # Create the trajectory\n", + " t = np.linspace(0, Tf, 100)\n", + " x, u = traj.eval(t)\n", + "\n", + " # Configure matplotlib plots to be a bit bigger and optimize layout\n", + " plt.figure(figsize=[9, 4.5])\n", + "\n", + " # Plot the trajectory in xy coordinate\n", + " plt.subplot(1, 4, 2)\n", + " plt.plot(x[1], x[0])\n", + " plt.xlabel('y [m]')\n", + " plt.ylabel('x [m]')\n", + "\n", + " # Add lane lines and scale the axis\n", + " plt.plot([-4, -4], [0, x[0, -1]], 'k-', linewidth=1)\n", + " plt.plot([0, 0], [0, x[0, -1]], 'k--', linewidth=1)\n", + " plt.plot([4, 4], [0, x[0, -1]], 'k-', linewidth=1)\n", + " plt.axis([-10, 10, -5, x[0, -1] + 5])\n", + "\n", + " # Time traces of the state and input\n", + " plt.subplot(2, 4, 3)\n", + " plt.plot(t, x[1])\n", + " plt.ylabel('y [m]')\n", + "\n", + " plt.subplot(2, 4, 4)\n", + " plt.plot(t, x[2])\n", + " plt.ylabel('theta [rad]')\n", + "\n", + " plt.subplot(2, 4, 7)\n", + " plt.plot(t, u[0])\n", + " plt.xlabel('Time t [sec]')\n", + " plt.ylabel('v [m/s]')\n", + " # plt.axis([0, t[-1], u0[0] - 1, uf[0] + 1])\n", + "\n", + " plt.subplot(2, 4, 8)\n", + " plt.plot(t, u[1]);\n", + " plt.xlabel('Time t [sec]')\n", + " plt.ylabel('$\\delta$ [rad]')\n", + " plt.tight_layout()" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -724,14 +655,14 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 11, "metadata": { "scrolled": true }, "outputs": [ { "data": { - "image/png": "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\n", + "image/png": "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\n", "text/plain": [ "
" ] @@ -749,88 +680,92 @@ "Tf = xf[0] / uf[0]\n", "\n", "# Define a set of basis functions to use for the trajectories\n", - "poly = fs.PolyFamily(6)\n", + "poly = fs.PolyFamily(8)\n", "\n", "# Find a trajectory between the initial condition and the final condition\n", - "traj = fs.point_to_point(vehicle_flat, x0, u0, xf, uf, Tf, basis=poly)\n", - "\n", - "# Create the trajectory\n", - "t = np.linspace(0, Tf, 100)\n", - "x, u = traj.eval(t)\n", - "\n", - "# Configure matplotlib plots to be a bit bigger and optimize layout\n", - "plt.figure(figsize=[9, 4.5])\n", - "\n", - "# Plot the trajectory in xy coordinate\n", - "plt.subplot(1, 4, 2)\n", - "plt.plot(x[1], x[0])\n", - "plt.xlabel('y [m]')\n", - "plt.ylabel('x [m]')\n", - "\n", - "# Add lane lines and scale the axis\n", - "plt.plot([-4, -4], [0, x[0, -1]], 'k-', linewidth=1)\n", - "plt.plot([0, 0], [0, x[0, -1]], 'k--', linewidth=1)\n", - "plt.plot([4, 4], [0, x[0, -1]], 'k-', linewidth=1)\n", - "plt.axis([-10, 10, -5, x[0, -1] + 5])\n", - "\n", - "# Time traces of the state and input\n", - "plt.subplot(2, 4, 3)\n", - "plt.plot(t, x[1])\n", - "plt.ylabel('y [m]')\n", - "\n", - "plt.subplot(2, 4, 4)\n", - "plt.plot(t, x[2])\n", - "plt.ylabel('theta [rad]')\n", - "\n", - "plt.subplot(2, 4, 7)\n", - "plt.plot(t, u[0])\n", - "plt.xlabel('Time t [sec]')\n", - "plt.ylabel('v [m/s]')\n", - "plt.axis([0, Tf, u0[0] - 1, uf[0] +1])\n", - "\n", - "plt.subplot(2, 4, 8)\n", - "plt.plot(t, u[1]);\n", - "plt.xlabel('Time t [sec]')\n", - "plt.ylabel('$\\delta$ [rad]')\n", - "plt.tight_layout()" + "traj1 = fs.point_to_point(vehicle_flat, Tf, x0, u0, xf, uf, basis=poly) \n", + "plot_vehicle_lanechange(traj1)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Vehicle transfer functions for forward and reverse driving (Example 10.11)\n", - "\n", - "The vehicle steering model has different properties depending on whether we are driving forward or in reverse. The figures below show step responses from steering angle to lateral translation for a the linearized model when driving forward (dashed) and reverse (solid). In this simulation we have added an extra pole with the time constant $T=0.1$ to approximately account for the dynamics in the steering system.\n", - "\n", - "With rear-wheel steering the center of mass first moves in the wrong direction and the overall response with rear-wheel steering is significantly delayed compared with that for front-wheel steering. (b) Frequency response for driving forward (dashed) and reverse (solid). Notice that the gain curves are identical, but the phase curve for driving in reverse has non-minimum phase." + "### Change of basis function" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "bezier = fs.BezierFamily(8)\n", + "traj2 = fs.point_to_point(vehicle_flat, Tf, x0, u0, xf, uf, basis=bezier)\n", + "plot_vehicle_lanechange(traj2)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "RMM note, 27 Jun 2019:\n", - "* I cannot recreate the figures in Example 10.11. Since we are looking at the lateral *velocity*, there is a differentiator in the output and this takes the step function and creates an offset at $t = 0$ (intead of a smooth curve).\n", - "* The transfer functions are also different, and I don't quite understand why. Need to spend a bit more time on this one.\n", - "\n", - "KJA comment, 1 Jul 2019: The reason why you cannot recreate figures i Example 10.11 is because the caption in figure is wrong, sorry my fault, the y-axis should be lateral position not lateral velocity. The approximate expression for the transfer functions\n", - "\n", - "$$\n", - "G_{y\\delta}=\\frac{av_0s+v_0^2}{bs} = \\frac{1.5 s + 1}{3s^2}=\\frac{0.5s + 0.33}{s}\n", - "$$\n", - "\n", - "are quite close to the values that you get numerically\n", - "\n", - "In this case I think it is useful to have v=1 m/s because we do not drive to fast backwards.\n", - "\n", - "RMM response, 17 Jul 2019\n", - "* Updated figures below use the same parameters as the running example (the current text uses different parameters)\n", - "* Following the material in the text, a pole is added at s = -1 to approximate the dynamics of the steering system. This is not strictly needed, so we could decide to take it out (and update the text)\n", + "### Added cost function" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "timepts = np.linspace(0, Tf, 12)\n", + "poly = fs.PolyFamily(8)\n", + "traj_cost = opt.quadratic_cost(\n", + " vehicle_flat, np.diag([0, 0.1, 0]), np.diag([0.1, 10]), x0=xf, u0=uf)\n", + "constraints = [\n", + " opt.input_range_constraint(vehicle_flat, [8, -0.1], [12, 0.1]) ]\n", + "\n", + "traj3 = fs.point_to_point(\n", + " vehicle_flat, timepts, x0, u0, xf, uf, cost=traj_cost, basis=poly\n", + ")\n", + "plot_vehicle_lanechange(traj3)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Vehicle transfer functions for forward and reverse driving (Example 10.11)\n", "\n", - "KJA comment, 20 Jul 2019: I have been oscillating a bit about this example. Of course it does not make sense to drive in reverse in 30 m/s but it seems a bit silly to change parameters just in this case (if we do we have to motivate it). On the other hand what we are doing is essentially based on transfer functions and a RHP zero. My current view which has changed a few times is to keep the standard parameters. In any case we should eliminate the extra time constant. A small detail, I could not see the time response in the file you sent, do not resend it!, I will look at the final version.\n", + "The vehicle steering model has different properties depending on whether we are driving forward or in reverse. The figures below show step responses from steering angle to lateral translation for a the linearized model when driving forward (dashed) and reverse (solid). In this simulation we have added an extra pole with the time constant $T=0.1$ to approximately account for the dynamics in the steering system.\n", "\n", - "RMM comment, 23 Jul 2019: I think it is OK to have the speed be different and just talk about this in the text. I have removed the extra time constant in the current version." + "With rear-wheel steering the center of mass first moves in the wrong direction and the overall response with rear-wheel steering is significantly delayed compared with that for front-wheel steering. (b) Frequency response for driving forward (dashed) and reverse (solid). Notice that the gain curves are identical, but the phase curve for driving in reverse has non-minimum phase." ] }, { @@ -938,26 +873,6 @@ "For a lane transfer system we would like to have a nice response without overshoot, and we therefore consider the use of feedforward compensation to provide a reference trajectory for the closed loop system. We choose the desired response as $F_\\text{m}(s) = a^22/(s + a)^2$, where the response speed or aggressiveness of the steering is governed by the parameter $a$." ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "RMM note, 27 Jun 2019:\n", - "* $a$ was used in the original description of the dynamics as the reference offset. Perhaps choose a different symbol here?\n", - "* In current version of Ch 12, the $y$ axis is labeled in absolute units, but it should actually be in normalized units, I think.\n", - "* The steering angle input for this example is quite high. Compare to Example 8.8, above. Also, we should probably make the size of the \"lane change\" from this example match whatever we use in Example 8.8\n", - "\n", - "KJA comments, 1 Jul 2019: Chosen parameters look good to me\n", - "\n", - "RMM response, 17 Jul 2019\n", - "* I changed the time constant for the feedforward model to give something that is more reasonable in terms of turning angle at the speed of $v_0 = 30$ m/s. Note that this takes about 30 body lengths to change lanes (= 9 seconds at 105 kph).\n", - "* The time to change lanes is about 2X what it is using the differentially flat trajectory above. This is mainly because the feedback controller applies a large pulse at the beginning of the trajectory (based on the input error), whereas the differentially flat trajectory spreads the turn over a longer interval. Since are living the steering angle, we have to limit the size of the pulse => slow down the time constant for the reference model.\n", - "\n", - "KJA response, 20 Jul 2019: I think the time for lane change is too long, which may depend on the small steering angles used. The largest steering angle is about 0.03 rad, but we have admitted larger values in previous examples. I suggest that we change the design so that the largest sterring angel is closer to 0.05, see the remark from Bjorn O a lane change could take about 5 s at 30m/s. \n", - "\n", - "RMM response, 23 Jul 2019: I reset the time constant to 0.2, which gives something closer to what we had for trajectory generation. It is still slower, but this is to be expected since it is a linear controller. We now finish the trajectory in 20 body lengths, which is about 6 seconds." - ] - }, { "cell_type": "code", "execution_count": 13, @@ -1023,15 +938,6 @@ "Consider a controller based on state feedback combined with an observer where we want a faster closed loop system and choose $\\omega_\\text{c} = 10$, $\\zeta_\\text{c} = 0.707$, $\\omega_\\text{o} = 20$, and $\\zeta_\\text{o} = 0.707$." ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "KJA comment, 20 Jul 2019: This is a really troublesome case. If we keep it as a vehicle steering problem we must have an order of magnitude lower valuer for $\\omega_c$ and $\\omega_o$ and then the zero will not be slow. My recommendation is to keep it as a general system with the transfer function. $P(s)=(s+1)/s^2$. The text then has to be reworded.\n", - "\n", - "RMM response, 23 Jul 2019: I think the way we have it is OK. Our current value for the controller and observer is $\\omega_\\text{c} = 0.7$ and $\\omega_\\text{o} = 1$. Here we way we want something faster and so we got to $\\omega_\\text{c} = 7$ (10X) and $\\omega_\\text{o} = 10$ (10X)." - ] - }, { "cell_type": "code", "execution_count": 14, @@ -1081,7 +987,7 @@ "zc = 0.707\n", "eigs = np.roots([1, 2*zc*wc, wc**2])\n", "K = ct.place(A, B, eigs)\n", - "kr = np.real(1/clsys.evalfr(0))\n", + "kr = np.real(1/clsys(0))\n", "print(\"K = \", np.squeeze(K))\n", "\n", "# Compute the estimator gain using eigenvalue placement\n", @@ -1126,7 +1032,7 @@ "zc = 2.6\n", "eigs = np.roots([1, 2*zc*wc, wc**2])\n", "K = ct.place(A, B, eigs)\n", - "kr = np.real(1/clsys.evalfr(0))\n", + "kr = np.real(1/clsys(0))\n", "print(\"K = \", np.squeeze(K))\n", "\n", "# Construct an output-based controller for the system\n", @@ -1157,13 +1063,6 @@ "ct.gangof4(P, C1, np.logspace(-1, 3, 100))\n", "ct.gangof4(P, C2, np.logspace(-1, 3, 100))" ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { @@ -1182,7 +1081,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.2" + "version": "3.9.1" } }, "nbformat": 4, diff --git a/examples/test-response.py b/examples/test-response.py index 0ccc70b6c..359d1c3ea 100644 --- a/examples/test-response.py +++ b/examples/test-response.py @@ -4,7 +4,9 @@ import os import matplotlib.pyplot as plt # MATLAB plotting functions from control.matlab import * # Load the controls systems library -from scipy import arange # function to create range of numbers +from numpy import arange # function to create range of numbers + +from control import reachable_form # Create several systems for testing sys1 = tf([1], [1, 2, 1]) @@ -13,10 +15,18 @@ # Generate step responses (y1a, T1a) = step(sys1) (y1b, T1b) = step(sys1, T=arange(0, 10, 0.1)) -(y1c, T1c) = step(sys1, X0=[1, 0]) +# convert to reachable canonical SS to specify initial state +sys1_ss = reachable_form(ss(sys1))[0] +(y1c, T1c) = step(sys1_ss, X0=[1, 0]) (y2a, T2a) = step(sys2, T=arange(0, 10, 0.1)) -plt.plot(T1a, y1a, T1b, y1b, T1c, y1c, T2a, y2a) +plt.plot(T1a, y1a, label='$g_1$ (default)', linewidth=5) +plt.plot(T1b, y1b, label='$g_1$ (w/ spec. times)', linestyle='--') +plt.plot(T1c, y1c, label='$g_1$ (w/ init cond.)') +plt.plot(T2a, y2a, label='$g_2$ (w/ spec. times)') +plt.xlabel('time') +plt.ylabel('output') +plt.legend() if 'PYCONTROL_TEST_EXAMPLES' not in os.environ: - plt.show() \ No newline at end of file + plt.show() diff --git a/examples/tfvis.py b/examples/tfvis.py index 60b837d99..f05a45780 100644 --- a/examples/tfvis.py +++ b/examples/tfvis.py @@ -341,12 +341,12 @@ def redraw(self): self.f_bode.clf() plt.figure(self.f_bode.number) - control.matlab.bode(self.sys, logspace(-2, 2)) + control.matlab.bode(self.sys, logspace(-2, 2, 1000)) plt.suptitle('Bode Diagram') self.f_nyquist.clf() plt.figure(self.f_nyquist.number) - control.matlab.nyquist(self.sys, logspace(-2, 2)) + control.matlab.nyquist(self.sys, logspace(-2, 2, 1000)) plt.suptitle('Nyquist Diagram') self.f_step.clf() @@ -354,7 +354,7 @@ def redraw(self): try: # Step seems to get intro trouble # with purely imaginary poles - tvec, yvec = control.matlab.step(self.sys) + yvec, tvec = control.matlab.step(self.sys) plt.plot(tvec.T, yvec) except: print("Error plotting step response") diff --git a/setup.cfg b/setup.cfg index ac4f92c75..c72ef19a8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,7 +2,7 @@ universal=1 [tool:pytest] +addopts = -ra filterwarnings = - ignore:.*matrix subclass:PendingDeprecationWarning - ignore:.*scipy:DeprecationWarning + error:.*matrix subclass:PendingDeprecationWarning diff --git a/setup.py b/setup.py index ec16d7135..849d30b34 100644 --- a/setup.py +++ b/setup.py @@ -19,10 +19,10 @@ Programming Language :: Python :: 2 Programming Language :: Python :: 2.7 Programming Language :: Python :: 3 -Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 +Programming Language :: Python :: 3.9 Topic :: Software Development Topic :: Scientific/Engineering Operating System :: Microsoft :: Windows @@ -44,4 +44,7 @@ install_requires=['numpy', 'scipy', 'matplotlib'], + extras_require={ + 'test': ['pytest', 'pytest-timeout'], + } )