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
directsd/zpk/zpk.py ADDED
@@ -0,0 +1,400 @@
1
+ """
2
+ Root-list (zero-pole-gain) representation with MATLAB CST/DirectSD semantics.
3
+
4
+ Carries (zeros, poles, gain) through rational arithmetic the way MATLAB's
5
+ zpk objects do — cancellations are EXACT root matching and roots are never
6
+ re-derived from high-degree coefficient arrays (only sums re-root their
7
+ small reduced numerators, mirroring sumzpk.m). This representation is what
8
+ makes MATLAB's polynomial-design pipeline numerically survive: running the
9
+ original ynyd.m/zterm.m/polhinf.m algorithms with a coefficient-array
10
+ pipeline instead fails to cancel Z = E - B*B~/A exactly (yielding spurious
11
+ degree 10/12 results instead of the true degree 3/4), because the shared
12
+ roots of E and B*B~/A only agree to floating-point tolerance rather than
13
+ exactly. Exact root-list cancellation sidesteps that failure mode entirely.
14
+
15
+ Ports: @lti/ctranspose.m (DT), @zpk/minreal.m (via Minreal._reducezp),
16
+ @zpk/minreals.m, @zpk/symmetr.m, sumzpk.m (SISO), root-level sfactor
17
+ (via spectral._sfactor_lti_scipy).
18
+
19
+ This module holds the Zpk class as a single file rather than mirroring
20
+ MATLAB's @zpk/ folder (one file per method): that per-method split is an
21
+ artifact of old-style MATLAB class folders, not evidence that Python
22
+ should split the class the same way — a single class file is the
23
+ idiomatic Python equivalent. `_zpk_snap` lives here alongside it since
24
+ it's a Zpk-construction helper, not a design algorithm.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import numpy as np
30
+
31
+ _SQRT_EPS = float(np.sqrt(np.finfo(float).eps))
32
+
33
+ __all__ = ['Zpk', '_zpk_snap']
34
+
35
+
36
+ def _col(x):
37
+ return np.atleast_1d(np.asarray(x, complex)).ravel()
38
+
39
+
40
+ def _realify(r, tol=_SQRT_EPS):
41
+ r = _col(r).copy()
42
+ m = np.abs(np.imag(r)) < tol
43
+ r[m] = np.real(r[m])
44
+ return r
45
+
46
+
47
+ def _conj_symmetric(r, tol=1e-7):
48
+ """True iff every non-real entry has a conjugate partner (the invariant
49
+ of a real-coefficient rational's root list)."""
50
+ r = _col(r)
51
+ cplx = r[np.abs(np.imag(r)) > tol * (1.0 + np.abs(r))]
52
+ for x in cplx:
53
+ if not np.any(np.abs(cplx - np.conj(x)) < tol * (1.0 + abs(x))):
54
+ return False
55
+ return True
56
+
57
+
58
+ def _others2(a, b, tol=_SQRT_EPS):
59
+ """others.m 2-output semantics: elements of `a` not matched in `b`
60
+ (greedy nearest matching within tol*(1+|.|)), plus the matched ones."""
61
+ a = list(_col(a)); b = list(_col(b))
62
+ rest, common = [], []
63
+ for x in a:
64
+ hit = -1
65
+ best = tol * (1.0 + abs(x))
66
+ for j, y in enumerate(b):
67
+ d = abs(x - y)
68
+ if d < best:
69
+ best = d; hit = j
70
+ if hit >= 0:
71
+ b.pop(hit)
72
+ common.append(x)
73
+ else:
74
+ rest.append(x)
75
+ return np.array(rest, complex), np.array(common, complex)
76
+
77
+
78
+ class Zpk:
79
+ """SISO zero-pole-gain rational: k * prod(x - z_i) / prod(x - p_j)."""
80
+
81
+ __slots__ = ('z', 'p', 'k')
82
+
83
+ def __init__(self, z, p, k):
84
+ self.z = _col(z)
85
+ self.p = _col(p)
86
+ self.k = float(np.real(k))
87
+
88
+ # ── construction / conversion ───────────────────────────────────────
89
+ @staticmethod
90
+ def from_tf(num, den):
91
+ num = np.atleast_1d(np.asarray(num, float)).ravel()
92
+ den = np.atleast_1d(np.asarray(den, float)).ravel()
93
+ # striplz semantics: leading coefficients that are negligible
94
+ # RELATIVE to the polynomial's scale are padding, not degree — a
95
+ # 1e-16-relative leading term would otherwise root into a spurious
96
+ # ~1e13 zero carrying a compensating ~1e-16 gain (as seen in A/E out
97
+ # of _sdh2coef for demo_h2hinf's double-integrator plant).
98
+ _nmax = np.max(np.abs(num)) if num.size else 0.0
99
+ _dmax = np.max(np.abs(den)) if den.size else 0.0
100
+ while len(num) > 1 and abs(num[0]) <= 1e-12 * _nmax:
101
+ num = num[1:]
102
+ while len(den) > 1 and abs(den[0]) <= 1e-12 * _dmax:
103
+ den = den[1:]
104
+ # Trailing noise coefficients are different: the low-order terms of a
105
+ # quasipolynomial can be STRUCTURALLY zero (an exact origin root, e.g.
106
+ # z·(a z² + b z + a) self-adjoint against a palindromic quartic), so
107
+ # a noise-level constant term must be snapped to exact 0 — not
108
+ # dropped — letting np.roots produce an exact origin root that
109
+ # conj_dt's origin bookkeeping handles. Left as ~1e-16 it roots into
110
+ # a ~1e-14 zero that conj_dt inverts into a ~1e13 monster (how the
111
+ # artifact reached A/E in ζ-domain).
112
+ num = num.copy(); den = den.copy()
113
+ num[np.abs(num) <= 1e-12 * _nmax] = 0.0
114
+ den[np.abs(den) <= 1e-12 * _dmax] = 0.0
115
+ num = np.trim_zeros(num, 'f')
116
+ den = np.trim_zeros(den, 'f')
117
+ if len(num) == 0:
118
+ return Zpk([], [], 0.0)
119
+ z = np.roots(num) if len(num) > 1 else np.zeros(0)
120
+ p = np.roots(den) if len(den) > 1 else np.zeros(0)
121
+ return Zpk(z, p, num[0] / den[0])
122
+
123
+ def to_tf(self):
124
+ num = np.real(self.k * np.poly(self.z)) if len(self.z) else np.array([self.k])
125
+ den = np.real(np.poly(self.p)) if len(self.p) else np.array([1.0])
126
+ return np.atleast_1d(num).astype(float), np.atleast_1d(den).astype(float)
127
+
128
+ def copy(self):
129
+ return Zpk(self.z.copy(), self.p.copy(), self.k)
130
+
131
+ def __repr__(self):
132
+ return (f"Zpk(k={self.k:.10g}, z={np.round(self.z, 6)}, "
133
+ f"p={np.round(self.p, 6)})")
134
+
135
+ # ── arithmetic ──────────────────────────────────────────────────────
136
+ def __mul__(self, other):
137
+ if isinstance(other, (int, float)):
138
+ return Zpk(self.z, self.p, self.k * other)
139
+ return Zpk(np.concatenate([self.z, other.z]),
140
+ np.concatenate([self.p, other.p]),
141
+ self.k * other.k)
142
+
143
+ __rmul__ = __mul__
144
+
145
+ def __truediv__(self, other):
146
+ if isinstance(other, (int, float)):
147
+ return Zpk(self.z, self.p, self.k / other)
148
+ if other.k == 0.0:
149
+ raise ZeroDivisionError("Zpk: division by zero system")
150
+ return Zpk(np.concatenate([self.z, other.p]),
151
+ np.concatenate([self.p, other.z]),
152
+ self.k / other.k)
153
+
154
+ def __neg__(self):
155
+ return Zpk(self.z, self.p, -self.k)
156
+
157
+ # ── conjugate H(1/z) — port of @lti/ctranspose.m, discrete branch ───
158
+ def conj_dt(self):
159
+ zj = np.conj(self.z); pj = np.conj(self.p)
160
+ origin_z = np.abs(zj) < 1e-14
161
+ origin_p = np.abs(pj) < 1e-14
162
+ n_oz = int(np.sum(origin_z)); n_op = int(np.sum(origin_p))
163
+ zj = zj[~origin_z]; pj = pj[~origin_p]
164
+ k = self.k
165
+ if len(zj):
166
+ k = k * float(np.real(np.prod(-zj)))
167
+ if len(pj):
168
+ k = k / float(np.real(np.prod(-pj)))
169
+ zj = 1.0 / zj if len(zj) else zj
170
+ pj = 1.0 / pj if len(pj) else pj
171
+ zpow = n_op + len(pj) - (n_oz + len(zj))
172
+ zn = np.concatenate([zj, np.zeros(max(zpow, 0), complex)])
173
+ pn = np.concatenate([pj, np.zeros(max(-zpow, 0), complex)])
174
+ return Zpk(zn, pn, k)
175
+
176
+ # ── minreal — @zpk/minreal.m (REDUCEZP core shared with Minreal) ────
177
+ def minreal(self, tol=_SQRT_EPS):
178
+ from directsd.linalg.minreal import Minreal
179
+ zr, pr = Minreal._reducezp(self.z, self.p, tol)
180
+ return Zpk(zr, pr, self.k)
181
+
182
+ # ── minreals — @zpk/minreals.m (reciprocal-pair-preserving) ─────────
183
+ def minreals(self, tol=_SQRT_EPS, ftype='d'):
184
+ from directsd.polynomial.poln import _extrpair
185
+ from directsd.linalg.minreal import Minreal
186
+ zs, z_rem, z0 = _extrpair(_realify(self.z), ftype, tol)
187
+ ps, p_rem, p0 = _extrpair(_realify(self.p), ftype, tol)
188
+ if len(np.atleast_1d(z_rem)) or len(np.atleast_1d(p_rem)):
189
+ raise ValueError("Zpk.minreals: the function is not symmetric")
190
+ zs = _col(zs); ps = _col(ps)
191
+ c0 = min(z0, p0)
192
+ z0 -= c0; p0 -= c0
193
+ if len(zs) + z0 != len(ps) + p0:
194
+ raise ValueError("Zpk.minreals: incorrect zeros or poles at the origin")
195
+ # cancel common representative roots (each removal also drops the
196
+ # reciprocal partner because zs/ps store one root per pair)
197
+ modified = False
198
+ i = 0
199
+ ps_l = list(ps); zs_l = list(zs)
200
+ while i < len(ps_l):
201
+ if not zs_l:
202
+ break
203
+ A = ps_l[i]
204
+ tolA = max(tol * abs(A), tol)
205
+ d = np.abs(np.array(zs_l) - A)
206
+ if d.min() < tolA:
207
+ modified = True
208
+ targets = [A, np.conj(A)] if abs(np.imag(A)) > 1e-14 else [A]
209
+ zs_l = list(Minreal._remove(np.array(zs_l), targets, 100 * tolA))
210
+ ps_l = list(Minreal._remove(np.array(ps_l), targets, 100 * tolA))
211
+ i = 0
212
+ else:
213
+ i += 1
214
+ if not modified:
215
+ return self.copy()
216
+ zs = np.array(zs_l, complex); ps = np.array(ps_l, complex)
217
+ zz = np.concatenate([zs, 1.0 / zs if len(zs) else zs,
218
+ np.zeros(z0, complex)])
219
+ pp = np.concatenate([ps, 1.0 / ps if len(ps) else ps,
220
+ np.zeros(p0, complex)])
221
+ # Invariant: a real-coefficient rational's root lists are
222
+ # conjugate-symmetric. The reciprocal rebuild can break this when
223
+ # extrpair mistakes a near-unit-circle CONJUGATE pair {c, c~} for
224
+ # reciprocal partners (|c*c~ - 1| small because |c| ~ 1) and keeps
225
+ # only one member — MATLAB's tighter default tolerance rejects that
226
+ # pairing and minreals errors instead: the broken rebuild produces
227
+ # exactly-on-circle complex singles that even MATLAB's own extrpair
228
+ # cannot process.
229
+ if not (_conj_symmetric(zz) and _conj_symmetric(pp)):
230
+ raise ValueError("Zpk.minreals: reduction would break conjugate "
231
+ "symmetry (near-circle conjugate pair mistaken "
232
+ "for a reciprocal pair)")
233
+ return Zpk(zz, pp, self.k)
234
+
235
+ # ── symmetr — @zpk/symmetr.m ('z' default for DT) ───────────────────
236
+ def symmetr(self, ftype='z', tol=1e-2):
237
+ from directsd.polynomial.poln import _extrpair
238
+ zs, z_rem, z0 = _extrpair(_realify(self.z), ftype, tol)
239
+ ps, p_rem, p0 = _extrpair(_realify(self.p), ftype, tol)
240
+ if len(np.atleast_1d(z_rem)) or len(np.atleast_1d(p_rem)):
241
+ raise ValueError("Zpk.symmetr: cannot symmetrize the fraction")
242
+ zs = _col(zs); ps = _col(ps)
243
+ g = min(z0, p0)
244
+ z0 -= g; p0 -= g
245
+ pe = len(ps) + p0 - len(zs) - z0
246
+ if pe != 0:
247
+ raise ValueError("Zpk.symmetr: cannot symmetrize the fraction")
248
+ zz = np.concatenate([zs, 1.0 / zs if len(zs) else zs,
249
+ np.zeros(z0, complex)])
250
+ pp = np.concatenate([ps, 1.0 / ps if len(ps) else ps,
251
+ np.zeros(p0, complex)])
252
+ if not (_conj_symmetric(zz) and _conj_symmetric(pp)):
253
+ raise ValueError("Zpk.symmetr: symmetrization would break "
254
+ "conjugate symmetry")
255
+ return Zpk(zz, pp, self.k)
256
+
257
+ # ── setpoles — @zpk/setpoles.m (SISO) ───────────────────────────────
258
+ def setpoles(self, p, tol=1e-4):
259
+ """Force the pole list to `p`: existing poles matching an entry of
260
+ `p` are snapped to it; EXTRA poles (not in `p`) cancel against the
261
+ nearest zero (analytic cancellation declared by the caller — this
262
+ is how PCancel poles leave L in ynyd.m); missing entries of `p` are
263
+ inserted as pole+zero pairs (function unchanged). DT origin poles
264
+ are never cancelled."""
265
+ F = self.minreal()
266
+ p_rem = list(_col(p))
267
+ pF = list(F.p); zF = list(F.z)
268
+ pFCorr = []
269
+ for i in range(len(pF) - 1, -1, -1):
270
+ if not p_rem:
271
+ break
272
+ d = [abs(q - pF[i]) for q in p_rem]
273
+ j = int(np.argmin(d))
274
+ if d[j] < tol:
275
+ pFCorr.append(p_rem[j])
276
+ pF.pop(i)
277
+ p_rem.pop(j)
278
+ # remaining pF are extra poles: keep origin poles, cancel the rest
279
+ # against the nearest zeros
280
+ lack = []
281
+ for q in pF:
282
+ if abs(q) < np.finfo(float).eps:
283
+ pFCorr.append(q)
284
+ else:
285
+ lack.append(q)
286
+ for q in lack:
287
+ if not zF:
288
+ raise ValueError(f"Zpk.setpoles: initial model contains an "
289
+ f"extra pole {q}")
290
+ d = [abs(zz - q) for zz in zF]
291
+ zF.pop(int(np.argmin(d)))
292
+ # remaining desired poles are new: insert as pole+zero pairs
293
+ zF.extend(p_rem)
294
+ pFCorr.extend(p_rem)
295
+ return Zpk(np.array(zF, complex), np.array(pFCorr, complex), F.k)
296
+
297
+ # ── pole-aware sum — sumzpk.m (SISO, two terms) ─────────────────────
298
+ def zsum(self, other, tol=_SQRT_EPS):
299
+ a = self; b = other
300
+ if a.k == 0.0:
301
+ return b.minreal()
302
+ if b.k == 0.0:
303
+ return a.minreal()
304
+ az = _realify(a.z); ap = _realify(a.p)
305
+ bz = _realify(b.z); bp = _realify(b.p)
306
+ # separate common zeros / poles (kept aside, reattached at the end)
307
+ bz_r, zcommon = _others2(bz, az, tol)
308
+ az_r, _ = _others2(az, zcommon, tol)
309
+ zcommon = np.roots(np.real(np.poly(zcommon))) if len(zcommon) else zcommon
310
+ bp_r, pcommon = _others2(bp, ap, tol)
311
+ ap_r, _ = _others2(ap, pcommon, tol)
312
+ # reduced-part polynomial sum (small degrees; only the numerator of
313
+ # the sum is re-rooted — poles are carried over exactly)
314
+ na = a.k * (np.poly(az_r) if len(az_r) else np.array([1.0]))
315
+ da = np.poly(ap_r) if len(ap_r) else np.array([1.0])
316
+ nb = b.k * (np.poly(bz_r) if len(bz_r) else np.array([1.0]))
317
+ db = np.poly(bp_r) if len(bp_r) else np.array([1.0])
318
+ n = np.polyadd(np.polymul(na, db), np.polymul(nb, da))
319
+ n = np.real(n)
320
+ n = np.trim_zeros(n, 'f')
321
+ if len(n) == 0 or np.max(np.abs(n)) < 1e-300:
322
+ return Zpk([], [], 0.0)
323
+ z_new = np.roots(n) if len(n) > 1 else np.zeros(0)
324
+ p_new = np.concatenate([_col(ap_r), _col(bp_r)])
325
+ s = Zpk(np.concatenate([z_new, _col(zcommon)]),
326
+ np.concatenate([p_new, _col(pcommon)]),
327
+ n[0])
328
+ return s.minreal()
329
+
330
+ # ── spectral factor (root level, joint gain rule) ───────────────────
331
+ def sfactor(self, ftype='d'):
332
+ import scipy.signal as sig
333
+ from directsd.polynomial.spectral import _sfactor_lti_scipy
334
+ _, fs0 = _sfactor_lti_scipy(
335
+ sig.ZerosPolesGain(self.z, self.p, self.k), ftype)
336
+ return Zpk(np.atleast_1d(fs0.zeros), np.atleast_1d(fs0.poles),
337
+ float(np.real(fs0.gain)))
338
+
339
+
340
+ # ---------------------------------------------------------------------------
341
+ # _zpk_snap -- Zpk.from_tf + a setpoles-style pole snap onto a known set
342
+ # (a Zpk-construction helper, not a design algorithm, so it belongs here
343
+ # alongside Zpk itself)
344
+ # ---------------------------------------------------------------------------
345
+
346
+ def _zpk_snap(num, den, known_poles, tol=1e-3):
347
+ """
348
+ Zpk.from_tf + a setpoles-style snap of the pole list onto the
349
+ exactly-known set (MATLAB's setpoles discipline: discretized quantities
350
+ have poles at exp(±λT) for known continuous eigenvalues λ; snapping the
351
+ np.roots-derived copies back onto those exact values is what lets every
352
+ downstream root-list cancellation match exactly).
353
+
354
+ When the whole pole list can be matched one-to-one against the target
355
+ MULTISET (greedy nearest pairing, each target consumed once) within a
356
+ loose 5e-2 tolerance, the list is replaced wholesale — the equivalent of
357
+ setpoles.m's correct→cancel→insert net effect. This is what handles
358
+ high-multiplicity roots: np.roots scatters a (z-1)⁴ factor by
359
+ ±|ε|^(1/4) ≈ 3e-3, far beyond any per-root snap tolerance that would
360
+ still be safe globally, but analytically every pole of these
361
+ discretized quantities IS in the target set, so a full-cover match is
362
+ trustworthy at a loose tolerance (as with double-integrator plants).
363
+ Otherwise only the individually-close poles are snapped (tight tol).
364
+ """
365
+ Z = Zpk.from_tf(num, den)
366
+ kp = np.atleast_1d(np.asarray(known_poles, complex)).ravel()
367
+ if len(kp) and len(Z.p):
368
+ # Greedy nearest pairing with multiset consumption.
369
+ avail = list(kp)
370
+ pairs = [] # (dist, pole_index, target)
371
+ for i, rt in enumerate(Z.p):
372
+ if not avail:
373
+ pairs = None
374
+ break
375
+ d = [abs(rt - t) for t in avail]
376
+ j = int(np.argmin(d))
377
+ pairs.append((d[j], i, avail.pop(j)))
378
+ # Accept the wholesale replacement only when every match is both
379
+ # close (5e-2) and UNAMBIGUOUS — the pole must be much nearer its
380
+ # matched target than any *different* target value. Without the
381
+ # dominance check, small-T problems (where distinct exp(λT) crowd
382
+ # around 1) get legitimately-distinct poles merged (measured:
383
+ # sdahinf(1/(s+1)², T=0.1) → hinfbisec failure → nan).
384
+ def _unambiguous(d, i, t):
385
+ others = np.abs(kp[np.abs(kp - t) > 1e-9] - Z.p[i])
386
+ return len(others) == 0 or d < 0.2 * float(np.min(others))
387
+ p = Z.p.copy()
388
+ if pairs is not None and all(
389
+ d < 5e-2 * (1.0 + abs(t)) and _unambiguous(d, i, t)
390
+ for d, i, t in pairs):
391
+ for _, i, t in pairs:
392
+ p[i] = t
393
+ else:
394
+ for i, rt in enumerate(p):
395
+ d = np.abs(kp - rt)
396
+ j = int(np.argmin(d))
397
+ if d[j] < tol * (1.0 + abs(rt)):
398
+ p[i] = kp[j]
399
+ Z = Zpk(Z.z, p, Z.k)
400
+ return Z