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.
Files changed (41) hide show
  1. directsd/__init__.py +141 -0
  2. directsd/analysis/__init__.py +4 -0
  3. directsd/analysis/charpol.py +89 -0
  4. directsd/analysis/errors.py +185 -0
  5. directsd/analysis/norms.py +630 -0
  6. directsd/design/__init__.py +9 -0
  7. directsd/design/convex.py +620 -0
  8. directsd/design/lifting.py +403 -0
  9. directsd/design/polynomial.py +5925 -0
  10. directsd/examples/__init__.py +0 -0
  11. directsd/examples/_common.py +36 -0
  12. directsd/examples/demos.py +1161 -0
  13. directsd/examples/examples.py +296 -0
  14. directsd/examples/help_examples.py +1043 -0
  15. directsd/glopt/__init__.py +4 -0
  16. directsd/glopt/advanced.py +1658 -0
  17. directsd/glopt/optimize.py +420 -0
  18. directsd/linalg/__init__.py +3 -0
  19. directsd/linalg/linsys.py +66 -0
  20. directsd/linalg/matrices.py +156 -0
  21. directsd/linalg/minreal.py +425 -0
  22. directsd/linalg/riccati.py +197 -0
  23. directsd/polynomial/__init__.py +11 -0
  24. directsd/polynomial/diophantine.py +426 -0
  25. directsd/polynomial/operations.py +247 -0
  26. directsd/polynomial/poln.py +742 -0
  27. directsd/polynomial/spectral.py +368 -0
  28. directsd/polynomial/transforms.py +124 -0
  29. directsd/polynomial/utils.py +449 -0
  30. directsd/sspace/__init__.py +4 -0
  31. directsd/sspace/design.py +1083 -0
  32. directsd/sspace/plant.py +198 -0
  33. directsd/tf/__init__.py +9 -0
  34. directsd/tf/interconnect.py +105 -0
  35. directsd/zpk/__init__.py +1 -0
  36. directsd/zpk/zpk.py +400 -0
  37. directsd_python-0.1.0.dist-info/METADATA +450 -0
  38. directsd_python-0.1.0.dist-info/RECORD +41 -0
  39. directsd_python-0.1.0.dist-info/WHEEL +5 -0
  40. directsd_python-0.1.0.dist-info/licenses/LICENSE +36 -0
  41. 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")