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.
Files changed (50) hide show
  1. lyznet/__init__.py +108 -0
  2. lyznet/_symbolic_z3.py +79 -0
  3. lyznet/analysis.py +443 -0
  4. lyznet/assessment.py +295 -0
  5. lyznet/certificates.py +58 -0
  6. lyznet/constraints.py +58 -0
  7. lyznet/equilibria.py +196 -0
  8. lyznet/homogeneous_learning.py +427 -0
  9. lyznet/homogeneous_workflow.py +121 -0
  10. lyznet/neural.py +337 -0
  11. lyznet/neural_assessment.py +281 -0
  12. lyznet/neural_learning.py +1312 -0
  13. lyznet/neural_options.py +43 -0
  14. lyznet/neural_workflow.py +535 -0
  15. lyznet/plotting.py +290 -0
  16. lyznet/sets.py +243 -0
  17. lyznet/synthesis.py +255 -0
  18. lyznet/systems.py +614 -0
  19. lyznet/verification/__init__.py +33 -0
  20. lyznet/verification/_crown.py +423 -0
  21. lyznet/verification/_level_search.py +38 -0
  22. lyznet/verification/_quadratic_clf.py +85 -0
  23. lyznet/verification/control_lyapunov.py +479 -0
  24. lyznet/verification/core/__init__.py +1 -0
  25. lyznet/verification/core/global_lyapunov_z3.py +102 -0
  26. lyznet/verification/core/global_quadratic_dreal.py +22 -0
  27. lyznet/verification/core/homogeneous_dreal.py +40 -0
  28. lyznet/verification/core/neural_lyapunov_crown.py +271 -0
  29. lyznet/verification/core/quadratic_clf_crown.py +44 -0
  30. lyznet/verification/core/quadratic_clf_dreal.py +54 -0
  31. lyznet/verification/core/quadratic_clf_z3.py +59 -0
  32. lyznet/verification/core/quadratic_crown.py +77 -0
  33. lyznet/verification/core/quadratic_dreal.py +83 -0
  34. lyznet/verification/core/quadratic_z3.py +44 -0
  35. lyznet/verification/core/trigonometric_z3.py +50 -0
  36. lyznet/verification/global_lyapunov.py +297 -0
  37. lyznet/verification/global_quadratic.py +201 -0
  38. lyznet/verification/homogeneous_dreal.py +291 -0
  39. lyznet/verification/neural_crown.py +729 -0
  40. lyznet/verification/quadratic.py +596 -0
  41. lyznet/verification/quadratic_clf_crown.py +248 -0
  42. lyznet/verification/quadratic_clf_dreal.py +264 -0
  43. lyznet/verification/quadratic_crown.py +307 -0
  44. lyznet/verification/quadratic_dreal.py +415 -0
  45. lyznet/workflows.py +367 -0
  46. lyznet-0.1.0.dist-info/METADATA +157 -0
  47. lyznet-0.1.0.dist-info/RECORD +50 -0
  48. lyznet-0.1.0.dist-info/WHEEL +5 -0
  49. lyznet-0.1.0.dist-info/licenses/LICENSE +204 -0
  50. 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
+ ]