Skip to content

Commit b4cf736

Browse files
authored
Merge pull request python-control#1248 from CNZHM666
Improve numerical symmetry check
2 parents a775479 + 067cf78 commit b4cf736

4 files changed

Lines changed: 121 additions & 30 deletions

File tree

‎control/mateqn.py‎

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
import numpy as np
1616
import scipy as sp
17-
from numpy import eye, finfo, inexact
17+
from numpy import eye, finfo
1818
from scipy.linalg import eigvals, solve
1919

2020
from .exception import ControlArgument, ControlDimension, ControlSlycot, \
@@ -81,7 +81,7 @@ def _warn_ill_conditioned_E(E):
8181
#
8282

8383

84-
def lyap(A, Q, C=None, E=None, method=None):
84+
def lyap(A, Q, C=None, E=None, method=None, symmetric_kwargs=None):
8585
"""Solves the continuous-time Lyapunov equation.
8686
8787
X = lyap(A, Q) solves
@@ -117,6 +117,9 @@ def lyap(A, Q, C=None, E=None, method=None):
117117
Set the method used for computing the result. Current methods are
118118
'slycot' and 'scipy'. If set to None (default), try 'slycot' first
119119
and then 'scipy'.
120+
symmetric_kwargs : dict, optional
121+
Keyword arguments passed to `scipy.linalg.issymmetric` or
122+
`scipy.linalg.ishermitian`.
120123
121124
Returns
122125
-------
@@ -169,7 +172,7 @@ def lyap(A, Q, C=None, E=None, method=None):
169172
# Solve standard Lyapunov equation
170173
if C is None and E is None:
171174
# Check to make sure input matrices are the right shape and type
172-
_check_shape(Q, n, n, square=True, symmetric=True, name="Q")
175+
_check_shape(Q, n, n, square=True, symmetric=True, name="Q", symmetric_kwargs=symmetric_kwargs)
173176

174177
if method == 'scipy':
175178
# Solve the Lyapunov equation using SciPy
@@ -197,7 +200,7 @@ def lyap(A, Q, C=None, E=None, method=None):
197200
# Solve the generalized Lyapunov equation
198201
elif C is None and E is not None:
199202
# Check to make sure input matrices are the right shape and type
200-
_check_shape(Q, n, n, square=True, symmetric=True, name="Q")
203+
_check_shape(Q, n, n, square=True, symmetric=True, name="Q", symmetric_kwargs=symmetric_kwargs)
201204
_check_shape(E, n, n, square=True, name="E")
202205

203206
if method == 'scipy':
@@ -243,7 +246,7 @@ def lyap(A, Q, C=None, E=None, method=None):
243246
return X
244247

245248

246-
def dlyap(A, Q, C=None, E=None, method=None):
249+
def dlyap(A, Q, C=None, E=None, method=None, symmetric_kwargs=None):
247250
"""Solves the discrete-time Lyapunov equation.
248251
249252
X = dlyap(A, Q) solves
@@ -279,6 +282,9 @@ def dlyap(A, Q, C=None, E=None, method=None):
279282
Set the method used for computing the result. Current methods are
280283
'slycot' and 'scipy'. If set to None (default), try 'slycot' first
281284
and then 'scipy'.
285+
symmetric_kwargs : dict, optional
286+
Keyword arguments passed to `scipy.linalg.issymmetric` or
287+
`scipy.linalg.ishermitian`.
282288
283289
Returns
284290
-------
@@ -346,7 +352,7 @@ def dlyap(A, Q, C=None, E=None, method=None):
346352
# Solve standard Lyapunov equation
347353
if C is None and E is None:
348354
# Check to make sure input matrices are the right shape and type
349-
_check_shape(Q, n, n, square=True, symmetric=True, name="Q")
355+
_check_shape(Q, n, n, square=True, symmetric=True, name="Q", symmetric_kwargs=symmetric_kwargs)
350356

351357
if method == 'scipy':
352358
# Solve the Lyapunov equation using SciPy
@@ -406,7 +412,7 @@ def dlyap(A, Q, C=None, E=None, method=None):
406412
# Solve the generalized Lyapunov equation
407413
elif C is None and E is not None:
408414
# Check to make sure input matrices are the right shape and type
409-
_check_shape(Q, n, n, square=True, symmetric=True, name="Q")
415+
_check_shape(Q, n, n, square=True, symmetric=True, name="Q", symmetric_kwargs=symmetric_kwargs)
410416
_check_shape(E, n, n, square=True, name="E")
411417

412418
if method == 'scipy':
@@ -449,8 +455,8 @@ def dlyap(A, Q, C=None, E=None, method=None):
449455
# Riccati equation solvers care and dare
450456
#
451457

452-
def care(A, B, Q, R=None, S=None, E=None, stabilizing=True, method=None,
453-
_As="A", _Bs="B", _Qs="Q", _Rs="R", _Ss="S", _Es="E"):
458+
def care(A, B, Q, R=None, S=None, E=None, stabilizing=True, method=None, symmetric_kwargs=None,
459+
_As="A", _Bs="B", _Qs="Q", _Rs="R", _Ss="S", _Es="E", ):
454460
"""Solves the continuous-time algebraic Riccati equation.
455461
456462
X, L, G = care(A, B, Q, R=None) solves
@@ -484,6 +490,9 @@ def care(A, B, Q, R=None, S=None, E=None, stabilizing=True, method=None,
484490
Set the method used for computing the result. Current methods are
485491
'slycot' and 'scipy'. If set to None (default), try 'slycot' first
486492
and then 'scipy'.
493+
symmetric_kwargs : dict, optional
494+
Keyword arguments passed to `scipy.linalg.issymmetric` or
495+
`scipy.linalg.ishermitian`.
487496
stabilizing : bool, optional
488497
If `method` is 'slycot', unstabilized eigenvalues will be returned
489498
in the initial elements of `L`. Not supported for 'scipy'.
@@ -518,8 +527,8 @@ def care(A, B, Q, R=None, S=None, E=None, stabilizing=True, method=None,
518527
# Check to make sure input matrices are the right shape and type
519528
_check_shape(A, n, n, square=True, name=_As)
520529
_check_shape(B, n, m, name=_Bs)
521-
_check_shape(Q, n, n, square=True, symmetric=True, name=_Qs)
522-
_check_shape(R, m, m, square=True, symmetric=True, name=_Rs)
530+
_check_shape(Q, n, n, square=True, symmetric=True, name=_Qs, symmetric_kwargs=symmetric_kwargs)
531+
_check_shape(R, m, m, square=True, symmetric=True, name=_Rs, symmetric_kwargs=symmetric_kwargs)
523532

524533
# Solve the standard algebraic Riccati equation
525534
if S is None and E is None:
@@ -605,7 +614,7 @@ def care(A, B, Q, R=None, S=None, E=None, stabilizing=True, method=None,
605614
# the gain matrix G
606615
return X, L, G
607616

608-
def dare(A, B, Q, R, S=None, E=None, stabilizing=True, method=None,
617+
def dare(A, B, Q, R, S=None, E=None, stabilizing=True, method=None, symmetric_kwargs=None,
609618
_As="A", _Bs="B", _Qs="Q", _Rs="R", _Ss="S", _Es="E"):
610619
"""Solves the discrete-time algebraic Riccati equation.
611620
@@ -640,6 +649,9 @@ def dare(A, B, Q, R, S=None, E=None, stabilizing=True, method=None,
640649
Set the method used for computing the result. Current methods are
641650
'slycot' and 'scipy'. If set to None (default), try 'slycot' first
642651
and then 'scipy'.
652+
symmetric_kwargs : dict, optional
653+
Keyword arguments passed to `scipy.linalg.issymmetric` or
654+
`scipy.linalg.ishermitian`.
643655
stabilizing : bool, optional
644656
If `method` is 'slycot', unstabilized eigenvalues will be returned
645657
in the initial elements of `L`. Not supported for 'scipy'.
@@ -674,8 +686,8 @@ def dare(A, B, Q, R, S=None, E=None, stabilizing=True, method=None,
674686
# Check to make sure input matrices are the right shape and type
675687
_check_shape(A, n, n, square=True, name=_As)
676688
_check_shape(B, n, m, name=_Bs)
677-
_check_shape(Q, n, n, square=True, symmetric=True, name=_Qs)
678-
_check_shape(R, m, m, square=True, symmetric=True, name=_Rs)
689+
_check_shape(Q, n, n, square=True, symmetric=True, name=_Qs, symmetric_kwargs=symmetric_kwargs)
690+
_check_shape(R, m, m, square=True, symmetric=True, name=_Rs, symmetric_kwargs=symmetric_kwargs)
679691
if E is not None:
680692
_check_shape(E, n, n, square=True, name=_Es)
681693
if S is not None:
@@ -740,7 +752,7 @@ def _slycot_or_scipy(method):
740752

741753

742754
# Utility function to check matrix dimensions
743-
def _check_shape(M, n, m, square=False, symmetric=False, name="??"):
755+
def _check_shape(M, n, m, square=False, symmetric=False, name="??", symmetric_kwargs=None):
744756
"""Check the shape and properties of a 2D array.
745757
746758
This function can be used to check to make sure a 2D array_like has the
@@ -773,7 +785,7 @@ def _check_shape(M, n, m, square=False, symmetric=False, name="??"):
773785
if (square or symmetric) and M.shape[0] != M.shape[1]:
774786
raise ControlDimension("%s must be a square matrix" % name)
775787

776-
if symmetric and not _is_symmetric(M):
788+
if symmetric and not _is_symmetric(M, symmetric_kwargs=symmetric_kwargs):
777789
raise ControlArgument("%s must be a symmetric matrix" % name)
778790

779791
if M.shape[0] != n or M.shape[1] != m:
@@ -785,10 +797,10 @@ def _check_shape(M, n, m, square=False, symmetric=False, name="??"):
785797

786798

787799
# Utility function to check if a matrix is symmetric
788-
def _is_symmetric(M):
789-
M = np.atleast_2d(M)
790-
if isinstance(M[0, 0], inexact):
791-
eps = finfo(M.dtype).eps
792-
return ((M - M.T) < eps).all()
793-
else:
794-
return (M == M.T).all()
800+
def _is_symmetric(M, symmetric_kwargs=None):
801+
if symmetric_kwargs is None:
802+
symmetric_kwargs = {}
803+
804+
if np.iscomplexobj(M):
805+
return sp.linalg.ishermitian(M, **symmetric_kwargs)
806+
return sp.linalg.issymmetric(M, **symmetric_kwargs)

‎control/stochsys.py‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333

3434

3535
# contributed by Sawyer B. Fuller <minster@uw.edu>
36-
def lqe(*args, **kwargs):
36+
def lqe(*args, symmetric_kwargs=None, **kwargs):
3737
r"""lqe(A, G, C, QN, RN, [, NN])
3838
3939
Continuous-time linear quadratic estimator (Kalman filter).
@@ -83,6 +83,9 @@ def lqe(*args, **kwargs):
8383
Set the method used for computing the result. Current methods are
8484
'slycot' and 'scipy'. If set to None (default), try 'slycot' first
8585
and then 'scipy'.
86+
symmetric_kwargs : dict, optional
87+
Keyword arguments passed to `scipy.linalg.issymmetric` or
88+
`scipy.linalg.ishermitian`.
8689
8790
Returns
8891
-------
@@ -178,12 +181,12 @@ def lqe(*args, **kwargs):
178181

179182
# Compute the result (dimension and symmetry checking done in care())
180183
P, E, LT = care(A.T, C.T, G @ QN @ G.T, RN, method=method,
181-
_Bs="C", _Qs="QN", _Rs="RN", _Ss="NN")
184+
_Bs="C", _Qs="QN", _Rs="RN", _Ss="NN", symmetric_kwargs=symmetric_kwargs)
182185
return LT.T, P, E
183186

184187

185188
# contributed by Sawyer B. Fuller <minster@uw.edu>
186-
def dlqe(*args, **kwargs):
189+
def dlqe(*args, symmetric_kwargs=None, **kwargs):
187190
r"""dlqe(A, G, C, QN, RN, [, N])
188191
189192
Discrete-time linear quadratic estimator (Kalman filter).
@@ -220,6 +223,9 @@ def dlqe(*args, **kwargs):
220223
Set the method used for computing the result. Current methods are
221224
'slycot' and 'scipy'. If set to None (default), try 'slycot'
222225
first and then 'scipy'.
226+
symmetric_kwargs : dict, optional
227+
Keyword arguments passed to `scipy.linalg.issymmetric` or
228+
`scipy.linalg.ishermitian`.
223229
224230
Returns
225231
-------
@@ -299,7 +305,7 @@ def dlqe(*args, **kwargs):
299305

300306
# Compute the result (dimension and symmetry checking done in dare())
301307
P, E, LT = dare(A.T, C.T, G @ QN @ G.T, RN, method=method,
302-
_Bs="C", _Qs="QN", _Rs="RN", _Ss="NN")
308+
_Bs="C", _Qs="QN", _Rs="RN", _Ss="NN", symmetric_kwargs=symmetric_kwargs)
303309
return LT.T, P, E
304310

305311

@@ -312,7 +318,7 @@ def create_estimator_iosystem(
312318
control_indices=None, disturbance_indices=None,
313319
estimate_labels='xhat[{i}]', covariance_labels='P[{i},{j}]',
314320
measurement_labels=None, control_labels=None,
315-
inputs=None, outputs=None, states=None, **kwargs):
321+
inputs=None, outputs=None, states=None, symmetric_kwargs=None, **kwargs):
316322
r"""Create an I/O system implementing a linear quadratic estimator.
317323
318324
This function creates an input/output system that implements a
@@ -410,6 +416,9 @@ def create_estimator_iosystem(
410416
name : string, optional
411417
System name (used for specifying signals). If unspecified, a generic
412418
name 'sys[id]' is generated with a unique integer id.
419+
symmetric_kwargs : dict, optional
420+
Keyword arguments passed to `scipy.linalg.issymmetric` or
421+
`scipy.linalg.ishermitian`.
413422
414423
Notes
415424
-----
@@ -492,7 +501,7 @@ def create_estimator_iosystem(
492501
if P0 is None:
493502
# Initialize P0 to the steady state value
494503
_, P0, _ = lqe(A, G, C, QN, RN)
495-
P0 = _check_shape(P0, sys.nstates, sys.nstates, symmetric=True, name='P0')
504+
P0 = _check_shape(P0, sys.nstates, sys.nstates, symmetric=True, name='P0', symmetric_kwargs=symmetric_kwargs)
496505

497506
# Figure out the labels to use
498507
estimate_labels = _process_labels(

‎control/tests/mateqn_test.py‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
import pytest
4040
from scipy.linalg import eigvals, solve
4141

42-
from control.mateqn import lyap, dlyap, care, dare
42+
from control.mateqn import lyap, dlyap, care, dare, _is_symmetric
4343
from control.exception import ControlArgument, ControlDimension
4444

4545

@@ -466,3 +466,25 @@ def test_raise(self):
466466
cdare(A, B, Qfs, R, S, E)
467467
with pytest.raises(ControlArgument):
468468
cdare(A, B, Q, Rfs, S, E)
469+
470+
def test_is_symmetric_scale_aware(self):
471+
M = np.array([
472+
[1e8, 1e8],
473+
[1e8 + 1e-8, 1e8]
474+
])
475+
assert not _is_symmetric(M)
476+
assert _is_symmetric(M,symmetric_kwargs={"rtol": 1e-12},)
477+
478+
def test_is_symmetric_rejects_asymmetric(self):
479+
M = np.array([
480+
[1., 2.],
481+
[5., 1.]
482+
])
483+
assert not _is_symmetric(M)
484+
485+
def test_is_symmetric_complex_hermitian(self):
486+
M = np.array([
487+
[1., 2. + 1.j],
488+
[2. - 1.j, 3.]
489+
])
490+
assert _is_symmetric(M)

‎control/tests/stochsys_test.py‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,54 @@ def test_LQE(method):
3535
L, P, poles = lqe(A, G, C, QN, RN, method=method)
3636
check_LQE(L, P, poles, G, QN, RN)
3737

38+
def test_lqe_symmetric_kwargs():
39+
A = np.array([
40+
[-1., 0.],
41+
[0., -2.]
42+
])
43+
G = np.eye(2)
44+
C = np.eye(2)
45+
QN = np.array([
46+
[1., 0.2],
47+
[0.2 + 1e-15, 1.]
48+
])
49+
RN = np.eye(2)
50+
51+
# Exact symmetry check should fail
52+
with pytest.raises(ControlArgument, match="symmetric"):
53+
lqe(A, G, C, QN, RN, method="scipy")
54+
55+
# Passing rtol should allow the nearly symmetric matrix
56+
lqe(
57+
A, G, C, QN, RN,
58+
method="scipy",
59+
symmetric_kwargs={"rtol": 1e-12},
60+
)
61+
62+
def test_dlqe_symmetric_kwargs():
63+
A = np.array([
64+
[0.5, 0.],
65+
[0., 0.4]
66+
])
67+
G = np.eye(2)
68+
C = np.eye(2)
69+
QN = np.array([
70+
[1., 0.2],
71+
[0.2 + 1e-15, 1.]
72+
])
73+
RN = np.eye(2)
74+
75+
# Exact symmetry check should fail
76+
with pytest.raises(ControlArgument, match="symmetric"):
77+
dlqe(A, G, C, QN, RN, method="scipy")
78+
79+
# Passing rtol should allow the nearly symmetric matrix
80+
dlqe(
81+
A, G, C, QN, RN,
82+
method="scipy",
83+
symmetric_kwargs={"rtol": 1e-12},
84+
)
85+
3886
@pytest.mark.parametrize("cdlqe", [lqe, dlqe])
3987
def test_lqe_call_format(cdlqe):
4088
# Create a random state space system for testing

0 commit comments

Comments
 (0)