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
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
|