lyznet 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lyznet/__init__.py +108 -0
- lyznet/_symbolic_z3.py +79 -0
- lyznet/analysis.py +443 -0
- lyznet/assessment.py +295 -0
- lyznet/certificates.py +58 -0
- lyznet/constraints.py +58 -0
- lyznet/equilibria.py +196 -0
- lyznet/homogeneous_learning.py +427 -0
- lyznet/homogeneous_workflow.py +121 -0
- lyznet/neural.py +337 -0
- lyznet/neural_assessment.py +281 -0
- lyznet/neural_learning.py +1312 -0
- lyznet/neural_options.py +43 -0
- lyznet/neural_workflow.py +535 -0
- lyznet/plotting.py +290 -0
- lyznet/sets.py +243 -0
- lyznet/synthesis.py +255 -0
- lyznet/systems.py +614 -0
- lyznet/verification/__init__.py +33 -0
- lyznet/verification/_crown.py +423 -0
- lyznet/verification/_level_search.py +38 -0
- lyznet/verification/_quadratic_clf.py +85 -0
- lyznet/verification/control_lyapunov.py +479 -0
- lyznet/verification/core/__init__.py +1 -0
- lyznet/verification/core/global_lyapunov_z3.py +102 -0
- lyznet/verification/core/global_quadratic_dreal.py +22 -0
- lyznet/verification/core/homogeneous_dreal.py +40 -0
- lyznet/verification/core/neural_lyapunov_crown.py +271 -0
- lyznet/verification/core/quadratic_clf_crown.py +44 -0
- lyznet/verification/core/quadratic_clf_dreal.py +54 -0
- lyznet/verification/core/quadratic_clf_z3.py +59 -0
- lyznet/verification/core/quadratic_crown.py +77 -0
- lyznet/verification/core/quadratic_dreal.py +83 -0
- lyznet/verification/core/quadratic_z3.py +44 -0
- lyznet/verification/core/trigonometric_z3.py +50 -0
- lyznet/verification/global_lyapunov.py +297 -0
- lyznet/verification/global_quadratic.py +201 -0
- lyznet/verification/homogeneous_dreal.py +291 -0
- lyznet/verification/neural_crown.py +729 -0
- lyznet/verification/quadratic.py +596 -0
- lyznet/verification/quadratic_clf_crown.py +248 -0
- lyznet/verification/quadratic_clf_dreal.py +264 -0
- lyznet/verification/quadratic_crown.py +307 -0
- lyznet/verification/quadratic_dreal.py +415 -0
- lyznet/workflows.py +367 -0
- lyznet-0.1.0.dist-info/METADATA +157 -0
- lyznet-0.1.0.dist-info/RECORD +50 -0
- lyznet-0.1.0.dist-info/WHEEL +5 -0
- lyznet-0.1.0.dist-info/licenses/LICENSE +204 -0
- lyznet-0.1.0.dist-info/top_level.txt +1 -0
lyznet/__init__.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""LyZNet public interface."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from .analysis import (
|
|
6
|
+
QuadraticControlLyapunov,
|
|
7
|
+
QuadraticLyapunov,
|
|
8
|
+
homogeneous_degree,
|
|
9
|
+
linearize,
|
|
10
|
+
quadratic_control_lyapunov,
|
|
11
|
+
quadratic_lyapunov,
|
|
12
|
+
)
|
|
13
|
+
from .assessment import (
|
|
14
|
+
QuadraticLevelProposal,
|
|
15
|
+
QuadraticScreeningResult,
|
|
16
|
+
screen_quadratic_clf,
|
|
17
|
+
screen_quadratic_roa,
|
|
18
|
+
)
|
|
19
|
+
from .certificates import PolynomialLyapunov
|
|
20
|
+
from .equilibria import NumericalEquilibrium, solve_equilibrium
|
|
21
|
+
from .neural_options import PINNCorrectionStage
|
|
22
|
+
from .sets import (
|
|
23
|
+
Box,
|
|
24
|
+
EuclideanBall,
|
|
25
|
+
RestrictedSublevelSet,
|
|
26
|
+
SublevelSet,
|
|
27
|
+
WholeSpace,
|
|
28
|
+
)
|
|
29
|
+
from .systems import ContinuousTimeSystem, ControlAffineSystem, System
|
|
30
|
+
from .verification import (
|
|
31
|
+
GlobalLyapunovVerificationResult,
|
|
32
|
+
OptionalVerificationDependencyError,
|
|
33
|
+
QuadraticVerificationResult,
|
|
34
|
+
VerificationClaim,
|
|
35
|
+
VerificationObligation,
|
|
36
|
+
VerificationStatus,
|
|
37
|
+
verify_quadratic_roa,
|
|
38
|
+
verify_global_lyapunov,
|
|
39
|
+
verify_global_quadratic,
|
|
40
|
+
verify_global_quadratic_clf,
|
|
41
|
+
verify_quadratic_clf,
|
|
42
|
+
)
|
|
43
|
+
from .synthesis import (
|
|
44
|
+
PolynomialLyapunovSynthesisResult,
|
|
45
|
+
SynthesisStatus,
|
|
46
|
+
synthesize_global_polynomial_lyapunov,
|
|
47
|
+
)
|
|
48
|
+
from .workflows import (
|
|
49
|
+
LyapunovSearchResult,
|
|
50
|
+
LyapunovSearchTimings,
|
|
51
|
+
VerifiedLyapunovCertificate,
|
|
52
|
+
find_control_lyapunov,
|
|
53
|
+
find_lyap,
|
|
54
|
+
find_lyapunov,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
__version__ = version("lyznet")
|
|
60
|
+
except PackageNotFoundError:
|
|
61
|
+
__version__ = "uninstalled"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
__all__ = [
|
|
65
|
+
"Box",
|
|
66
|
+
"ContinuousTimeSystem",
|
|
67
|
+
"ControlAffineSystem",
|
|
68
|
+
"EuclideanBall",
|
|
69
|
+
"GlobalLyapunovVerificationResult",
|
|
70
|
+
"LyapunovSearchResult",
|
|
71
|
+
"LyapunovSearchTimings",
|
|
72
|
+
"NumericalEquilibrium",
|
|
73
|
+
"OptionalVerificationDependencyError",
|
|
74
|
+
"PINNCorrectionStage",
|
|
75
|
+
"PolynomialLyapunov",
|
|
76
|
+
"PolynomialLyapunovSynthesisResult",
|
|
77
|
+
"QuadraticControlLyapunov",
|
|
78
|
+
"QuadraticLevelProposal",
|
|
79
|
+
"QuadraticLyapunov",
|
|
80
|
+
"QuadraticScreeningResult",
|
|
81
|
+
"QuadraticVerificationResult",
|
|
82
|
+
"RestrictedSublevelSet",
|
|
83
|
+
"SublevelSet",
|
|
84
|
+
"SynthesisStatus",
|
|
85
|
+
"System",
|
|
86
|
+
"VerificationObligation",
|
|
87
|
+
"VerificationClaim",
|
|
88
|
+
"VerificationStatus",
|
|
89
|
+
"VerifiedLyapunovCertificate",
|
|
90
|
+
"WholeSpace",
|
|
91
|
+
"__version__",
|
|
92
|
+
"find_lyap",
|
|
93
|
+
"find_control_lyapunov",
|
|
94
|
+
"find_lyapunov",
|
|
95
|
+
"homogeneous_degree",
|
|
96
|
+
"linearize",
|
|
97
|
+
"quadratic_lyapunov",
|
|
98
|
+
"quadratic_control_lyapunov",
|
|
99
|
+
"solve_equilibrium",
|
|
100
|
+
"screen_quadratic_clf",
|
|
101
|
+
"screen_quadratic_roa",
|
|
102
|
+
"synthesize_global_polynomial_lyapunov",
|
|
103
|
+
"verify_global_lyapunov",
|
|
104
|
+
"verify_global_quadratic",
|
|
105
|
+
"verify_global_quadratic_clf",
|
|
106
|
+
"verify_quadratic_clf",
|
|
107
|
+
"verify_quadratic_roa",
|
|
108
|
+
]
|
lyznet/_symbolic_z3.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Translate supported SymPy arithmetic into exact Z3 arithmetic."""
|
|
2
|
+
|
|
3
|
+
import sympy as sp
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def sympy_to_z3(
|
|
7
|
+
expression,
|
|
8
|
+
substitutions,
|
|
9
|
+
z3,
|
|
10
|
+
*,
|
|
11
|
+
allow_trigonometric_bounds=False,
|
|
12
|
+
function_substitutions=None,
|
|
13
|
+
extra_constraints=None,
|
|
14
|
+
):
|
|
15
|
+
"""Encode arithmetic, optionally enclosing sine and cosine auxiliaries."""
|
|
16
|
+
|
|
17
|
+
functions = {} if function_substitutions is None else function_substitutions
|
|
18
|
+
constraints = [] if extra_constraints is None else extra_constraints
|
|
19
|
+
|
|
20
|
+
def convert(value):
|
|
21
|
+
value = sp.sympify(value)
|
|
22
|
+
if value in substitutions:
|
|
23
|
+
return substitutions[value]
|
|
24
|
+
if value.is_Integer:
|
|
25
|
+
return z3.RealVal(int(value))
|
|
26
|
+
if value.is_Rational:
|
|
27
|
+
return z3.RealVal("{}/{}".format(value.p, value.q))
|
|
28
|
+
if value.is_Float:
|
|
29
|
+
return z3.RealVal(str(value))
|
|
30
|
+
if value.is_Add:
|
|
31
|
+
return z3.Sum([convert(term) for term in value.args])
|
|
32
|
+
if value.is_Mul:
|
|
33
|
+
result = z3.RealVal(1)
|
|
34
|
+
for factor in value.args:
|
|
35
|
+
result *= convert(factor)
|
|
36
|
+
return result
|
|
37
|
+
if value.is_Pow:
|
|
38
|
+
base, exponent = value.args
|
|
39
|
+
if not exponent.is_Integer or exponent < 0:
|
|
40
|
+
raise NotImplementedError(
|
|
41
|
+
"Z3 conversion supports nonnegative integer powers."
|
|
42
|
+
)
|
|
43
|
+
return convert(base) ** int(exponent)
|
|
44
|
+
if value.func is sp.Abs and allow_trigonometric_bounds:
|
|
45
|
+
argument = convert(value.args[0])
|
|
46
|
+
return z3.If(argument >= 0, argument, -argument)
|
|
47
|
+
if value.func in (sp.sin, sp.cos) and allow_trigonometric_bounds:
|
|
48
|
+
if value in functions:
|
|
49
|
+
return functions[value]
|
|
50
|
+
auxiliary = z3.Real(
|
|
51
|
+
"{}_{}".format(value.func.__name__, len(functions))
|
|
52
|
+
)
|
|
53
|
+
functions[value] = auxiliary
|
|
54
|
+
from .verification.core.trigonometric_z3 import (
|
|
55
|
+
cosine_auxiliary_constraints,
|
|
56
|
+
sine_auxiliary_constraints,
|
|
57
|
+
)
|
|
58
|
+
builder = (
|
|
59
|
+
sine_auxiliary_constraints
|
|
60
|
+
if value.func is sp.sin
|
|
61
|
+
else cosine_auxiliary_constraints
|
|
62
|
+
)
|
|
63
|
+
constraints.extend(builder(
|
|
64
|
+
z3, argument=convert(value.args[0]), auxiliary=auxiliary
|
|
65
|
+
))
|
|
66
|
+
return auxiliary
|
|
67
|
+
if value.is_Function:
|
|
68
|
+
raise NotImplementedError(
|
|
69
|
+
"Z3 supports polynomial arithmetic unless trigonometric "
|
|
70
|
+
"bounds are enabled."
|
|
71
|
+
)
|
|
72
|
+
raise NotImplementedError(
|
|
73
|
+
"Z3 does not support expression {!s}.".format(value)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
return convert(expression)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
__all__ = ["sympy_to_z3"]
|
lyznet/analysis.py
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"""Linearization and quadratic Lyapunov candidate construction."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy import linalg
|
|
7
|
+
import sympy as sp
|
|
8
|
+
|
|
9
|
+
from .systems import ControlAffineSystem
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def homogeneous_degree(system):
|
|
13
|
+
"""Return the exact ordinary degree of a homogeneous vector field.
|
|
14
|
+
|
|
15
|
+
Zero components impose no degree. The positive scale symbol lets SymPy
|
|
16
|
+
simplify radicals such as ``sqrt(scale**6 * p(x))`` without introducing
|
|
17
|
+
an absolute value.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
if isinstance(system, ControlAffineSystem):
|
|
21
|
+
raise TypeError("homogeneous_degree requires an autonomous system.")
|
|
22
|
+
scale = sp.Symbol("_homogeneity_scale", positive=True)
|
|
23
|
+
substitutions = {
|
|
24
|
+
state: scale * state for state in system.states
|
|
25
|
+
}
|
|
26
|
+
common_degree = None
|
|
27
|
+
for expression in system.symbolic.dynamics:
|
|
28
|
+
expression = sp.sympify(expression)
|
|
29
|
+
if expression == 0:
|
|
30
|
+
continue
|
|
31
|
+
scaled = expression.subs(substitutions, simultaneous=True)
|
|
32
|
+
ratio = sp.powsimp(
|
|
33
|
+
sp.powdenest(sp.simplify(scaled / expression), force=True),
|
|
34
|
+
force=True,
|
|
35
|
+
)
|
|
36
|
+
powers = ratio.as_powers_dict()
|
|
37
|
+
degree = sp.simplify(powers.get(scale, 0))
|
|
38
|
+
if degree.free_symbols or sp.simplify(ratio - scale**degree) != 0:
|
|
39
|
+
return None
|
|
40
|
+
if common_degree is None:
|
|
41
|
+
common_degree = degree
|
|
42
|
+
elif sp.simplify(degree - common_degree) != 0:
|
|
43
|
+
return None
|
|
44
|
+
return common_degree
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _real_finite_array(value, label):
|
|
48
|
+
"""Return a copied real floating-point array without lossy conversion."""
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
source = np.asarray(value)
|
|
52
|
+
except (TypeError, ValueError) as error:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
"{} must contain real numeric values.".format(label)
|
|
55
|
+
) from error
|
|
56
|
+
if np.iscomplexobj(source):
|
|
57
|
+
raise ValueError("{} must contain real values.".format(label))
|
|
58
|
+
try:
|
|
59
|
+
array = np.array(source, dtype=float, copy=True)
|
|
60
|
+
except (TypeError, ValueError) as error:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
"{} must contain real numeric values.".format(label)
|
|
63
|
+
) from error
|
|
64
|
+
if not np.all(np.isfinite(array)):
|
|
65
|
+
raise ValueError("{} must contain only finite values.".format(label))
|
|
66
|
+
return array
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _immutable_array(value, label):
|
|
70
|
+
array = np.ascontiguousarray(_real_finite_array(value, label))
|
|
71
|
+
immutable = np.frombuffer(array.tobytes(), dtype=array.dtype)
|
|
72
|
+
return immutable.reshape(array.shape)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _point(point, dimension, label):
|
|
76
|
+
values = (point,) if np.isscalar(point) and dimension == 1 else tuple(point)
|
|
77
|
+
if len(values) != dimension:
|
|
78
|
+
raise ValueError(
|
|
79
|
+
"{} must contain {} values, received {}.".format(
|
|
80
|
+
label, dimension, len(values)
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
return values
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def linearize(system, at=None, input_at=None):
|
|
87
|
+
"""Return the state Jacobian at a declared operating point."""
|
|
88
|
+
|
|
89
|
+
point = _point(
|
|
90
|
+
system.equilibrium if at is None else at,
|
|
91
|
+
system.state_dimension,
|
|
92
|
+
"Linearization point",
|
|
93
|
+
)
|
|
94
|
+
_real_finite_array(point, "Linearization point")
|
|
95
|
+
substitutions = dict(zip(system.states, point))
|
|
96
|
+
|
|
97
|
+
if isinstance(system, ControlAffineSystem):
|
|
98
|
+
control = _point(
|
|
99
|
+
system.equilibrium_input if input_at is None else input_at,
|
|
100
|
+
system.input_dimension,
|
|
101
|
+
"Linearization input",
|
|
102
|
+
)
|
|
103
|
+
_real_finite_array(control, "Linearization input")
|
|
104
|
+
dynamics = (
|
|
105
|
+
system.symbolic.drift
|
|
106
|
+
+ system.symbolic.input_matrix * sp.ImmutableMatrix(control)
|
|
107
|
+
)
|
|
108
|
+
jacobian = dynamics.jacobian(system.states).subs(substitutions)
|
|
109
|
+
else:
|
|
110
|
+
if input_at is not None:
|
|
111
|
+
raise ValueError("input_at applies only to a control-affine system.")
|
|
112
|
+
jacobian = system.symbolic.jacobian.subs(substitutions)
|
|
113
|
+
|
|
114
|
+
if jacobian.free_symbols:
|
|
115
|
+
names = ", ".join(sorted(str(symbol) for symbol in jacobian.free_symbols))
|
|
116
|
+
raise ValueError(
|
|
117
|
+
"Linearization still contains free symbols: {}.".format(names)
|
|
118
|
+
)
|
|
119
|
+
return _real_finite_array(jacobian.tolist(), "Linearization")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _positive_definite_matrix(value, dimension, label):
|
|
123
|
+
matrix = _real_finite_array(value, label)
|
|
124
|
+
if matrix.shape != (dimension, dimension):
|
|
125
|
+
raise ValueError(
|
|
126
|
+
"{} must have shape ({}, {}), received {}.".format(
|
|
127
|
+
label, dimension, dimension, matrix.shape
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
if not np.array_equal(matrix, matrix.T):
|
|
131
|
+
raise ValueError(
|
|
132
|
+
"{} must be exactly symmetric after conversion to float.".format(label)
|
|
133
|
+
)
|
|
134
|
+
if np.min(np.linalg.eigvalsh(matrix)) <= 0:
|
|
135
|
+
raise ValueError("{} must be positive definite.".format(label))
|
|
136
|
+
return matrix
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _require_small_residual(residual, terms):
|
|
140
|
+
"""Check the floating-point Lyapunov equation solution explicitly."""
|
|
141
|
+
|
|
142
|
+
scale = max(
|
|
143
|
+
1.0,
|
|
144
|
+
sum(float(np.linalg.norm(term, ord=np.inf)) for term in terms),
|
|
145
|
+
)
|
|
146
|
+
tolerance = 1e-12 + 1e-9 * scale
|
|
147
|
+
residual_norm = float(np.linalg.norm(residual, ord=np.inf))
|
|
148
|
+
if residual_norm > tolerance:
|
|
149
|
+
raise ValueError(
|
|
150
|
+
"Lyapunov equation residual {:.3g} exceeds the numerical "
|
|
151
|
+
"tolerance {:.3g}.".format(residual_norm, tolerance)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass(frozen=True)
|
|
156
|
+
class QuadraticLyapunov:
|
|
157
|
+
"""Numerical data for ``V(x)=(x-centre)^T P (x-centre)``."""
|
|
158
|
+
|
|
159
|
+
matrix: np.ndarray
|
|
160
|
+
q_matrix: np.ndarray
|
|
161
|
+
linearization: np.ndarray
|
|
162
|
+
centre: np.ndarray
|
|
163
|
+
|
|
164
|
+
def __post_init__(self):
|
|
165
|
+
for name in ("matrix", "q_matrix", "linearization", "centre"):
|
|
166
|
+
object.__setattr__(
|
|
167
|
+
self, name, _immutable_array(getattr(self, name), name)
|
|
168
|
+
)
|
|
169
|
+
if (
|
|
170
|
+
self.matrix.ndim != 2
|
|
171
|
+
or self.matrix.shape[0] < 1
|
|
172
|
+
or self.matrix.shape[0] != self.matrix.shape[1]
|
|
173
|
+
):
|
|
174
|
+
raise ValueError("matrix must be a nonempty square matrix.")
|
|
175
|
+
dimension = self.matrix.shape[0]
|
|
176
|
+
for name, shape in (
|
|
177
|
+
("q_matrix", (dimension, dimension)),
|
|
178
|
+
("linearization", (dimension, dimension)),
|
|
179
|
+
("centre", (dimension,)),
|
|
180
|
+
):
|
|
181
|
+
if getattr(self, name).shape != shape:
|
|
182
|
+
raise ValueError("{} must have shape {}.".format(name, shape))
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def state_dimension(self):
|
|
186
|
+
return self.matrix.shape[0]
|
|
187
|
+
|
|
188
|
+
def value(self, states):
|
|
189
|
+
values = np.asarray(states)
|
|
190
|
+
if values.ndim == 0 or values.shape[-1] != self.state_dimension:
|
|
191
|
+
raise ValueError(
|
|
192
|
+
"States must have shape (..., {}).".format(self.state_dimension)
|
|
193
|
+
)
|
|
194
|
+
shifted = values - self.centre
|
|
195
|
+
return np.einsum(
|
|
196
|
+
"...i,ij,...j->...", shifted, self.matrix, shifted
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def residual(self):
|
|
201
|
+
return (
|
|
202
|
+
self.linearization.T @ self.matrix
|
|
203
|
+
+ self.matrix @ self.linearization
|
|
204
|
+
+ self.q_matrix
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@dataclass(frozen=True)
|
|
209
|
+
class QuadraticControlLyapunov:
|
|
210
|
+
"""Numerical data for an LQR quadratic control-Lyapunov function."""
|
|
211
|
+
|
|
212
|
+
matrix: np.ndarray
|
|
213
|
+
q_matrix: np.ndarray
|
|
214
|
+
r_matrix: np.ndarray
|
|
215
|
+
linearization: np.ndarray
|
|
216
|
+
input_linearization: np.ndarray
|
|
217
|
+
centre: np.ndarray
|
|
218
|
+
|
|
219
|
+
def __post_init__(self):
|
|
220
|
+
for name in (
|
|
221
|
+
"matrix",
|
|
222
|
+
"q_matrix",
|
|
223
|
+
"r_matrix",
|
|
224
|
+
"linearization",
|
|
225
|
+
"input_linearization",
|
|
226
|
+
"centre",
|
|
227
|
+
):
|
|
228
|
+
object.__setattr__(
|
|
229
|
+
self, name, _immutable_array(getattr(self, name), name)
|
|
230
|
+
)
|
|
231
|
+
if (
|
|
232
|
+
self.matrix.ndim != 2
|
|
233
|
+
or self.matrix.shape[0] < 1
|
|
234
|
+
or self.matrix.shape[0] != self.matrix.shape[1]
|
|
235
|
+
):
|
|
236
|
+
raise ValueError("matrix must be a nonempty square matrix.")
|
|
237
|
+
if (
|
|
238
|
+
self.r_matrix.ndim != 2
|
|
239
|
+
or self.r_matrix.shape[0] < 1
|
|
240
|
+
or self.r_matrix.shape[0] != self.r_matrix.shape[1]
|
|
241
|
+
):
|
|
242
|
+
raise ValueError("r_matrix must be a nonempty square matrix.")
|
|
243
|
+
state_dimension = self.matrix.shape[0]
|
|
244
|
+
input_dimension = self.r_matrix.shape[0]
|
|
245
|
+
for name, shape in (
|
|
246
|
+
("matrix", (state_dimension, state_dimension)),
|
|
247
|
+
("q_matrix", (state_dimension, state_dimension)),
|
|
248
|
+
("r_matrix", (input_dimension, input_dimension)),
|
|
249
|
+
("linearization", (state_dimension, state_dimension)),
|
|
250
|
+
(
|
|
251
|
+
"input_linearization",
|
|
252
|
+
(state_dimension, input_dimension),
|
|
253
|
+
),
|
|
254
|
+
("centre", (state_dimension,)),
|
|
255
|
+
):
|
|
256
|
+
if getattr(self, name).shape != shape:
|
|
257
|
+
raise ValueError("{} must have shape {}.".format(name, shape))
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def state_dimension(self):
|
|
261
|
+
return self.matrix.shape[0]
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def input_dimension(self):
|
|
265
|
+
return self.r_matrix.shape[0]
|
|
266
|
+
|
|
267
|
+
@property
|
|
268
|
+
def gain(self):
|
|
269
|
+
"""Return the LQR feedback under ``u-u*=K(x-x*)``."""
|
|
270
|
+
|
|
271
|
+
return -np.linalg.solve(
|
|
272
|
+
self.r_matrix,
|
|
273
|
+
self.input_linearization.T @ self.matrix,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
@property
|
|
277
|
+
def residual(self):
|
|
278
|
+
feedback = (
|
|
279
|
+
self.matrix
|
|
280
|
+
@ self.input_linearization
|
|
281
|
+
@ np.linalg.solve(
|
|
282
|
+
self.r_matrix,
|
|
283
|
+
self.input_linearization.T @ self.matrix,
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
return (
|
|
287
|
+
self.linearization.T @ self.matrix
|
|
288
|
+
+ self.matrix @ self.linearization
|
|
289
|
+
- feedback
|
|
290
|
+
+ self.q_matrix
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def value(self, states):
|
|
294
|
+
values = np.asarray(states)
|
|
295
|
+
if values.ndim == 0 or values.shape[-1] != self.state_dimension:
|
|
296
|
+
raise ValueError(
|
|
297
|
+
"States must have shape (..., {}).".format(
|
|
298
|
+
self.state_dimension
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
shifted = values - self.centre
|
|
302
|
+
return np.einsum(
|
|
303
|
+
"...i,ij,...j->...", shifted, self.matrix, shifted
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def quadratic_lyapunov(system, q_matrix=None, at=None):
|
|
308
|
+
"""Construct a quadratic candidate from a stable linearization.
|
|
309
|
+
|
|
310
|
+
This function performs numerical checks only. Formal verification of the
|
|
311
|
+
nonlinear system is a separate operation.
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
if isinstance(system, ControlAffineSystem):
|
|
315
|
+
raise TypeError(
|
|
316
|
+
"quadratic_lyapunov requires an autonomous system; close the "
|
|
317
|
+
"loop first for a controlled plant."
|
|
318
|
+
)
|
|
319
|
+
centre = _real_finite_array(
|
|
320
|
+
_point(
|
|
321
|
+
system.equilibrium if at is None else at,
|
|
322
|
+
system.state_dimension,
|
|
323
|
+
"Lyapunov centre",
|
|
324
|
+
),
|
|
325
|
+
"Lyapunov centre",
|
|
326
|
+
)
|
|
327
|
+
linearization = linearize(system, at=centre)
|
|
328
|
+
dimension = system.state_dimension
|
|
329
|
+
q_matrix = _positive_definite_matrix(
|
|
330
|
+
np.eye(dimension) if q_matrix is None else q_matrix,
|
|
331
|
+
dimension,
|
|
332
|
+
"q_matrix",
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
# Candidate construction:
|
|
336
|
+
#
|
|
337
|
+
# A^T P + P A = -Q, Q > 0.
|
|
338
|
+
#
|
|
339
|
+
# A Hurwitz matrix gives the unique solution P > 0.
|
|
340
|
+
if np.max(np.real(np.linalg.eigvals(linearization))) >= 0:
|
|
341
|
+
raise ValueError("The continuous-time linearization is not Hurwitz.")
|
|
342
|
+
try:
|
|
343
|
+
matrix = linalg.solve_continuous_lyapunov(
|
|
344
|
+
linearization.T, -q_matrix
|
|
345
|
+
)
|
|
346
|
+
except linalg.LinAlgError as error:
|
|
347
|
+
raise ValueError(
|
|
348
|
+
"The continuous Lyapunov equation could not be solved numerically."
|
|
349
|
+
) from error
|
|
350
|
+
|
|
351
|
+
matrix = _real_finite_array(matrix, "Lyapunov solution")
|
|
352
|
+
matrix = 0.5 * (matrix + matrix.T)
|
|
353
|
+
if np.min(np.linalg.eigvalsh(matrix)) <= 0:
|
|
354
|
+
raise ValueError("The Lyapunov solution is not positive definite.")
|
|
355
|
+
terms = (
|
|
356
|
+
linearization.T @ matrix,
|
|
357
|
+
matrix @ linearization,
|
|
358
|
+
q_matrix,
|
|
359
|
+
)
|
|
360
|
+
_require_small_residual(sum(terms), terms)
|
|
361
|
+
return QuadraticLyapunov(
|
|
362
|
+
matrix=matrix,
|
|
363
|
+
q_matrix=q_matrix,
|
|
364
|
+
linearization=linearization,
|
|
365
|
+
centre=centre,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def quadratic_control_lyapunov(system, q_matrix=None, r_matrix=None):
|
|
370
|
+
"""Construct a continuous-time LQR quadratic CLF at equilibrium."""
|
|
371
|
+
|
|
372
|
+
if not isinstance(system, ControlAffineSystem):
|
|
373
|
+
raise TypeError(
|
|
374
|
+
"quadratic_control_lyapunov requires a control-affine system."
|
|
375
|
+
)
|
|
376
|
+
q_matrix = _positive_definite_matrix(
|
|
377
|
+
np.eye(system.state_dimension) if q_matrix is None else q_matrix,
|
|
378
|
+
system.state_dimension,
|
|
379
|
+
"q_matrix",
|
|
380
|
+
)
|
|
381
|
+
r_matrix = _positive_definite_matrix(
|
|
382
|
+
np.eye(system.input_dimension) if r_matrix is None else r_matrix,
|
|
383
|
+
system.input_dimension,
|
|
384
|
+
"r_matrix",
|
|
385
|
+
)
|
|
386
|
+
state_matrix = linearize(system)
|
|
387
|
+
substitutions = dict(zip(system.states, system.equilibrium))
|
|
388
|
+
input_matrix = _real_finite_array(
|
|
389
|
+
system.symbolic.input_matrix.subs(
|
|
390
|
+
substitutions, simultaneous=True
|
|
391
|
+
).tolist(),
|
|
392
|
+
"Input linearization",
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
# Continuous-time algebraic Riccati equation:
|
|
396
|
+
# A.T P + P A - P B R^-1 B.T P + Q = 0.
|
|
397
|
+
try:
|
|
398
|
+
matrix = linalg.solve_continuous_are(
|
|
399
|
+
state_matrix, input_matrix, q_matrix, r_matrix
|
|
400
|
+
)
|
|
401
|
+
except linalg.LinAlgError as error:
|
|
402
|
+
raise ValueError(
|
|
403
|
+
"The linearization is not stabilizable for these LQR weights."
|
|
404
|
+
) from error
|
|
405
|
+
matrix = _real_finite_array(
|
|
406
|
+
0.5 * (matrix + matrix.T), "Riccati solution"
|
|
407
|
+
)
|
|
408
|
+
if np.min(np.linalg.eigvalsh(matrix)) <= 0:
|
|
409
|
+
raise ValueError("The Riccati solution must be positive definite.")
|
|
410
|
+
feedback_term = (
|
|
411
|
+
matrix
|
|
412
|
+
@ input_matrix
|
|
413
|
+
@ np.linalg.solve(r_matrix, input_matrix.T @ matrix)
|
|
414
|
+
)
|
|
415
|
+
residual_terms = (
|
|
416
|
+
state_matrix.T @ matrix,
|
|
417
|
+
matrix @ state_matrix,
|
|
418
|
+
-feedback_term,
|
|
419
|
+
q_matrix,
|
|
420
|
+
)
|
|
421
|
+
_require_small_residual(sum(residual_terms), residual_terms)
|
|
422
|
+
gain = -np.linalg.solve(r_matrix, input_matrix.T @ matrix)
|
|
423
|
+
closed_loop = state_matrix + input_matrix @ gain
|
|
424
|
+
if np.max(np.real(np.linalg.eigvals(closed_loop))) >= 0:
|
|
425
|
+
raise ValueError("The LQR closed-loop linearization is not Hurwitz.")
|
|
426
|
+
return QuadraticControlLyapunov(
|
|
427
|
+
matrix=matrix,
|
|
428
|
+
q_matrix=q_matrix,
|
|
429
|
+
r_matrix=r_matrix,
|
|
430
|
+
linearization=state_matrix,
|
|
431
|
+
input_linearization=input_matrix,
|
|
432
|
+
centre=_real_finite_array(system.equilibrium, "LQR centre"),
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
__all__ = [
|
|
437
|
+
"homogeneous_degree",
|
|
438
|
+
"QuadraticControlLyapunov",
|
|
439
|
+
"QuadraticLyapunov",
|
|
440
|
+
"linearize",
|
|
441
|
+
"quadratic_control_lyapunov",
|
|
442
|
+
"quadratic_lyapunov",
|
|
443
|
+
]
|