DirectSD-Python 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.
- directsd/__init__.py +141 -0
- directsd/analysis/__init__.py +4 -0
- directsd/analysis/charpol.py +89 -0
- directsd/analysis/errors.py +185 -0
- directsd/analysis/norms.py +630 -0
- directsd/design/__init__.py +9 -0
- directsd/design/convex.py +620 -0
- directsd/design/lifting.py +403 -0
- directsd/design/polynomial.py +5925 -0
- directsd/examples/__init__.py +0 -0
- directsd/examples/_common.py +36 -0
- directsd/examples/demos.py +1161 -0
- directsd/examples/examples.py +296 -0
- directsd/examples/help_examples.py +1043 -0
- directsd/glopt/__init__.py +4 -0
- directsd/glopt/advanced.py +1658 -0
- directsd/glopt/optimize.py +420 -0
- directsd/linalg/__init__.py +3 -0
- directsd/linalg/linsys.py +66 -0
- directsd/linalg/matrices.py +156 -0
- directsd/linalg/minreal.py +425 -0
- directsd/linalg/riccati.py +197 -0
- directsd/polynomial/__init__.py +11 -0
- directsd/polynomial/diophantine.py +426 -0
- directsd/polynomial/operations.py +247 -0
- directsd/polynomial/poln.py +742 -0
- directsd/polynomial/spectral.py +368 -0
- directsd/polynomial/transforms.py +124 -0
- directsd/polynomial/utils.py +449 -0
- directsd/sspace/__init__.py +4 -0
- directsd/sspace/design.py +1083 -0
- directsd/sspace/plant.py +198 -0
- directsd/tf/__init__.py +9 -0
- directsd/tf/interconnect.py +105 -0
- directsd/zpk/__init__.py +1 -0
- directsd/zpk/zpk.py +400 -0
- directsd_python-0.1.0.dist-info/METADATA +450 -0
- directsd_python-0.1.0.dist-info/RECORD +41 -0
- directsd_python-0.1.0.dist-info/WHEEL +5 -0
- directsd_python-0.1.0.dist-info/licenses/LICENSE +36 -0
- directsd_python-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Polynomial class for DirectSD — idiomatic Python port of MATLAB @poln.
|
|
3
|
+
|
|
4
|
+
Variables:
|
|
5
|
+
's', 'p' — continuous-time (Laplace / alternative)
|
|
6
|
+
'z', 'q', 'd' — discrete-time (Z-transform / delta / delta-alt)
|
|
7
|
+
|
|
8
|
+
A quasipolynomial in 'z' with shift m represents:
|
|
9
|
+
coef[0]*x^(n-m) + coef[1]*x^(n-1-m) + ... + coef[n]*x^(-m)
|
|
10
|
+
where n = len(coef) - 1.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from typing import Tuple, Union
|
|
17
|
+
|
|
18
|
+
_VALID_VARS = frozenset("spzqd")
|
|
19
|
+
_DT_VARS = frozenset("zqd")
|
|
20
|
+
_CT_VARS = frozenset("sp")
|
|
21
|
+
_TOL = np.sqrt(np.finfo(float).eps)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Coefficient-array utilities
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
def _strip_lz(c: np.ndarray, tol: float = np.finfo(float).eps) -> np.ndarray:
|
|
29
|
+
"""Strip leading near-zero coefficients. Returns at least [0]."""
|
|
30
|
+
c = np.asarray(c, dtype=complex).ravel()
|
|
31
|
+
nrm = np.linalg.norm(c)
|
|
32
|
+
if nrm < max(tol * tol, 1e-300):
|
|
33
|
+
return np.zeros(1)
|
|
34
|
+
while c.size > 1 and abs(c[0]) <= tol * np.linalg.norm(c[1:] if c.size > 1 else c):
|
|
35
|
+
c = c[1:]
|
|
36
|
+
return c
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _real_if_close(c: np.ndarray) -> np.ndarray:
|
|
40
|
+
if np.max(np.abs(np.imag(c))) < _TOL * (np.max(np.abs(np.real(c))) + 1e-300):
|
|
41
|
+
return np.real(c)
|
|
42
|
+
return c
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Root-level helpers (used by coprime / add)
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def _remove_common_roots(
|
|
50
|
+
rA: np.ndarray, rB: np.ndarray, tol: float = _TOL
|
|
51
|
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
52
|
+
"""
|
|
53
|
+
Find roots common to rA and rB (greedy nearest-match).
|
|
54
|
+
Returns (reduced_rA, reduced_rB, common_roots).
|
|
55
|
+
"""
|
|
56
|
+
rA, rB = list(rA), list(rB)
|
|
57
|
+
common: list = []
|
|
58
|
+
i = 0
|
|
59
|
+
while i < len(rA):
|
|
60
|
+
if not rB:
|
|
61
|
+
break
|
|
62
|
+
r = rA[i]
|
|
63
|
+
tolr = max(tol, tol * abs(r))
|
|
64
|
+
dists = [abs(r - b) for b in rB]
|
|
65
|
+
j = int(np.argmin(dists))
|
|
66
|
+
if dists[j] < tolr:
|
|
67
|
+
common.append(r)
|
|
68
|
+
rA.pop(i)
|
|
69
|
+
rB.pop(j)
|
|
70
|
+
else:
|
|
71
|
+
i += 1
|
|
72
|
+
return np.array(rA, dtype=complex), np.array(rB, dtype=complex), np.array(common, dtype=complex)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _others(a: np.ndarray, b: np.ndarray, tol: float = _TOL) -> np.ndarray:
|
|
76
|
+
"""Elements of a that are not in b (greedy nearest-match removal)."""
|
|
77
|
+
a, b = list(a), list(b)
|
|
78
|
+
result: list = []
|
|
79
|
+
for ai in a:
|
|
80
|
+
dists = [abs(ai - bj) for bj in b]
|
|
81
|
+
if not dists or min(dists) >= max(tol, tol * abs(ai)):
|
|
82
|
+
result.append(ai)
|
|
83
|
+
else:
|
|
84
|
+
b.pop(int(np.argmin(dists)))
|
|
85
|
+
return np.array(result, dtype=complex)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Main class
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
class Poln:
|
|
93
|
+
"""
|
|
94
|
+
Polynomial / quasipolynomial for sampled-data control design.
|
|
95
|
+
|
|
96
|
+
Primary storage: ``coef`` (descending-power numpy array).
|
|
97
|
+
A quasipolynomial is stored as a plain polynomial times x^{-shift}.
|
|
98
|
+
|
|
99
|
+
Parameters
|
|
100
|
+
----------
|
|
101
|
+
a : array-like or scalar
|
|
102
|
+
Descending-order coefficients, or roots if ``var`` starts with ``'r'``.
|
|
103
|
+
var : str
|
|
104
|
+
Variable: ``'s'``, ``'p'`` (CT) or ``'z'``, ``'q'``, ``'d'`` (DT).
|
|
105
|
+
Prefix ``'r'`` selects root-input mode, e.g. ``var='rz'``.
|
|
106
|
+
shift : int
|
|
107
|
+
Non-negative delay exponent for DT quasipolynomials.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
# Construction
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def __init__(self, a=0, var: str = "s", shift: int = 0) -> None:
|
|
115
|
+
root_mode = isinstance(var, str) and var.startswith("r")
|
|
116
|
+
if root_mode:
|
|
117
|
+
var = var[1:] or "s"
|
|
118
|
+
|
|
119
|
+
if var not in _VALID_VARS:
|
|
120
|
+
raise ValueError(f"Unknown variable '{var}'. Use one of: s p z q d")
|
|
121
|
+
if var in _CT_VARS and shift > 0:
|
|
122
|
+
raise ValueError("'shift' is only for discrete-time polynomials")
|
|
123
|
+
if shift < 0:
|
|
124
|
+
raise ValueError("'shift' must be non-negative")
|
|
125
|
+
|
|
126
|
+
self.var = var
|
|
127
|
+
self.shift = int(shift)
|
|
128
|
+
|
|
129
|
+
if isinstance(a, Poln):
|
|
130
|
+
self.coef = a.coef.copy()
|
|
131
|
+
self.var = var
|
|
132
|
+
self.shift = a.shift
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
a = np.atleast_1d(np.asarray(a, dtype=complex)).ravel()
|
|
136
|
+
|
|
137
|
+
if root_mode:
|
|
138
|
+
self.coef = _real_if_close(np.poly(a)) if a.size > 0 else np.array([1.0])
|
|
139
|
+
else:
|
|
140
|
+
if a.size == 0:
|
|
141
|
+
a = np.zeros(1, dtype=complex)
|
|
142
|
+
self.coef = _real_if_close(_strip_lz(a.astype(complex)))
|
|
143
|
+
|
|
144
|
+
# ------------------------------------------------------------------
|
|
145
|
+
# Core properties
|
|
146
|
+
# ------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def k(self) -> float:
|
|
150
|
+
"""Leading coefficient."""
|
|
151
|
+
return float(np.real(self.coef[0])) if self.coef.size > 0 else 0.0
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def roots(self) -> np.ndarray:
|
|
155
|
+
"""Roots of the polynomial (computed on demand)."""
|
|
156
|
+
if np.linalg.norm(self.coef) < np.finfo(float).eps or len(self.coef) <= 1:
|
|
157
|
+
return np.array([], dtype=complex)
|
|
158
|
+
return np.roots(self.coef)
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def degree(self) -> int:
|
|
162
|
+
"""Highest positive-power degree. For quasipolynomials: len(coef)-1-shift."""
|
|
163
|
+
if np.linalg.norm(self.coef) < np.finfo(float).eps:
|
|
164
|
+
return 0
|
|
165
|
+
return len(self.coef) - 1 - self.shift
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def deg_neg(self) -> int:
|
|
169
|
+
"""Highest negative-power degree (= shift for quasipolynomials)."""
|
|
170
|
+
if np.linalg.norm(self.coef) < np.finfo(float).eps:
|
|
171
|
+
return 0
|
|
172
|
+
return self.shift
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def is_ct(self) -> bool:
|
|
176
|
+
return self.var in _CT_VARS
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def is_dt(self) -> bool:
|
|
180
|
+
return self.var in _DT_VARS
|
|
181
|
+
|
|
182
|
+
def norm(self) -> float:
|
|
183
|
+
return float(np.linalg.norm(self.coef))
|
|
184
|
+
|
|
185
|
+
# ------------------------------------------------------------------
|
|
186
|
+
# Arithmetic
|
|
187
|
+
# ------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
def __neg__(self) -> Poln:
|
|
190
|
+
return Poln(-self.coef, self.var, self.shift)
|
|
191
|
+
|
|
192
|
+
def __mul__(self, other) -> Poln:
|
|
193
|
+
if isinstance(other, (int, float, complex, np.number)):
|
|
194
|
+
return Poln(float(np.real(other)) * self.coef, self.var, self.shift)
|
|
195
|
+
if isinstance(other, np.ndarray) and other.ndim == 0:
|
|
196
|
+
return self.__mul__(float(other))
|
|
197
|
+
a, b = compat(self, other)
|
|
198
|
+
return Poln(np.polymul(a.coef, b.coef), a.var, a.shift + b.shift)
|
|
199
|
+
|
|
200
|
+
def __rmul__(self, other) -> Poln:
|
|
201
|
+
return self.__mul__(other)
|
|
202
|
+
|
|
203
|
+
def __add__(self, other) -> Poln:
|
|
204
|
+
if isinstance(other, (int, float, complex)):
|
|
205
|
+
other = Poln(np.array([other]), self.var)
|
|
206
|
+
a, b = compat(self, other)
|
|
207
|
+
return _poly_add(a, b)
|
|
208
|
+
|
|
209
|
+
def __radd__(self, other) -> Poln:
|
|
210
|
+
return self.__add__(other)
|
|
211
|
+
|
|
212
|
+
def __sub__(self, other) -> Poln:
|
|
213
|
+
if isinstance(other, (int, float, complex)):
|
|
214
|
+
other = Poln(np.array([other]), self.var)
|
|
215
|
+
return self.__add__(-other)
|
|
216
|
+
|
|
217
|
+
def __rsub__(self, other) -> Poln:
|
|
218
|
+
return (-self).__add__(other)
|
|
219
|
+
|
|
220
|
+
def __pow__(self, n: int) -> Poln:
|
|
221
|
+
n = int(n)
|
|
222
|
+
# Special case: (scalar * x)^n for a pure linear monomial
|
|
223
|
+
r = self.roots
|
|
224
|
+
if self.shift == 0 and self.degree == 1 and r.size == 1 and abs(r[0]) < np.finfo(float).eps:
|
|
225
|
+
if n < 0:
|
|
226
|
+
if self.is_ct:
|
|
227
|
+
raise ValueError("Negative power not admissible for CT polynomials")
|
|
228
|
+
return Poln(np.array([self.k ** n]), self.var, -n)
|
|
229
|
+
if n == 0:
|
|
230
|
+
return Poln(np.array([1.0]), self.var)
|
|
231
|
+
new_z = np.zeros(n, dtype=complex)
|
|
232
|
+
c = float(self.k ** n) * np.poly(new_z)
|
|
233
|
+
return Poln(_real_if_close(c), self.var)
|
|
234
|
+
if n < 0:
|
|
235
|
+
raise ValueError("Negative power not supported for general polynomials")
|
|
236
|
+
if n == 0:
|
|
237
|
+
return Poln(np.array([1.0]), self.var)
|
|
238
|
+
result = self
|
|
239
|
+
for _ in range(n - 1):
|
|
240
|
+
result = result * self
|
|
241
|
+
return result
|
|
242
|
+
|
|
243
|
+
def __truediv__(self, other) -> Poln:
|
|
244
|
+
"""Exact polynomial division. Use quorem() for division with remainder."""
|
|
245
|
+
if isinstance(other, (int, float, complex)):
|
|
246
|
+
return Poln(self.coef / float(np.real(other)), self.var, self.shift)
|
|
247
|
+
q, r = quorem(self, other)
|
|
248
|
+
if r.norm() > _TOL * max(self.norm(), 1e-300):
|
|
249
|
+
raise ValueError("Division is not exact; use quorem() for quotient + remainder")
|
|
250
|
+
return q
|
|
251
|
+
|
|
252
|
+
def __call__(self, x):
|
|
253
|
+
"""Evaluate the (quasi)polynomial at x."""
|
|
254
|
+
if self.shift > 0 and x == 0:
|
|
255
|
+
return float("inf")
|
|
256
|
+
val = np.polyval(self.coef, x)
|
|
257
|
+
if self.shift > 0:
|
|
258
|
+
val = val / (x ** self.shift)
|
|
259
|
+
return float(np.real(val)) if abs(np.imag(complex(x))) < np.finfo(float).eps else val
|
|
260
|
+
|
|
261
|
+
# ------------------------------------------------------------------
|
|
262
|
+
# Control-specific methods
|
|
263
|
+
# ------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
def c2d(self, T: float = 1.0, var: str = "z") -> Poln:
|
|
266
|
+
"""Discretize CT polynomial: roots r → exp(-r*T)."""
|
|
267
|
+
if self.is_dt:
|
|
268
|
+
raise ValueError("Already discrete-time")
|
|
269
|
+
if var not in _DT_VARS:
|
|
270
|
+
raise ValueError(f"Unknown DT variable '{var}'")
|
|
271
|
+
new_z = np.exp(-self.roots * T)
|
|
272
|
+
c = _real_if_close(self.k * np.poly(new_z)) if new_z.size > 0 else np.array([self.k])
|
|
273
|
+
return Poln(c, var, self.shift)
|
|
274
|
+
|
|
275
|
+
def c2z(self, T: float = 1.0, var: str = "z") -> Poln:
|
|
276
|
+
"""Discretize CT polynomial: roots r → exp(r*T)."""
|
|
277
|
+
if self.is_dt:
|
|
278
|
+
raise ValueError("Already discrete-time")
|
|
279
|
+
if var not in _DT_VARS:
|
|
280
|
+
raise ValueError(f"Unknown DT variable '{var}'")
|
|
281
|
+
new_z = np.exp(self.roots * T)
|
|
282
|
+
c = _real_if_close(self.k * np.poly(new_z)) if new_z.size > 0 else np.array([self.k])
|
|
283
|
+
return Poln(c, var, self.shift)
|
|
284
|
+
|
|
285
|
+
def conj_reciprocal(self) -> Poln:
|
|
286
|
+
"""
|
|
287
|
+
Conjugate-reciprocal polynomial (MATLAB ctranspose ``'``).
|
|
288
|
+
|
|
289
|
+
CT (s/p): P'(s) = P(-s) with sign fix for odd degree.
|
|
290
|
+
DT (z/q/d): P'(z) = z^{-(n+m)} * P(1/z), roots r → 1/r.
|
|
291
|
+
"""
|
|
292
|
+
if self.is_ct:
|
|
293
|
+
# Transform: coef → fliplr, negate odd positions, fliplr back.
|
|
294
|
+
# The root map r → -r and this coefficient op are equivalent.
|
|
295
|
+
# The odd-degree sign lives inside the transformed coef; no extra -1 needed.
|
|
296
|
+
c = np.flipud(self.coef.copy())
|
|
297
|
+
c[1::2] = -c[1::2]
|
|
298
|
+
c = np.flipud(c)
|
|
299
|
+
return Poln(c, self.var, self.shift)
|
|
300
|
+
else:
|
|
301
|
+
z = self.roots
|
|
302
|
+
ind_zero = np.where(np.abs(z) < np.finfo(float).eps)[0]
|
|
303
|
+
z_nz = np.delete(z, ind_zero)
|
|
304
|
+
k_new = float(np.real(self.k * np.prod(-z_nz))) if z_nz.size > 0 else self.k
|
|
305
|
+
z_new = 1.0 / z_nz if z_nz.size > 0 else z_nz
|
|
306
|
+
shift_new = int(len(z_nz) + len(ind_zero) - self.shift)
|
|
307
|
+
if shift_new < 0:
|
|
308
|
+
z_new = np.concatenate([z_new, np.zeros(-shift_new)])
|
|
309
|
+
shift_new = 0
|
|
310
|
+
c = _real_if_close(k_new * np.poly(z_new)) if z_new.size > 0 else np.array([k_new])
|
|
311
|
+
if shift_new + 1 > len(c):
|
|
312
|
+
c = np.concatenate([np.zeros(shift_new + 1 - len(c)), c])
|
|
313
|
+
return Poln(c, self.var, shift_new)
|
|
314
|
+
|
|
315
|
+
def reciprocal(self) -> Poln:
|
|
316
|
+
"""Reciprocal polynomial (reverse coefficient order, strip leading zeros)."""
|
|
317
|
+
if self.shift > 0:
|
|
318
|
+
raise ValueError("reciprocal() is not applicable to quasipolynomials")
|
|
319
|
+
return Poln(_real_if_close(_strip_lz(self.coef[::-1])), self.var)
|
|
320
|
+
|
|
321
|
+
def shift_by(self, n: int) -> Poln:
|
|
322
|
+
"""Return self * x^n (n may be negative for DT quasipolynomials)."""
|
|
323
|
+
n = int(n)
|
|
324
|
+
if n == 0:
|
|
325
|
+
return Poln(self.coef.copy(), self.var, self.shift)
|
|
326
|
+
new_shift = self.shift - n
|
|
327
|
+
if n > 0 and new_shift < 0:
|
|
328
|
+
extra = np.zeros(-new_shift, dtype=self.coef.dtype)
|
|
329
|
+
new_coef = np.concatenate([self.coef, extra])
|
|
330
|
+
new_shift = 0
|
|
331
|
+
elif not self.is_dt and n < 0:
|
|
332
|
+
z = self.roots
|
|
333
|
+
ind_zero = np.where(np.abs(z) < np.finfo(float).eps)[0]
|
|
334
|
+
need = -new_shift
|
|
335
|
+
if len(ind_zero) < need:
|
|
336
|
+
raise ValueError("shift_by(x^-N) requires enough zero roots for CT polynomials")
|
|
337
|
+
z_new = np.delete(z, ind_zero[:need])
|
|
338
|
+
new_coef = (_real_if_close(self.k * np.poly(z_new)) if z_new.size > 0
|
|
339
|
+
else np.array([self.k]))
|
|
340
|
+
new_shift = 0
|
|
341
|
+
else:
|
|
342
|
+
new_coef = self.coef.copy()
|
|
343
|
+
return Poln(new_coef, self.var, max(0, new_shift))
|
|
344
|
+
|
|
345
|
+
def common_den(self, other: Poln) -> Poln:
|
|
346
|
+
"""Polynomial with the union of roots from self and other (LCM denominator)."""
|
|
347
|
+
a, b = compat(self, other)
|
|
348
|
+
extra = _others(b.roots, a.roots)
|
|
349
|
+
all_poles = np.concatenate([a.roots, extra])
|
|
350
|
+
if all_poles.size == 0:
|
|
351
|
+
return Poln(np.array([1.0]), a.var)
|
|
352
|
+
return Poln(all_poles, "r" + a.var)
|
|
353
|
+
|
|
354
|
+
def derivative(self, n: int = 1) -> Poln:
|
|
355
|
+
"""n-th derivative polynomial."""
|
|
356
|
+
p = self
|
|
357
|
+
for _ in range(n):
|
|
358
|
+
d = p.degree
|
|
359
|
+
if d == 0:
|
|
360
|
+
return Poln(np.array([0.0]), self.var)
|
|
361
|
+
new_coef = np.array([(d - i) * c for i, c in enumerate(p.coef[:-1])], dtype=complex)
|
|
362
|
+
p = Poln(_real_if_close(new_coef), self.var)
|
|
363
|
+
return p
|
|
364
|
+
|
|
365
|
+
def monic(self) -> Poln:
|
|
366
|
+
"""Return monic version (leading coefficient = 1)."""
|
|
367
|
+
if abs(self.k) < 1e-14:
|
|
368
|
+
return Poln(np.array([0.0]), self.var, self.shift)
|
|
369
|
+
return Poln(self.coef / self.k, self.var, self.shift)
|
|
370
|
+
|
|
371
|
+
def quorem(self, other: Union[Poln, float]) -> Tuple[Poln, Poln]:
|
|
372
|
+
"""Quotient and remainder: self = q*other + r, deg(r) < deg(other)."""
|
|
373
|
+
return quorem(self, other)
|
|
374
|
+
|
|
375
|
+
def sfactor(self, ftype: str | None = None) -> Poln:
|
|
376
|
+
"""Stable spectral factor of a Hermitian polynomial."""
|
|
377
|
+
return sfactor(self, ftype)
|
|
378
|
+
|
|
379
|
+
# ------------------------------------------------------------------
|
|
380
|
+
# Display
|
|
381
|
+
# ------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
def __repr__(self) -> str:
|
|
384
|
+
return f"Poln({list(np.round(self.coef, 10))}, var='{self.var}', shift={self.shift})"
|
|
385
|
+
|
|
386
|
+
def __str__(self) -> str:
|
|
387
|
+
return _poly_to_str(self)
|
|
388
|
+
|
|
389
|
+
# ------------------------------------------------------------------
|
|
390
|
+
# Equality
|
|
391
|
+
# ------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
def __eq__(self, other) -> bool:
|
|
394
|
+
if not isinstance(other, Poln):
|
|
395
|
+
return False
|
|
396
|
+
return (self.var == other.var and self.shift == other.shift
|
|
397
|
+
and np.allclose(self.coef, other.coef))
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# ---------------------------------------------------------------------------
|
|
401
|
+
# Module-level functions (mirror MATLAB calling convention)
|
|
402
|
+
# ---------------------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
def compat(*polys) -> tuple:
|
|
405
|
+
"""
|
|
406
|
+
Coerce all arguments to share the same variable.
|
|
407
|
+
|
|
408
|
+
The first Poln argument's variable wins; scalars are promoted.
|
|
409
|
+
"""
|
|
410
|
+
var = None
|
|
411
|
+
for p in polys:
|
|
412
|
+
if isinstance(p, Poln):
|
|
413
|
+
var = p.var
|
|
414
|
+
break
|
|
415
|
+
if var is None:
|
|
416
|
+
return tuple(polys)
|
|
417
|
+
result = []
|
|
418
|
+
for p in polys:
|
|
419
|
+
if isinstance(p, Poln):
|
|
420
|
+
result.append(p if p.var == var else Poln(p.coef, var, p.shift))
|
|
421
|
+
else:
|
|
422
|
+
result.append(Poln(np.atleast_1d(np.asarray(p, dtype=float)), var))
|
|
423
|
+
return tuple(result)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def coprime(
|
|
427
|
+
a: Poln, b: Poln, tol: float = _TOL
|
|
428
|
+
) -> Tuple[Poln, Poln, Poln]:
|
|
429
|
+
"""
|
|
430
|
+
Cancel common roots of two polynomials.
|
|
431
|
+
|
|
432
|
+
Returns (a/gcd, b/gcd, gcd) where gcd is monic.
|
|
433
|
+
"""
|
|
434
|
+
a, b = compat(a, b)
|
|
435
|
+
var = a.var
|
|
436
|
+
g = Poln(np.array([1.0]), var)
|
|
437
|
+
if a.degree < 1 or a.norm() < 1e-10:
|
|
438
|
+
return a, b, g
|
|
439
|
+
if b.degree < 1 or b.norm() < 1e-10:
|
|
440
|
+
return a, b, g
|
|
441
|
+
rA, rB, rG = _remove_common_roots(a.roots, b.roots, tol)
|
|
442
|
+
if rG.size == 0:
|
|
443
|
+
return a, b, g
|
|
444
|
+
kA, kB = a.k, b.k
|
|
445
|
+
cA = _real_if_close(kA * np.poly(rA)) if rA.size > 0 else np.array([kA])
|
|
446
|
+
cB = _real_if_close(kB * np.poly(rB)) if rB.size > 0 else np.array([kB])
|
|
447
|
+
cG = _real_if_close(np.poly(rG)) if rG.size > 0 else np.array([1.0])
|
|
448
|
+
return (
|
|
449
|
+
Poln(cA, var, a.shift),
|
|
450
|
+
Poln(cB, var, b.shift),
|
|
451
|
+
Poln(cG, var),
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def gcd(a: Poln, b: Poln) -> Poln:
|
|
456
|
+
"""Monic greatest common divisor of two polynomials."""
|
|
457
|
+
_, _, g = coprime(a, b)
|
|
458
|
+
return g
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def quorem(a: Poln, b: Union[Poln, float]) -> Tuple[Poln, Poln]:
|
|
462
|
+
"""
|
|
463
|
+
Polynomial division with remainder: a = q*b + r, deg(r) < deg(b).
|
|
464
|
+
|
|
465
|
+
Returns (quotient, remainder).
|
|
466
|
+
"""
|
|
467
|
+
if isinstance(b, (int, float, complex)):
|
|
468
|
+
return Poln(a.coef / float(np.real(b)), a.var, a.shift), Poln(np.array([0.0]), a.var)
|
|
469
|
+
|
|
470
|
+
a, b = compat(a, b)
|
|
471
|
+
shift_a, shift_b = a.shift, b.shift
|
|
472
|
+
|
|
473
|
+
# Work on plain (shift=0) polynomials
|
|
474
|
+
pa = Poln(a.coef.copy(), a.var)
|
|
475
|
+
pb = Poln(b.coef.copy(), b.var)
|
|
476
|
+
pa, pb, _ = coprime(pa, pb)
|
|
477
|
+
|
|
478
|
+
if pb.degree > 0:
|
|
479
|
+
q_coef, r_coef = np.polydiv(pa.coef, pb.coef)
|
|
480
|
+
q = Poln(_real_if_close(_strip_lz(q_coef)), a.var)
|
|
481
|
+
r = Poln(_real_if_close(_strip_lz(r_coef)), a.var)
|
|
482
|
+
else:
|
|
483
|
+
q = Poln(_real_if_close(pa.coef / pb.k), a.var)
|
|
484
|
+
r = Poln(np.array([0.0]), a.var)
|
|
485
|
+
|
|
486
|
+
q = q.shift_by(shift_b - shift_a)
|
|
487
|
+
r = r.shift_by(-shift_a)
|
|
488
|
+
return q, r
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def sfactor(p: Poln, ftype: str | None = None) -> Poln:
|
|
492
|
+
"""
|
|
493
|
+
Stable spectral factor of a Hermitian polynomial.
|
|
494
|
+
|
|
495
|
+
Finds ``fs`` such that ``p ≈ fs * fs.conj_reciprocal()``.
|
|
496
|
+
``ftype``: ``'s'`` for CT (default), ``'z'`` or ``'d'`` for DT.
|
|
497
|
+
"""
|
|
498
|
+
if ftype is None:
|
|
499
|
+
ftype = "d" if p.is_dt else "s"
|
|
500
|
+
if ftype not in ("s", "z", "d"):
|
|
501
|
+
raise ValueError(f"Unknown factorization type '{ftype}'")
|
|
502
|
+
if abs(p.k) < np.finfo(float).eps:
|
|
503
|
+
return Poln(np.array([0.0]), p.var)
|
|
504
|
+
|
|
505
|
+
zs, z_rem, z0 = _extrpair(p.roots, ftype)
|
|
506
|
+
if z_rem.size > 0 or z0 > 0:
|
|
507
|
+
raise ValueError("Exact Hermitian factorization is impossible (unpaired roots remain)")
|
|
508
|
+
|
|
509
|
+
if ftype == "s":
|
|
510
|
+
K = float(np.real(p.k))
|
|
511
|
+
if len(zs) % 2 == 1:
|
|
512
|
+
K = -K
|
|
513
|
+
else:
|
|
514
|
+
K = float(np.real(p.k / np.prod(-zs))) if zs.size > 0 else float(np.real(p.k))
|
|
515
|
+
|
|
516
|
+
if K < 0:
|
|
517
|
+
raise ValueError("Exact Hermitian factorization is impossible (negative gain)")
|
|
518
|
+
|
|
519
|
+
c = _real_if_close(np.sqrt(K) * np.poly(zs)) if zs.size > 0 else np.array([np.sqrt(K)])
|
|
520
|
+
return Poln(c, p.var)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _extrpair(
|
|
524
|
+
z: np.ndarray, ftype: str, tol: float = 1e-4
|
|
525
|
+
) -> Tuple[np.ndarray, np.ndarray, int]:
|
|
526
|
+
"""
|
|
527
|
+
Extract symmetric root pairs.
|
|
528
|
+
|
|
529
|
+
Port of MATLAB `extrpair.m` (K. Polyakov) — full 3-output form, including
|
|
530
|
+
the "zeros at the origin" count needed by `minreals`-style symmetric
|
|
531
|
+
minimal realizations. Previously only the 2-output (`zs`, unpaired
|
|
532
|
+
non-origin roots) form was ported here, which silently treated any root
|
|
533
|
+
exactly at the origin as an unresolvable "unpaired" failure (`sfactor`'s
|
|
534
|
+
only caller raises whenever anything comes back in that slot) instead of
|
|
535
|
+
counting it separately, as MATLAB does.
|
|
536
|
+
|
|
537
|
+
's': pairs z_i + z_j ≈ 0; stable root has Re < 0.
|
|
538
|
+
'z': pairs z_i*z_j ≈ 1; stable root has |z| < 1.
|
|
539
|
+
'd': pairs z_i*z_j ≈ 1; stable root has |z| > 1.
|
|
540
|
+
|
|
541
|
+
Returns
|
|
542
|
+
-------
|
|
543
|
+
zs : ndarray
|
|
544
|
+
One "stable" representative per extracted symmetric pair.
|
|
545
|
+
z_rem : ndarray
|
|
546
|
+
Unpaired, nonzero leftover roots (empty for a genuinely symmetric input).
|
|
547
|
+
z0 : int
|
|
548
|
+
Count of remaining roots at the origin (removed from `z_rem`).
|
|
549
|
+
"""
|
|
550
|
+
z = list(np.asarray(z, dtype=complex).ravel())
|
|
551
|
+
z.sort(key=lambda zi: -abs(zi))
|
|
552
|
+
zs: list = []
|
|
553
|
+
while len(z) >= 2:
|
|
554
|
+
min_dist = np.inf
|
|
555
|
+
i_min = j_min = -1
|
|
556
|
+
for i in range(len(z)):
|
|
557
|
+
for j in range(len(z)):
|
|
558
|
+
if i == j:
|
|
559
|
+
continue
|
|
560
|
+
dij = abs(z[i] + z[j]) if ftype == "s" else abs(z[i] * z[j] - 1)
|
|
561
|
+
# MATLAB: exclude i as a candidate "iMin" only when z[i] is
|
|
562
|
+
# complex and exactly on the unit circle, UNLESS it's close
|
|
563
|
+
# to the point z=1 specifically (line 49-51 of extrpair.m):
|
|
564
|
+
# `abs(z(i)) == 1` -- a LITERAL bitwise equality against
|
|
565
|
+
# 1.0, not a tolerance check. Porting this
|
|
566
|
+
# as `abs(abs(z[i])-1) < eps` looks equivalent but ISN'T --
|
|
567
|
+
# MATLAB's `==1` is true only for a value that IS exactly
|
|
568
|
+
# 1.0 to the last bit (never true for a genuine unit-circle
|
|
569
|
+
# eigenvalue computed via `eig`/`expm`, which always carries
|
|
570
|
+
# ~1e-16-level floating-point noise), so in real MATLAB
|
|
571
|
+
# usage this exclusion never actually fires. The `<eps`
|
|
572
|
+
# tolerance version DOES fire for such noise-level
|
|
573
|
+
# deviations (e.g. abs(z)=0.9999999999999999, off from 1.0
|
|
574
|
+
# by 1.11e-16 < eps=2.22e-16) -- exactly the case for a
|
|
575
|
+
# genuine on-unit-circle conjugate pair with no other root
|
|
576
|
+
# available as an alternative pairing anchor, where BOTH
|
|
577
|
+
# members get excluded (each is complex and "on-circle") and
|
|
578
|
+
# neither can ever become `i_min`, so the pair is wrongly
|
|
579
|
+
# rejected as unpaired (`_sdh2coef`'s "could not symmetrize
|
|
580
|
+
# A1" warning, `test_sdahinf_returns_controller` et al.).
|
|
581
|
+
# Matching MATLAB's literal `==1` here (never true for a
|
|
582
|
+
# computed value) is not a bug retained on purpose -- MATLAB
|
|
583
|
+
# never intended this branch to be reachable in practice.
|
|
584
|
+
near_one = abs(z[i] - 1) < tol
|
|
585
|
+
on_unit_circle_complex = (
|
|
586
|
+
abs(z[i]) == 1.0
|
|
587
|
+
and abs(np.imag(z[i])) > np.finfo(float).eps
|
|
588
|
+
)
|
|
589
|
+
excluded = on_unit_circle_complex and not near_one
|
|
590
|
+
if not excluded and dij < min_dist:
|
|
591
|
+
i_min, j_min, min_dist = i, j, dij
|
|
592
|
+
if i_min < 0 or (min_dist > tol and min_dist > tol * abs(z[i_min])):
|
|
593
|
+
break
|
|
594
|
+
zi, zj = z[i_min], z[j_min]
|
|
595
|
+
if ftype == "s":
|
|
596
|
+
chosen = zi if np.real(zi) < 0 else zj
|
|
597
|
+
elif ftype == "z":
|
|
598
|
+
chosen = zi if abs(zi) < 1 else zj
|
|
599
|
+
else:
|
|
600
|
+
chosen = zi if abs(zi) > 1 else zj
|
|
601
|
+
zs.append(chosen)
|
|
602
|
+
for idx in sorted([i_min, j_min], reverse=True):
|
|
603
|
+
z.pop(idx)
|
|
604
|
+
|
|
605
|
+
# Post-pass: any complex entry in `zs` whose nearest match (by distance
|
|
606
|
+
# to its own conjugate) is itself has no genuine conjugate partner in
|
|
607
|
+
# `zs` — force it real, matching MATLAB's "unpaired complex" cleanup
|
|
608
|
+
# (extrpair.m lines 75-86).
|
|
609
|
+
zs_arr = np.array(zs, dtype=complex)
|
|
610
|
+
for i in range(len(zs_arr)):
|
|
611
|
+
if np.imag(zs_arr[i]) == 0:
|
|
612
|
+
continue
|
|
613
|
+
dists = np.abs(zs_arr - np.conj(zs_arr[i]))
|
|
614
|
+
if int(np.argmin(dists)) == i:
|
|
615
|
+
zs_arr[i] = np.real(zs_arr[i])
|
|
616
|
+
|
|
617
|
+
# Zeros at the origin: counted and removed separately, not left in the
|
|
618
|
+
# "unpaired" leftover list (extrpair.m lines 87-95).
|
|
619
|
+
z_arr = np.array(z, dtype=complex)
|
|
620
|
+
origin_mask = np.abs(z_arr) < tol
|
|
621
|
+
z0 = int(np.sum(origin_mask))
|
|
622
|
+
z_rem = z_arr[~origin_mask]
|
|
623
|
+
|
|
624
|
+
return zs_arr, z_rem, z0
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
# ---------------------------------------------------------------------------
|
|
628
|
+
# Polynomial addition (private — handles quasipolynomial shifts and GCD)
|
|
629
|
+
# ---------------------------------------------------------------------------
|
|
630
|
+
|
|
631
|
+
def _poly_add(a: Poln, b: Poln) -> Poln:
|
|
632
|
+
"""Add two Poln objects with the same variable. Handles quasipolynomial shifts."""
|
|
633
|
+
eps = np.finfo(float).eps
|
|
634
|
+
an, bn = a.norm(), b.norm()
|
|
635
|
+
if an < eps * (bn + eps) and bn < eps * (an + eps):
|
|
636
|
+
return Poln(np.array([0.0]), a.var)
|
|
637
|
+
if an < eps * (bn + eps):
|
|
638
|
+
return b
|
|
639
|
+
if bn < eps * (an + eps):
|
|
640
|
+
return a
|
|
641
|
+
|
|
642
|
+
# Extract GCD to improve numerical stability
|
|
643
|
+
ac, bc, x = coprime(a, b)
|
|
644
|
+
|
|
645
|
+
# Align quasipolynomial degrees (MATLAB plus.m logic)
|
|
646
|
+
pA, mA = ac.degree, ac.deg_neg
|
|
647
|
+
pB, mB = bc.degree, bc.deg_neg
|
|
648
|
+
p = max(pA, pB)
|
|
649
|
+
m = max(mA, mB)
|
|
650
|
+
|
|
651
|
+
cA = np.concatenate([np.zeros(p - pA), ac.coef, np.zeros(m - mA)])
|
|
652
|
+
cB = np.concatenate([np.zeros(p - pB), bc.coef, np.zeros(m - mB)])
|
|
653
|
+
c_sum = cA + cB
|
|
654
|
+
|
|
655
|
+
nrm = np.linalg.norm(c_sum)
|
|
656
|
+
if nrm > 0:
|
|
657
|
+
c_sum[np.abs(c_sum) < _TOL * nrm] = 0.0
|
|
658
|
+
c_sum = _strip_lz(c_sum)
|
|
659
|
+
|
|
660
|
+
result_coef = np.polymul(_real_if_close(c_sum), x.coef)
|
|
661
|
+
result = Poln(_real_if_close(result_coef), a.var, m)
|
|
662
|
+
return _zeroing(result)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _zeroing(p: Poln) -> Poln:
|
|
666
|
+
"""Cancel zero roots against shift (reduces quasipolynomial trailing terms)."""
|
|
667
|
+
eps = np.finfo(float).eps
|
|
668
|
+
if p.norm() < eps or p.shift == 0:
|
|
669
|
+
return p
|
|
670
|
+
z = p.roots
|
|
671
|
+
ind_zero = np.where(np.abs(z) < eps)[0]
|
|
672
|
+
n_cancel = min(p.shift, len(ind_zero))
|
|
673
|
+
if n_cancel == 0:
|
|
674
|
+
return p
|
|
675
|
+
new_coef = p.coef[:-n_cancel]
|
|
676
|
+
new_shift = p.shift - n_cancel
|
|
677
|
+
return Poln(_real_if_close(new_coef), p.var, new_shift)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
# ---------------------------------------------------------------------------
|
|
681
|
+
# String representation
|
|
682
|
+
# ---------------------------------------------------------------------------
|
|
683
|
+
|
|
684
|
+
def _poly_to_str(p: Poln) -> str:
|
|
685
|
+
coef = p.coef
|
|
686
|
+
n = len(coef)
|
|
687
|
+
deg_top = n - 1 - p.shift
|
|
688
|
+
parts = []
|
|
689
|
+
for i, ci in enumerate(coef):
|
|
690
|
+
cur_deg = deg_top - i
|
|
691
|
+
ci_r = float(np.real(ci))
|
|
692
|
+
if abs(ci_r) < 1e-12:
|
|
693
|
+
continue
|
|
694
|
+
sign = "- " if ci_r < 0 else ("+ " if parts else "")
|
|
695
|
+
val = abs(ci_r)
|
|
696
|
+
show_num = abs(val - 1.0) > 1e-12 or cur_deg == 0
|
|
697
|
+
term = sign
|
|
698
|
+
if show_num:
|
|
699
|
+
term += f"{val:g}"
|
|
700
|
+
if cur_deg != 0:
|
|
701
|
+
term += (" " if show_num else "") + p.var
|
|
702
|
+
if cur_deg != 1:
|
|
703
|
+
term += f"^{cur_deg}"
|
|
704
|
+
parts.append(term.rstrip())
|
|
705
|
+
return " ".join(parts) if parts else "0"
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
# ---------------------------------------------------------------------------
|
|
709
|
+
# Backward-compatibility aliases (used by operations.py and other modules)
|
|
710
|
+
# ---------------------------------------------------------------------------
|
|
711
|
+
_DISCRETE_VARS = _DT_VARS # old name
|
|
712
|
+
_CONTINUOUS_VARS = _CT_VARS # old name
|
|
713
|
+
_VALID_VARS = frozenset("spzqd") # re-export (was set, now frozenset — same values)
|
|
714
|
+
|
|
715
|
+
def _findzero(coef, tol=np.sqrt(np.finfo(float).eps)):
|
|
716
|
+
"""Alias for _strip_lz kept for backward compatibility."""
|
|
717
|
+
return _strip_lz(np.asarray(coef, dtype=complex), tol=tol)
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
# ---------------------------------------------------------------------------
|
|
721
|
+
# Convenience constructors
|
|
722
|
+
# ---------------------------------------------------------------------------
|
|
723
|
+
|
|
724
|
+
def s_var() -> Poln:
|
|
725
|
+
"""Elementary polynomial: s."""
|
|
726
|
+
return Poln([1, 0], "s")
|
|
727
|
+
|
|
728
|
+
def z_var() -> Poln:
|
|
729
|
+
"""Elementary polynomial: z."""
|
|
730
|
+
return Poln([1, 0], "z")
|
|
731
|
+
|
|
732
|
+
def q_var() -> Poln:
|
|
733
|
+
"""Elementary polynomial: q."""
|
|
734
|
+
return Poln([1, 0], "q")
|
|
735
|
+
|
|
736
|
+
def p_var() -> Poln:
|
|
737
|
+
"""Elementary polynomial: p."""
|
|
738
|
+
return Poln([1, 0], "p")
|
|
739
|
+
|
|
740
|
+
def d_var() -> Poln:
|
|
741
|
+
"""Elementary polynomial: d."""
|
|
742
|
+
return Poln([1, 0], "d")
|