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