Skip to content

Add passivity module, ispassive function, and passivity_test. Introduces optional dependency cvxopt. #739

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
Jun 16, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/python-package-conda.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on: [push, pull_request]

jobs:
test-linux:
name: Python ${{ matrix.python-version }}${{ matrix.slycot && format(' with Slycot from {0}', matrix.slycot) || ' without Slycot' }}${{ matrix.pandas && ', with pandas' || '' }}${{ matrix.array-and-matrix == 1 && ', array and matrix' || '' }}
name: Python ${{ matrix.python-version }}${{ matrix.slycot && format(' with Slycot from {0}', matrix.slycot) || ' without Slycot' }}${{ matrix.pandas && ', with pandas' || '' }}${{ matrix.array-and-matrix == 1 && ', array and matrix' || '' }}${{ matrix.cvxopt && format(' with cvxopt from {0}', matrix.cvxopt) || ' without cvxopt' }}
runs-on: ubuntu-latest

strategy:
Expand All @@ -13,6 +13,7 @@ jobs:
python-version: [3.7, 3.9]
slycot: ["", "conda"]
pandas: [""]
cvxopt: ["", "conda"]
array-and-matrix: [0]
include:
- python-version: 3.9
Expand Down Expand Up @@ -45,6 +46,9 @@ jobs:
if [[ '${{matrix.pandas}}' == 'conda' ]]; then
conda install pandas
fi
if [[ '${{matrix.cvxopt}}' == 'conda' ]]; then
conda install -c conda-forge cvxopt
fi

- name: Test with pytest
env:
Expand Down
12 changes: 12 additions & 0 deletions control/exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,15 @@ def pandas_check():
except:
pandas_installed = False
return pandas_installed

# Utility function to see if cvxopt is installed
cvxopt_installed = None
def cvxopt_check():
global cvxopt_installed
if cvxopt_installed is None:
try:
import cvxopt
cvxopt_installed = True
except:
cvxopt_installed = False
return cvxopt_installed
18 changes: 13 additions & 5 deletions control/lti.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"""

import numpy as np

from numpy import absolute, real, angle, abs
from warnings import warn
from . import config
Expand All @@ -21,6 +22,7 @@
__all__ = ['poles', 'zeros', 'damp', 'evalfr', 'frequency_response',
'freqresp', 'dcgain', 'pole', 'zero']


class LTI(NamedIOSystem):
"""LTI is a parent class to linear time-invariant (LTI) system objects.

Expand All @@ -44,6 +46,7 @@ class LTI(NamedIOSystem):
Note: dt processing has been moved to the NamedIOSystem class.

"""

def __init__(self, inputs=1, outputs=1, states=None, name=None, **kwargs):
"""Assign the LTI object's numbers of inputs and ouputs."""
super().__init__(
Expand Down Expand Up @@ -71,8 +74,7 @@ def _set_inputs(self, value):

#: Deprecated
inputs = property(
_get_inputs, _set_inputs, doc=
"""
_get_inputs, _set_inputs, doc="""
Deprecated attribute; use :attr:`ninputs` instead.

The ``inputs`` attribute was used to store the number of system
Expand All @@ -94,8 +96,7 @@ def _set_outputs(self, value):

#: Deprecated
outputs = property(
_get_outputs, _set_outputs, doc=
"""
_get_outputs, _set_outputs, doc="""
Deprecated attribute; use :attr:`noutputs` instead.

The ``outputs`` attribute was used to store the number of system
Expand Down Expand Up @@ -201,6 +202,11 @@ def _dcgain(self, warn_infinite):
else:
return zeroresp

def ispassive(self):
# importing here prevents circular dependancy
from control.passivity import ispassive
return ispassive(self)

#
# Deprecated functions
#
Expand Down Expand Up @@ -321,7 +327,7 @@ def damp(sys, doprint=True):
wn, damping, poles = sys.damp()
if doprint:
print('_____Eigenvalue______ Damping___ Frequency_')
for p, d, w in zip(poles, damping, wn) :
for p, d, w in zip(poles, damping, wn):
if abs(p.imag) < 1e-12:
print("%10.4g %10.4g %10.4g" %
(p.real, 1.0, -p.real))
Expand All @@ -330,6 +336,7 @@ def damp(sys, doprint=True):
(p.real, p.imag, d, w))
return wn, damping, poles


def evalfr(sys, x, squeeze=None):
"""Evaluate the transfer function of an LTI system for complex frequency x.

Expand Down Expand Up @@ -388,6 +395,7 @@ def evalfr(sys, x, squeeze=None):
"""
return sys.__call__(x, squeeze=squeeze)


def frequency_response(sys, omega, squeeze=None):
"""Frequency response of an LTI system at multiple angular frequencies.

Expand Down
87 changes: 87 additions & 0 deletions control/passivity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
'''
Author: Mark Yeatman
Date: May 15, 2022
'''

import numpy as np
from control import statesp as ss

try:
import cvxopt as cvx
except ImportError as e:
cvx = None


def ispassive(sys):
'''
Indicates if a linear time invariant (LTI) system is passive

Constructs a linear matrix inequality and a feasibility optimization
such that if a solution exists, the system is passive.

The source for the algorithm is:
McCourt, Michael J., and Panos J. Antsaklis.
"Demonstrating passivity and dissipativity using computational methods." ISIS 8 (2013).

Parameters
----------
sys: A continuous LTI system
System to be checked.

Returns
-------
bool:
The input system passive.
'''
if cvx is None:
raise ModuleNotFoundError("cvxopt required for passivity module")

sys = ss._convert_to_statespace(sys)

A = sys.A
B = sys.B
C = sys.C
D = sys.D

# account for strictly proper systems
[n, m] = D.shape
D = D + np.nextafter(0, 1)*np.eye(n, m)

[n, _] = A.shape
A = A - np.nextafter(0, 1)*np.eye(n)

def make_LMI_matrix(P):
V = np.vstack((
np.hstack((A.T @ P + P@A, P@B)),
np.hstack((B.T@P, np.zeros_like(D))))
)
return V

matrix_list = []
state_space_size = sys.nstates
for i in range(0, state_space_size):
for j in range(0, state_space_size):
if j <= i:
P = np.zeros_like(A)
P[i, j] = 1.0
P[j, i] = 1.0
matrix_list.append(make_LMI_matrix(P).flatten())

coefficents = np.vstack(matrix_list).T

constants = -np.vstack((
np.hstack((np.zeros_like(A), - C.T)),
np.hstack((- C, -D - D.T)))
)

number_of_opt_vars = int(
(state_space_size**2-state_space_size)/2 + state_space_size)
c = cvx.matrix(0.0, (number_of_opt_vars, 1))

# crunch feasibility solution
cvx.solvers.options['show_progress'] = False
sol = cvx.solvers.sdp(c,
Gs=[cvx.matrix(coefficents)],
hs=[cvx.matrix(constants)])

return (sol["x"] is not None)
2 changes: 2 additions & 0 deletions control/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
# pytest.param(marks=)
slycotonly = pytest.mark.skipif(not control.exception.slycot_check(),
reason="slycot not installed")
cvxoptonly = pytest.mark.skipif(not control.exception.cvxopt_check(),
reason="cvxopt not installed")
matrixfilter = pytest.mark.filterwarnings("ignore:.*matrix subclass:"
"PendingDeprecationWarning")
matrixerrorfilter = pytest.mark.filterwarnings("error:.*matrix subclass:"
Expand Down
74 changes: 74 additions & 0 deletions control/tests/passivity_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
'''
Author: Mark Yeatman
Date: May 30, 2022
'''
import pytest
import numpy
from control import ss, passivity, tf
from control.tests.conftest import cvxoptonly


pytestmark = cvxoptonly


def test_ispassive():
A = numpy.array([[0, 1], [-2, -2]])
B = numpy.array([[0], [1]])
C = numpy.array([[-1, 2]])
D = numpy.array([[1.5]])
sys = ss(A, B, C, D)

# happy path is passive
assert(passivity.ispassive(sys))

# happy path not passive
D = -D
sys = ss(A, B, C, D)

assert(not passivity.ispassive(sys))


A_d = numpy.array([[-2, 0], [0, 0]])
A = numpy.array([[-3, 0], [0, -2]])
B = numpy.array([[0], [1]])
C = numpy.array([[-1, 2]])
D = numpy.array([[1.5]])


@pytest.mark.parametrize(
"test_input,expected",
[((A, B, C, D*0.0), True),
((A_d, B, C, D), True),
((A*1e12, B, C, D*0), True),
((A, B*0, C*0, D), True),
((A*0, B, C, D), True),
((A*0, B*0, C*0, D*0), True)])
def test_ispassive_edge_cases(test_input, expected):

# strictly proper
A = test_input[0]
B = test_input[1]
C = test_input[2]
D = test_input[3]
sys = ss(A, B, C, D)
assert(passivity.ispassive(sys) == expected)


def test_transfer_function():
sys = tf([1], [1, 2])
assert(passivity.ispassive(sys))

sys = tf([1], [1, -2])
assert(not passivity.ispassive(sys))


def test_oo_style():
A = numpy.array([[0, 1], [-2, -2]])
B = numpy.array([[0], [1]])
C = numpy.array([[-1, 2]])
D = numpy.array([[1.5]])
sys = ss(A, B, C, D)
assert(sys.ispassive())

sys = tf([1], [1, 2])
assert(sys.ispassive())
3 changes: 2 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
'matplotlib'],
extras_require={
'test': ['pytest', 'pytest-timeout'],
'slycot': [ 'slycot>=0.4.0' ]
'slycot': [ 'slycot>=0.4.0' ],
'cvxopt': [ 'cvxopt>=1.2.0' ]
}
)