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,368 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Spectral factorization for polynomials and transfer functions.
|
|
3
|
+
|
|
4
|
+
Ports of: sfactor.m (root-level LTI), sfactfft.m
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import scipy.optimize as opt
|
|
11
|
+
from typing import Tuple, Union
|
|
12
|
+
|
|
13
|
+
from directsd.polynomial.poln import Poln, _extrpair, _real_if_close, _strip_lz
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def sfactor(
|
|
17
|
+
s: Union[Poln, np.ndarray, float, int],
|
|
18
|
+
ftype: str | None = None,
|
|
19
|
+
):
|
|
20
|
+
"""
|
|
21
|
+
Spectral factorization of a polynomial or LTI transfer function.
|
|
22
|
+
|
|
23
|
+
Finds ``fs`` such that ``fs * fs~ ≈ s`` (Hermitian / spectral factorization).
|
|
24
|
+
Neutral zeros/poles are assigned to the stable factor.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
s : Poln, array-like, or scipy ZerosPolesGain / lti
|
|
29
|
+
ftype : {'s', 'z', 'd'}, optional
|
|
30
|
+
Factorization type. Inferred from *s* if omitted.
|
|
31
|
+
|
|
32
|
+
Returns
|
|
33
|
+
-------
|
|
34
|
+
fs : spectral factor (minimal realization)
|
|
35
|
+
fs0 : spectral factor before pole-zero cancellation
|
|
36
|
+
"""
|
|
37
|
+
# Promote scalars / arrays to Poln
|
|
38
|
+
if isinstance(s, (int, float, complex)):
|
|
39
|
+
s = Poln(np.array([float(s)]))
|
|
40
|
+
if isinstance(s, np.ndarray):
|
|
41
|
+
s = Poln(s.ravel())
|
|
42
|
+
|
|
43
|
+
if isinstance(s, Poln):
|
|
44
|
+
if ftype is None:
|
|
45
|
+
ftype = "d" if s.is_dt else "s"
|
|
46
|
+
fs = _sfactor_poln(s, ftype)
|
|
47
|
+
return fs, fs # both the same (polynomial has no pole-zero cancellation)
|
|
48
|
+
|
|
49
|
+
# scipy LTI / ZPK systems
|
|
50
|
+
try:
|
|
51
|
+
import scipy.signal as sig
|
|
52
|
+
if isinstance(s, (sig.lti, sig.dlti, sig.ZerosPolesGain)):
|
|
53
|
+
return _sfactor_lti_scipy(s, ftype)
|
|
54
|
+
except ImportError:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
raise TypeError(f"sfactor: unsupported input type {type(s)}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Polynomial spectral factorization — delegates to the correct implementation
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
def _sfactor_poln(p: Poln, ftype: str) -> Poln:
|
|
65
|
+
"""
|
|
66
|
+
Call the canonical polynomial spectral factorization from poln.py.
|
|
67
|
+
|
|
68
|
+
That implementation uses ``_extrpair`` and the correct MATLAB gain formula.
|
|
69
|
+
"""
|
|
70
|
+
from directsd.polynomial.poln import sfactor as _poly_sfactor
|
|
71
|
+
return _poly_sfactor(p, ftype)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# LTI (ZPK) spectral factorization — port of root-level sfactor.m
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def _sfactor_lti_scipy(sys, ftype: str | None):
|
|
79
|
+
"""
|
|
80
|
+
Spectral factorization of a scipy LTI / ZerosPolesGain system.
|
|
81
|
+
|
|
82
|
+
Port of the root-level sfactor.m (K. Polyakov).
|
|
83
|
+
"""
|
|
84
|
+
import scipy.signal as sig
|
|
85
|
+
|
|
86
|
+
# Convert to ZPK so we can access zeros, poles, gain
|
|
87
|
+
if isinstance(sys, sig.ZerosPolesGain):
|
|
88
|
+
zpk = sys
|
|
89
|
+
elif hasattr(sys, 'to_zpk'):
|
|
90
|
+
zpk = sys.to_zpk()
|
|
91
|
+
else:
|
|
92
|
+
# wrap in ZerosPolesGain
|
|
93
|
+
zpk = sig.ZerosPolesGain(*sig.tf2zpk(sys.num, sys.den))
|
|
94
|
+
|
|
95
|
+
z_all = np.asarray(zpk.zeros, dtype=complex).ravel()
|
|
96
|
+
p_all = np.asarray(zpk.poles, dtype=complex).ravel()
|
|
97
|
+
K = float(np.real(zpk.gain))
|
|
98
|
+
|
|
99
|
+
# Infer ftype from sample time if not given
|
|
100
|
+
if ftype is None:
|
|
101
|
+
Ts = getattr(zpk, 'dt', 0) or 0
|
|
102
|
+
ftype = 'd' if Ts else 's'
|
|
103
|
+
|
|
104
|
+
if K == 0.0:
|
|
105
|
+
fs0 = sig.ZerosPolesGain([], [], 0.0)
|
|
106
|
+
return fs0, fs0
|
|
107
|
+
|
|
108
|
+
errmsg = "Exact Hermitian factorization is impossible"
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
# Symmetrize zeros (1 % tolerance, matching MATLAB sfactor.m)
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
z_symm = _symmetrize_zeros(z_all, ftype, tol=1e-2)
|
|
114
|
+
|
|
115
|
+
# ------------------------------------------------------------------
|
|
116
|
+
# Extract symmetric root pairs
|
|
117
|
+
# ------------------------------------------------------------------
|
|
118
|
+
zs, z_rem, n_z0 = _extrpair(z_symm, ftype)
|
|
119
|
+
ps, p_rem, n_p0 = _extrpair(p_all, ftype)
|
|
120
|
+
|
|
121
|
+
# Unpaired zeros must equal unpaired poles (they cancel in ratio)
|
|
122
|
+
if z_rem.size != p_rem.size:
|
|
123
|
+
raise ValueError(errmsg)
|
|
124
|
+
if z_rem.size > 0:
|
|
125
|
+
from directsd.polynomial.poln import _others
|
|
126
|
+
if _others(z_rem, p_rem, 1e-2).size > 0:
|
|
127
|
+
raise ValueError(errmsg)
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
# Check conjugation of complex zeros (sfactor.m lines 128-134): a
|
|
131
|
+
# complex zero selected into the factor WITHOUT its conjugate partner
|
|
132
|
+
# is realified. This happens e.g. for a conjugate pair ON the unit
|
|
133
|
+
# circle ({c, conj(c)} with |c| = 1): the two members are each other's
|
|
134
|
+
# reciprocal partners, so extrpair keeps only one — leaving a factor
|
|
135
|
+
# with complex coefficients unless it is snapped to its real part.
|
|
136
|
+
# (Without this fixup, Λ comes out complex for spectral densities
|
|
137
|
+
# touching zero on the circle, corrupting every downstream root-list
|
|
138
|
+
# operation.)
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
zs = np.asarray(zs, dtype=complex).copy()
|
|
141
|
+
_ctol = 1e-9
|
|
142
|
+
for _i in range(len(zs)):
|
|
143
|
+
_zc = zs[_i]
|
|
144
|
+
if abs(np.imag(_zc)) > _ctol:
|
|
145
|
+
_has_conj = np.any(np.abs(zs - np.conj(_zc)) < _ctol * (1.0 + abs(_zc)))
|
|
146
|
+
if not _has_conj:
|
|
147
|
+
zs[_i] = np.real(_zc)
|
|
148
|
+
|
|
149
|
+
# ------------------------------------------------------------------
|
|
150
|
+
# Gain calculation
|
|
151
|
+
# ------------------------------------------------------------------
|
|
152
|
+
n_zs, n_ps = len(zs), len(ps)
|
|
153
|
+
if ftype == 's':
|
|
154
|
+
K_f = float(np.real(K))
|
|
155
|
+
if (n_zs + n_ps) % 2 == 1:
|
|
156
|
+
K_f = -K_f
|
|
157
|
+
else:
|
|
158
|
+
# DT: K * prod(-ps) / prod(-zs)
|
|
159
|
+
if n_zs + n_z0 != n_ps + n_p0:
|
|
160
|
+
raise ValueError(errmsg)
|
|
161
|
+
prod_ps = float(np.real(np.prod(-ps))) if n_ps > 0 else 1.0
|
|
162
|
+
prod_zs = float(np.real(np.prod(-zs))) if n_zs > 0 else 1.0
|
|
163
|
+
K_f = float(np.real(K * prod_ps / prod_zs))
|
|
164
|
+
|
|
165
|
+
if K_f < 0:
|
|
166
|
+
raise ValueError("Function is negative definite; spectral factorization impossible")
|
|
167
|
+
|
|
168
|
+
# ------------------------------------------------------------------
|
|
169
|
+
# Build result
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
Ts = getattr(zpk, 'dt', None)
|
|
172
|
+
if Ts:
|
|
173
|
+
fs0 = sig.ZerosPolesGain(zs, ps, float(np.sqrt(K_f)), dt=Ts)
|
|
174
|
+
else:
|
|
175
|
+
fs0 = sig.ZerosPolesGain(zs, ps, float(np.sqrt(K_f)))
|
|
176
|
+
|
|
177
|
+
# Minimal realisation: cancel common zeros/poles
|
|
178
|
+
try:
|
|
179
|
+
fs_tf = sig.ZerosPolesGain(*sig.tf2zpk(*sig.zpk2tf(zs, ps, np.sqrt(K_f))))
|
|
180
|
+
fs = fs_tf
|
|
181
|
+
except Exception:
|
|
182
|
+
fs = fs0
|
|
183
|
+
|
|
184
|
+
return fs, fs0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _symmetrize_zeros(
|
|
188
|
+
z: np.ndarray, ftype: str, tol: float = 1e-2
|
|
189
|
+
) -> np.ndarray:
|
|
190
|
+
"""
|
|
191
|
+
Average each zero with its Hermitian mirror to enforce exact symmetry.
|
|
192
|
+
|
|
193
|
+
Port of the symmetrization block in sfactor.m (K. Polyakov).
|
|
194
|
+
"""
|
|
195
|
+
eps = np.sqrt(np.finfo(float).eps)
|
|
196
|
+
zz = list(z.copy())
|
|
197
|
+
out: list = []
|
|
198
|
+
|
|
199
|
+
while zz:
|
|
200
|
+
z0 = zz.pop(0)
|
|
201
|
+
# sfactor.m lines 65-72: the Hermitian mirror is -z0 ('s') / 1/z0
|
|
202
|
+
# (DT) — NOT the conjugate-reciprocal. Using 1/conj(z0) here paired
|
|
203
|
+
# each root with the WRONG member of its conjugate quadruple, and
|
|
204
|
+
# the (1/z0 + z1)/2 average then cancelled the imaginary parts —
|
|
205
|
+
# silently realifying every complex quadruple that passed through
|
|
206
|
+
# sfactor (Λ then comes out with a double real root where MATLAB
|
|
207
|
+
# keeps the complex pair).
|
|
208
|
+
if ftype == 's':
|
|
209
|
+
z0H = -z0
|
|
210
|
+
elif abs(z0) > eps:
|
|
211
|
+
z0H = 1.0 / z0
|
|
212
|
+
else:
|
|
213
|
+
out.append(z0)
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
if not zz:
|
|
217
|
+
out.append(z0)
|
|
218
|
+
break
|
|
219
|
+
|
|
220
|
+
diffs = np.abs(np.array(zz) - z0H)
|
|
221
|
+
idx = int(np.argmin(diffs))
|
|
222
|
+
close_enough = (diffs[idx] < tol * (abs(z0) + abs(z0H)) / 2
|
|
223
|
+
or diffs[idx] < eps)
|
|
224
|
+
|
|
225
|
+
if close_enough:
|
|
226
|
+
z1 = zz.pop(idx)
|
|
227
|
+
# Handle real-vs-complex mismatch
|
|
228
|
+
if (np.imag(z0) == 0) != (np.imag(z1) == 0):
|
|
229
|
+
z0 = np.real(z0)
|
|
230
|
+
z1 = np.real(z1)
|
|
231
|
+
if ftype == 's':
|
|
232
|
+
za = (z0 - z1) / 2
|
|
233
|
+
out.extend([za, -za])
|
|
234
|
+
else:
|
|
235
|
+
za = (1.0 / z0 + z1) / 2
|
|
236
|
+
out.extend([za, 1.0 / za])
|
|
237
|
+
else:
|
|
238
|
+
out.append(z0)
|
|
239
|
+
|
|
240
|
+
out.extend(zz)
|
|
241
|
+
return np.array(out, dtype=complex)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
# FFT-based spectral factorization — port of sfactfft.m (K. Polyakov)
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
def sfactfft(
|
|
251
|
+
p: Union[Poln, np.ndarray],
|
|
252
|
+
ftype: str = 'd',
|
|
253
|
+
N: int = 10,
|
|
254
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
255
|
+
"""
|
|
256
|
+
Polynomial spectral factorization using FFT / cepstrum (Hromcik's method).
|
|
257
|
+
|
|
258
|
+
Parameters
|
|
259
|
+
----------
|
|
260
|
+
p : Poln or array-like
|
|
261
|
+
Symmetric quasipolynomial (DT only: var in {'z','d','q'}).
|
|
262
|
+
ftype : {'d', 'z'}, optional
|
|
263
|
+
Factorization type. Default is 'd'.
|
|
264
|
+
N : int, optional
|
|
265
|
+
Zero-padding multiplier; FFT length = ``N * len(coef)``. Default 10.
|
|
266
|
+
|
|
267
|
+
Returns
|
|
268
|
+
-------
|
|
269
|
+
fp : np.ndarray
|
|
270
|
+
Stable spectral factor coefficients.
|
|
271
|
+
fm : np.ndarray
|
|
272
|
+
Unstable (conjugate) spectral factor, ``fm = fp[::-1]``.
|
|
273
|
+
|
|
274
|
+
References
|
|
275
|
+
----------
|
|
276
|
+
Hromcik, Jezek, Sebek (ECC'2001).
|
|
277
|
+
"""
|
|
278
|
+
if ftype not in ('z', 'd'):
|
|
279
|
+
raise ValueError(f"sfactfft: unknown type '{ftype}'; must be 'z' or 'd'")
|
|
280
|
+
|
|
281
|
+
poly_mode = isinstance(p, Poln)
|
|
282
|
+
if poly_mode:
|
|
283
|
+
var = p.var
|
|
284
|
+
if var not in ('z', 'd', 'q'):
|
|
285
|
+
raise ValueError("sfactfft: only applicable to DT polynomials (z, d, q)")
|
|
286
|
+
p0 = np.asarray(p.coef, dtype=float)
|
|
287
|
+
else:
|
|
288
|
+
p0 = np.asarray(p, dtype=float).ravel()
|
|
289
|
+
|
|
290
|
+
n_coef = len(p0)
|
|
291
|
+
|
|
292
|
+
# --- Half degree and zero-padding ---
|
|
293
|
+
dg = (n_coef - 1) // 2
|
|
294
|
+
R = N * n_coef
|
|
295
|
+
n_zeros = 2 * R + 1 - n_coef
|
|
296
|
+
|
|
297
|
+
# Build the circular correlation sequence:
|
|
298
|
+
# [c_0, c_1, ..., c_dg, 0, ..., 0, c_{-dg}, ..., c_{-1}]
|
|
299
|
+
# In descending p0: c_k = p0[dg - k]; negative lags use p0[:dg]
|
|
300
|
+
p_circ = np.concatenate([
|
|
301
|
+
p0[:dg + 1][::-1], # [c_0, c_1, ..., c_dg] (= p0[dg], ..., p0[0])
|
|
302
|
+
np.zeros(n_zeros),
|
|
303
|
+
p0[:dg], # [c_{-dg}, ..., c_{-1}] (= p0[0], ..., p0[dg-1])
|
|
304
|
+
])
|
|
305
|
+
|
|
306
|
+
# --- FFT I ---
|
|
307
|
+
P = np.fft.fft(p_circ)
|
|
308
|
+
|
|
309
|
+
# --- Log ---
|
|
310
|
+
N_log = np.log(P) # complex log
|
|
311
|
+
|
|
312
|
+
# --- IFFT I (cepstrum) ---
|
|
313
|
+
n_cep = np.fft.ifft(N_log)
|
|
314
|
+
xp = n_cep[:R + 1].copy()
|
|
315
|
+
xp[0] = n_cep[0] / 2 # half the DC term
|
|
316
|
+
|
|
317
|
+
# --- FFT II → exp → IFFT II ---
|
|
318
|
+
Xp = np.fft.fft(xp)
|
|
319
|
+
Pvp = np.exp(Xp)
|
|
320
|
+
pvp = np.fft.ifft(Pvp)
|
|
321
|
+
|
|
322
|
+
# --- Truncate to degree dg ---
|
|
323
|
+
fp = np.real(pvp[:dg + 1])
|
|
324
|
+
|
|
325
|
+
# --- Type-specific flip ---
|
|
326
|
+
if ftype[0] == 'd':
|
|
327
|
+
fp = fp[::-1]
|
|
328
|
+
|
|
329
|
+
# --- Optional refinement via scipy.optimize ---
|
|
330
|
+
# (Mirrors MATLAB's fminunc call when length(type) > 1)
|
|
331
|
+
if len(ftype) > 1:
|
|
332
|
+
def _obj(x: np.ndarray) -> float:
|
|
333
|
+
x = np.asarray(x, dtype=float)
|
|
334
|
+
p1 = np.convolve(x, x[::-1])
|
|
335
|
+
# Align with p0 (subtract and compute norm)
|
|
336
|
+
e = _sumpol2(p1, -p0)
|
|
337
|
+
return float(np.linalg.norm(e))
|
|
338
|
+
|
|
339
|
+
result = opt.minimize(_obj, fp, method='L-BFGS-B',
|
|
340
|
+
options={'disp': False})
|
|
341
|
+
if result.success:
|
|
342
|
+
fp = result.x
|
|
343
|
+
|
|
344
|
+
fm = fp[::-1].copy()
|
|
345
|
+
|
|
346
|
+
# --- Restore Poln form ---
|
|
347
|
+
if poly_mode:
|
|
348
|
+
fp_out = Poln(fp, var)
|
|
349
|
+
fm_out = Poln(fm, var)
|
|
350
|
+
return fp_out, fm_out
|
|
351
|
+
|
|
352
|
+
return fp, fm
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# ---------------------------------------------------------------------------
|
|
356
|
+
# Helpers
|
|
357
|
+
# ---------------------------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
def _sumpol2(a: np.ndarray, b: np.ndarray) -> np.ndarray:
|
|
360
|
+
"""Add two polynomials of possibly different length (zero-pads shorter)."""
|
|
361
|
+
la, lb = len(a), len(b)
|
|
362
|
+
if la >= lb:
|
|
363
|
+
out = a.copy().astype(float)
|
|
364
|
+
out[la - lb:] += b
|
|
365
|
+
else:
|
|
366
|
+
out = b.copy().astype(float)
|
|
367
|
+
out[lb - la:] += a
|
|
368
|
+
return out
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Discrete transforms for sampled-data systems.
|
|
3
|
+
|
|
4
|
+
Ports of: ztrm (modified Z-transform), dtfm (discrete Laplace with hold)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def dtfm(G, T, hold='zoh', method='residue'):
|
|
11
|
+
"""
|
|
12
|
+
Compute discrete-time transfer function with a hold from a continuous-time
|
|
13
|
+
transfer function G(s) (modified Z-transform / pulse transfer function).
|
|
14
|
+
|
|
15
|
+
Parameters
|
|
16
|
+
----------
|
|
17
|
+
G : scipy.signal.lti or (num, den) tuple
|
|
18
|
+
Continuous-time transfer function.
|
|
19
|
+
T : float
|
|
20
|
+
Sampling period.
|
|
21
|
+
hold : str
|
|
22
|
+
'zoh' (zero-order hold, default) or 'foh' (first-order hold).
|
|
23
|
+
method : str
|
|
24
|
+
'residue' (default) or 'matrix'.
|
|
25
|
+
|
|
26
|
+
Returns
|
|
27
|
+
-------
|
|
28
|
+
Gd : (num_d, den_d) tuple
|
|
29
|
+
Discrete-time numerator and denominator coefficient arrays.
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
import scipy.signal as sig
|
|
33
|
+
except ImportError:
|
|
34
|
+
raise ImportError("scipy is required for dtfm. Install with: pip install scipy")
|
|
35
|
+
|
|
36
|
+
# Normalise input to (num, den)
|
|
37
|
+
if isinstance(G, sig.lti):
|
|
38
|
+
num, den = G.num, G.den
|
|
39
|
+
elif isinstance(G, tuple) and len(G) == 2:
|
|
40
|
+
num, den = G
|
|
41
|
+
else:
|
|
42
|
+
raise TypeError("G must be a scipy lti or (num, den) tuple")
|
|
43
|
+
|
|
44
|
+
ct_sys = sig.lti(num, den)
|
|
45
|
+
if hold == 'zoh':
|
|
46
|
+
dt_sys = ct_sys.to_discrete(T, method='zoh')
|
|
47
|
+
elif hold == 'foh':
|
|
48
|
+
dt_sys = ct_sys.to_discrete(T, method='foh')
|
|
49
|
+
else:
|
|
50
|
+
raise ValueError(f"Unknown hold type '{hold}'")
|
|
51
|
+
|
|
52
|
+
return dt_sys.num, dt_sys.den
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def ztrm(G, T, mu=0.0):
|
|
56
|
+
"""
|
|
57
|
+
Modified Z-transform (advanced Z-transform) of a transfer matrix.
|
|
58
|
+
|
|
59
|
+
Computes Z{G(s) * e^{-mu*T*s}} / the Z-transform evaluated with the
|
|
60
|
+
advance parameter mu in [0, 1].
|
|
61
|
+
|
|
62
|
+
For mu=0 this equals the standard Z-transform with ZOH.
|
|
63
|
+
|
|
64
|
+
Parameters
|
|
65
|
+
----------
|
|
66
|
+
G : (num, den) tuple or scipy lti
|
|
67
|
+
Continuous-time SISO transfer function.
|
|
68
|
+
T : float
|
|
69
|
+
Sampling period.
|
|
70
|
+
mu : float
|
|
71
|
+
Advance parameter in [0, 1].
|
|
72
|
+
|
|
73
|
+
Returns
|
|
74
|
+
-------
|
|
75
|
+
num_d, den_d : np.ndarray
|
|
76
|
+
Numerator and denominator of discrete transfer function.
|
|
77
|
+
"""
|
|
78
|
+
if not (0.0 <= mu <= 1.0):
|
|
79
|
+
raise ValueError("mu must be in [0, 1]")
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
import scipy.signal as sig
|
|
83
|
+
import scipy.linalg as la
|
|
84
|
+
except ImportError:
|
|
85
|
+
raise ImportError("scipy is required for ztrm. Install with: pip install scipy")
|
|
86
|
+
|
|
87
|
+
if isinstance(G, sig.lti):
|
|
88
|
+
num, den = G.num, G.den
|
|
89
|
+
elif isinstance(G, tuple):
|
|
90
|
+
num, den = G
|
|
91
|
+
else:
|
|
92
|
+
raise TypeError("G must be a scipy lti or (num, den) tuple")
|
|
93
|
+
|
|
94
|
+
# Get poles and residues
|
|
95
|
+
r, p, k = sig.residue(num, den)
|
|
96
|
+
|
|
97
|
+
# Compute modified Z-transform via residue method:
|
|
98
|
+
# Z_mu{e^{p*t}} = z * e^{p*T*(1-mu)} / (z - e^{p*T})
|
|
99
|
+
z_poles = np.exp(p * T)
|
|
100
|
+
z_gains = r * np.exp(p * T * (1 - mu))
|
|
101
|
+
|
|
102
|
+
# Build discrete transfer function from partial fractions
|
|
103
|
+
# Reconstruct from poles and gains
|
|
104
|
+
num_d = np.array([0.0])
|
|
105
|
+
den_d = np.array([1.0])
|
|
106
|
+
|
|
107
|
+
for gi, pi in zip(z_gains, z_poles):
|
|
108
|
+
# Add term gi*z / (z - pi)
|
|
109
|
+
term_num = np.array([gi, 0.0])
|
|
110
|
+
term_den = np.array([1.0, -pi])
|
|
111
|
+
# Add fractions
|
|
112
|
+
new_num = np.polymul(num_d, term_den) + np.polymul(term_num, den_d)
|
|
113
|
+
den_d = np.polymul(den_d, term_den)
|
|
114
|
+
num_d = new_num
|
|
115
|
+
|
|
116
|
+
# Add polynomial (direct) part
|
|
117
|
+
if np.any(np.abs(k) > 1e-10):
|
|
118
|
+
k_poly = np.atleast_1d(k)
|
|
119
|
+
num_d = np.polyadd(num_d, np.polymul(k_poly, den_d))
|
|
120
|
+
|
|
121
|
+
num_d = np.real_if_close(num_d, tol=1e6)
|
|
122
|
+
den_d = np.real_if_close(den_d, tol=1e6)
|
|
123
|
+
|
|
124
|
+
return num_d, den_d
|