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,425 @@
1
+ """
2
+ Minimal-realization (pole/zero and state cancellation) utilities for DirectSD.
3
+
4
+ Port of MATLAB's ``@zpk/minreal.m`` and ``@zpk/minreals.m`` — both revised by
5
+ K. Polyakov from the original MathWorks Control System Toolbox ``minreal``.
6
+ MATLAB exposes these as ``minreal(sys, tol)`` / ``minreals(sys, tol)``, whose
7
+ behavior dispatches on the class of ``sys`` for the former (Kalman
8
+ decomposition for state-space models, root cancellation for zpk/transfer-
9
+ function models). Python has no such class-based dispatch for plain ndarrays
10
+ and scipy objects, so :class:`Minreal` groups all three operations under one
11
+ namespace instead of leaving them as differently-named free functions:
12
+
13
+ Minreal.ss(...) — state-space (Kalman decomposition)
14
+ Minreal.tf(...) — zpk/tf, general (conjugate-pair symmetry)
15
+ Minreal.tf_symmetric(...) — zpk/tf, symmetric spectral density
16
+ (reciprocal-pair symmetry; port of minreals.m)
17
+ """
18
+
19
+ import numpy as np
20
+
21
+
22
+ class Minreal:
23
+ """Namespace for DirectSD's minimal-realization operations."""
24
+
25
+ @staticmethod
26
+ def _ctrl_staircase(A, B_ctrl, B_full, C_full, tol):
27
+ """
28
+ Reduce to the controllable part from B_ctrl using the Rosenbrock staircase.
29
+
30
+ First step uses B_ctrl to find directly-reachable states; subsequent steps
31
+ use the coupling block A21 (lower-left of partitioned A) to find additionally
32
+ reachable states via the system dynamics.
33
+
34
+ B_full and C_full are transformed consistently.
35
+
36
+ Returns (A_new, B_ctrl_new, B_full_new, C_full_new, nc).
37
+ """
38
+ import scipy.linalg as la
39
+ n, m = A.shape[0], B_ctrl.shape[1]
40
+ if n == 0:
41
+ return A, B_ctrl, B_full, C_full, 0
42
+
43
+ # Absolute threshold: based on scale of original B and A to avoid dividing by ~0
44
+ sys_scale = max(np.linalg.norm(B_ctrl, 'fro'), np.linalg.norm(A, 'fro'), 1e-300)
45
+ abs_tol = tol * sys_scale
46
+
47
+ nc = 0
48
+ nc_prev = 0
49
+ Aw = A.copy()
50
+ Bctrl = B_ctrl.copy()
51
+ Bfull = B_full.copy()
52
+ Cfull = C_full.copy()
53
+
54
+ for step in range(n):
55
+ if step == 0:
56
+ Msub = Bctrl # first step: use B directly
57
+ else:
58
+ Msub = Aw[nc:, nc_prev:nc] # subsequent steps: coupling block A21
59
+
60
+ nr = Msub.shape[0]
61
+ mc = Msub.shape[1]
62
+ if nr == 0 or mc == 0:
63
+ break
64
+
65
+ # SVD guarantees singular values are sorted descending, so the first rk
66
+ # left singular vectors always span the full column space of Msub —
67
+ # no zero can appear between two nonzero values as happens with non-pivoting QR.
68
+ U2, sv, _ = la.svd(Msub, full_matrices=True) # U2: (nr × nr) orthogonal
69
+ rk = int(np.sum(sv > abs_tol))
70
+ # Sign-normalise for platform determinism: make largest-magnitude element positive.
71
+ for k in range(rk):
72
+ idx = np.argmax(np.abs(U2[:, k]))
73
+ if U2[idx, k] < 0:
74
+ U2[:, k] = -U2[:, k]
75
+ Q2 = U2
76
+ if rk == 0:
77
+ break
78
+
79
+ Qfull = np.eye(n)
80
+ Qfull[nc:, nc:] = Q2.T
81
+ Aw = Qfull @ Aw @ Qfull.T
82
+ Bctrl = Qfull @ Bctrl
83
+ Bfull = Qfull @ Bfull
84
+ Cfull = Cfull @ Qfull.T
85
+
86
+ nc_prev = nc
87
+ nc = nc + rk
88
+ if nc >= n:
89
+ break
90
+
91
+ return Aw, Bctrl, Bfull, Cfull, nc
92
+
93
+ @staticmethod
94
+ def ss(A, B=None, C=None, D=None, tol=None):
95
+ """
96
+ Minimal realization of a state-space system via Rosenbrock-staircase Kalman
97
+ decomposition (controllability then observability, each via a sequence of
98
+ SVDs on successive coupling blocks rather than one large Krylov matrix).
99
+
100
+ Dispatches on its own input instead of requiring a differently-named
101
+ function per calling convention:
102
+
103
+ Minreal.ss(A, B, C, D) -> (A_min, B_min, C_min, D) raw ndarrays
104
+ Minreal.ss(sys_ss) -> StateSpace scipy object
105
+
106
+ Detected by whether `B` is given: a bare `StateSpace`-like object (anything
107
+ exposing `.A/.B/.C/.D`) is passed as the sole positional argument, and the
108
+ reduced result comes back in the same form it went in.
109
+
110
+ The staircase algorithm (`_ctrl_staircase`) is preferred over a one-shot
111
+ Krylov-chain SVD (`[B, AB, A^2 B, ...]` built as one matrix and SVD'd once):
112
+ matrix powers amplify ill-conditioning as `A` develops close/repeated
113
+ eigenvalues, where the staircase's one-layer-at-a-time reduction stays
114
+ well-conditioned.
115
+
116
+ Port of MATLAB regular.m's very first step (``sys = minreal(ss(sys),...)``),
117
+ which the Python `h2reg`/`hinfreg` were missing entirely. Removes
118
+ uncontrollable and unobservable state directions before any Riccati-based
119
+ synthesis is attempted.
120
+
121
+ Uses controllability/observability *matrix rank* (not the Gramian/Lyapunov
122
+ approach `scipy` favors elsewhere) specifically because it works when `A`
123
+ has eigenvalues on the imaginary axis / at z=1 (marginally stable modes,
124
+ e.g. plant integrators) — Lyapunov-equation-based Gramians require `A` to
125
+ be Hurwitz/Schur and would raise or return garbage there.
126
+ """
127
+ as_ss = B is None
128
+ if as_ss:
129
+ sys_ss = A
130
+ A, B, C, D = sys_ss.A, sys_ss.B, sys_ss.C, sys_ss.D
131
+
132
+ n = A.shape[0]
133
+ if n == 0:
134
+ return sys_ss if as_ss else (A, B, C, D)
135
+ if tol is None:
136
+ tol = n * np.finfo(float).eps * max(np.linalg.norm(A), 1.0)
137
+
138
+ m, p = B.shape[1], C.shape[0]
139
+
140
+ def _empty():
141
+ return np.zeros((0, 0)), np.zeros((0, m)), np.zeros((p, 0)), D
142
+
143
+ # Step 1: controllability from B. B_full = C.T (kept for C recovery through
144
+ # the same orthogonal transform); C_full = empty (nothing else to carry).
145
+ Aw, Bw, Cw_t, _, nc = Minreal._ctrl_staircase(A, B, C.T, np.zeros((0, n)), tol)
146
+ if nc == 0:
147
+ A_min, B_min, C_min, D = _empty()
148
+ else:
149
+ if nc < n:
150
+ Aw = Aw[:nc, :nc]
151
+ Bw = Bw[:nc, :]
152
+ Cw_t = Cw_t[:nc, :]
153
+
154
+ # Step 2: observability from C (dual controllability on A.T).
155
+ # B_full dummy (nc×0); C_full = Bw.T (m×nc, nc cols matching A.T's dim).
156
+ Ao_t, Co_t_red, _, Bw_t_cfull, no = Minreal._ctrl_staircase(
157
+ Aw.T, Cw_t, np.zeros((nc, 0)), Bw.T, tol
158
+ )
159
+ if no == 0:
160
+ A_min, B_min, C_min, D = _empty()
161
+ else:
162
+ if no < nc:
163
+ Ao_t = Ao_t[:no, :no]
164
+ Co_t_red = Co_t_red[:no, :]
165
+ Bw_t_cfull = Bw_t_cfull[:, :no]
166
+ A_min, B_min, C_min = Ao_t.T, Bw_t_cfull.T, Co_t_red.T
167
+
168
+ if as_ss:
169
+ import scipy.signal as sig
170
+ return sig.StateSpace(A_min, B_min, C_min, D)
171
+ return A_min, B_min, C_min, D
172
+
173
+ @staticmethod
174
+ def _reducezp(z, p, tol, im_tol=1e-9):
175
+ """
176
+ Cancel matching pairs of zeros (z) and poles (p) within relative tolerance
177
+ `tol`, preserving complex-conjugate symmetry (assumes real coefficients —
178
+ i.e. every non-real root has a conjugate partner in the same array).
179
+
180
+ Port of MATLAB `@zpk/minreal.m`'s private `REDUCEZP`. Naive nearest-root
181
+ matching deletes whichever pair it matches first — but when a
182
+ complex-conjugate *pair* of zeros can only be matched against a single
183
+ real pole (or vice versa), blindly deleting one side breaks the
184
+ real-coefficient symmetry of the result. MATLAB's approach instead
185
+ *redistributes* the residual mismatch into a nearby surviving root —
186
+ replacing it with a weighted average `(matched + 2*target)/3` — and
187
+ re-queues the leftover half for another cancellation attempt, rather
188
+ than just discarding it.
189
+
190
+ Returns
191
+ -------
192
+ zr, pr : ndarray (complex)
193
+ Surviving zeros and poles after cancellation.
194
+ """
195
+ z = np.asarray(z, dtype=complex)
196
+ pr = list(np.asarray(p, dtype=complex))
197
+
198
+ def _closest(zm, lst):
199
+ if not lst:
200
+ return None, None
201
+ d = [abs(zm - s) for s in lst]
202
+ imin = int(np.argmin(d))
203
+ return d[imin], imin
204
+
205
+ def _pop_closest_to(target, lst):
206
+ d = [abs(target - s) for s in lst]
207
+ imin = int(np.argmin(d))
208
+ return lst.pop(imin)
209
+
210
+ # Represent each conjugate pair by its Im>0 member; the Im<0 partner is
211
+ # implicit (reconstructed at the end) and never separately tracked here —
212
+ # matching MATLAB's `z(imag(z)>0,:)` / `z(imag(z)==0,:)` split.
213
+ ccz = [zi for zi in z if zi.imag > im_tol]
214
+ sz = [zi for zi in z if abs(zi.imag) <= im_tol]
215
+
216
+ # --- Pass 1: complex-conjugate zero pairs, cancelled first so that any
217
+ # real-pole "downgrade" fallback feeds cleanly into pass 2. ---
218
+ ccz_keep = []
219
+ for zm in ccz:
220
+ dmin, imin = _closest(zm, pr)
221
+ if dmin is not None and dmin < tol * (1.0 + abs(zm)):
222
+ pm = pr[imin]
223
+ if abs(pm.imag) > im_tol:
224
+ # pm is complex: cancel (zm,pm) AND their conjugates together.
225
+ pr.pop(imin)
226
+ _pop_closest_to(np.conj(pm), pr)
227
+ else:
228
+ # pm is real: only half the pair can cancel against it —
229
+ # downgrade the pair to one new real zero, re-queued below.
230
+ sz.append((pm + 2.0 * zm.real) / 3.0)
231
+ pr.pop(imin)
232
+ else:
233
+ ccz_keep.append(zm)
234
+ ccz = ccz_keep
235
+
236
+ # --- Pass 2: simple (real) zeros. ---
237
+ sz_keep = []
238
+ for zm in sz:
239
+ dmin, imin = _closest(zm, pr)
240
+ if dmin is not None and dmin < tol * (1.0 + abs(zm)):
241
+ pm = pr[imin]
242
+ if abs(pm.imag) > im_tol:
243
+ # pm is complex: cancelling only its conjugate would break
244
+ # symmetry — replace the conjugate partner's position instead
245
+ # of deleting it, absorbing the residual mismatch there.
246
+ pr.pop(imin)
247
+ d = [abs(np.conj(pm) - s) for s in pr]
248
+ if d:
249
+ icjg = int(np.argmin(d))
250
+ pr[icjg] = (zm + 2.0 * pm.real) / 3.0
251
+ else:
252
+ pr.pop(imin)
253
+ else:
254
+ sz_keep.append(zm)
255
+ sz = sz_keep
256
+
257
+ zr = []
258
+ for zc in ccz:
259
+ zr.append(zc)
260
+ zr.append(np.conj(zc))
261
+ zr.extend(sz)
262
+
263
+ return np.array(zr, dtype=complex), np.array(pr, dtype=complex)
264
+
265
+ @staticmethod
266
+ def tf(num, den, tol=0.01):
267
+ """
268
+ Cancel common roots in (num, den) — minreal for raw numpy coefficient
269
+ arrays representing a transfer function's numerator/denominator.
270
+
271
+ Uses `_reducezp` (port of MATLAB `@zpk/minreal.m`'s `REDUCEZP`) rather
272
+ than naive first-match root deletion.
273
+ """
274
+ # Strip negligible leading coefficients (MATLAB striplz): the gain is
275
+ # taken from num[0]/den[0] below, so a padded leading 0.0 (e.g. from
276
+ # np.polyadd aligning different-degree operands) would otherwise zero
277
+ # the entire numerator (e.g. E from _ztrm_self_adjoint coming back
278
+ # identically 0 for a 2-output P11, gutting every AHinf design that
279
+ # reached _polhinf).
280
+ num = np.atleast_1d(np.asarray(num, float))
281
+ den = np.atleast_1d(np.asarray(den, float))
282
+ _nmax = np.max(np.abs(num)) if num.size else 0.0
283
+ _dmax = np.max(np.abs(den)) if den.size else 0.0
284
+ if _nmax == 0.0:
285
+ return np.array([0.0]), np.array([1.0])
286
+ while len(num) > 1 and abs(num[0]) <= 1e-12 * _nmax:
287
+ num = num[1:]
288
+ while len(den) > 1 and abs(den[0]) <= 1e-12 * _dmax:
289
+ den = den[1:]
290
+ rn = np.roots(num) if len(num) > 1 else np.array([], dtype=complex)
291
+ rd = np.roots(den) if len(den) > 1 else np.array([], dtype=complex)
292
+ zr, pr = Minreal._reducezp(rn, rd, tol)
293
+ sn = float(np.real(num[0])); sd = float(np.real(den[0]))
294
+ nn = np.real(np.poly(zr)).astype(float) * sn if len(zr) > 0 else np.array([sn])
295
+ nd = np.real(np.poly(pr)).astype(float) * sd if len(pr) > 0 else np.array([sd])
296
+ sc = nd[0] if abs(nd[0]) > 1e-30 else 1.0
297
+ return nn / sc, nd / sc
298
+
299
+ @staticmethod
300
+ def _remove(r, x, tol=1e-8):
301
+ """
302
+ Remove, from `r`, the nearest-matching entry to each element of `x`
303
+ in turn. Port of MATLAB `@zpk/private/remove.m`.
304
+
305
+ When the target `x[i]` is real but its nearest match in `r` is
306
+ complex, every entry of `r` is snapped to its real part before the
307
+ removal — MATLAB's own fix-up (`r(cc) = real(r(cc))` over a full
308
+ index permutation is equivalent to `r = real(r)`), presumably valid
309
+ because by this point in `minreals`' cancellation loop `r` is
310
+ expected to already be real-symmetric and any remaining imaginary
311
+ part is numerical noise to be cleaned up, not genuine structure.
312
+ """
313
+ r = list(np.asarray(r, dtype=complex))
314
+ for xi in x:
315
+ if len(r) < 1:
316
+ raise ValueError("No more elements to remove")
317
+ d = [abs(ri - xi) for ri in r]
318
+ k = int(np.argmin(d))
319
+ if d[k] > tol:
320
+ raise ValueError(f"Unable to find a term to remove: err={d[k]:g}")
321
+ if np.imag(xi) == 0 and np.imag(r[k]) != 0:
322
+ r = [np.real(ri) for ri in r]
323
+ r.pop(k)
324
+ return np.array(r, dtype=complex)
325
+
326
+ @staticmethod
327
+ def tf_symmetric(num, den, ftype='d', tol=None):
328
+ """
329
+ Minimal realization of a *symmetric* rational function (a spectral
330
+ density / para-Hermitian quasipolynomial ratio), preserving
331
+ reciprocal-pair symmetry (`z`, `1/z` for `ftype='d'`/`'z'`) or
332
+ negative-pair symmetry (`s`, `-s` for `ftype='s'`) under cancellation.
333
+
334
+ Port of MATLAB `@zpk/minreals.m`. Distinct from `Minreal.tf`
335
+ (`@zpk/minreal.m`'s `REDUCEZP`, which only preserves real-coefficient
336
+ conjugate-pair symmetry): here zeros and poles must *each*
337
+ independently already be symmetric (raises if not — "the function is
338
+ not symmetric"), and cancellation removes a pole/zero pair *together
339
+ with each side's own symmetric partner*, so the result stays
340
+ symmetric too.
341
+
342
+ Used by MATLAB's `zterm.m` (`Z = E - B*B~/A` construction),
343
+ `sdh2coef.m` (`A = A0*A1*A1'`-type products), `sdhinferr.m`,
344
+ `quaderr.m`, `abe2std.m` — all quadratic-form / spectral-density
345
+ constructions in the H2/H∞ polynomial design pipeline.
346
+
347
+ Parameters
348
+ ----------
349
+ num, den : ndarray
350
+ Numerator/denominator coefficients (descending powers).
351
+ ftype : {'d', 's'}
352
+ 'd' (default) for discrete-time (reciprocal pairs); 's' for
353
+ continuous-time (negative pairs).
354
+ tol : float, optional
355
+ Relative tolerance; default `sqrt(eps)`.
356
+
357
+ Returns
358
+ -------
359
+ num_min, den_min : ndarray
360
+ """
361
+ from directsd.polynomial.poln import _extrpair
362
+
363
+ if tol is None:
364
+ tol = np.sqrt(np.finfo(float).eps)
365
+
366
+ zz = np.roots(num) if len(num) > 1 else np.array([], dtype=complex)
367
+ pp = np.roots(den) if len(den) > 1 else np.array([], dtype=complex)
368
+ sn = float(np.real(num[0])) if len(num) > 0 else 1.0
369
+ sd = float(np.real(den[0])) if len(den) > 0 else 1.0
370
+
371
+ zs, z_rem, z0 = _extrpair(zz, ftype, tol)
372
+ ps, p_rem, p0 = _extrpair(pp, ftype, tol)
373
+ if z_rem.size > 0 or p_rem.size > 0:
374
+ raise ValueError("The function is not symmetric")
375
+
376
+ c0 = min(z0, p0)
377
+ z0 -= c0
378
+ p0 -= c0
379
+
380
+ if ftype == 's':
381
+ if z0 % 2 != 0 or p0 % 2 != 0:
382
+ raise ValueError("Incorrect zeros or poles at the origin")
383
+ else:
384
+ if len(zs) + z0 != len(ps) + p0:
385
+ raise ValueError("Incorrect zeros or poles at the origin")
386
+
387
+ zs_l, ps_l = list(zs), list(ps)
388
+ modified = False
389
+ i = 0
390
+ while i < len(ps_l):
391
+ A = ps_l[i]
392
+ tolA = max(tol * abs(A), tol)
393
+ if len(zs_l) < 1:
394
+ break
395
+ d = [abs(z - A) for z in zs_l]
396
+ if min(d) < tolA:
397
+ modified = True
398
+ targets = (
399
+ [A, np.conj(A)]
400
+ if abs(np.imag(A)) > np.finfo(float).eps
401
+ else [A]
402
+ )
403
+ zs_l = list(Minreal._remove(zs_l, targets, 100 * tolA))
404
+ ps_l = list(Minreal._remove(ps_l, targets, 100 * tolA))
405
+ else:
406
+ i += 1
407
+
408
+ if not modified:
409
+ return np.asarray(num, dtype=float), np.asarray(den, dtype=float)
410
+
411
+ zs_arr, ps_arr = np.array(zs_l, dtype=complex), np.array(ps_l, dtype=complex)
412
+ if ftype == 's':
413
+ zz_new = np.concatenate([zs_arr, -zs_arr, np.zeros(z0 // 2, dtype=complex)])
414
+ pp_new = np.concatenate([ps_arr, -ps_arr, np.zeros(p0 // 2, dtype=complex)])
415
+ else:
416
+ z_recip = 1.0 / zs_arr if zs_arr.size > 0 else zs_arr
417
+ p_recip = 1.0 / ps_arr if ps_arr.size > 0 else ps_arr
418
+ zz_new = np.concatenate([zs_arr, z_recip, np.zeros(z0, dtype=complex)])
419
+ pp_new = np.concatenate([ps_arr, p_recip, np.zeros(p0, dtype=complex)])
420
+
421
+ num_min = (np.real(np.poly(zz_new)).astype(float) * sn
422
+ if zz_new.size > 0 else np.array([sn]))
423
+ den_min = (np.real(np.poly(pp_new)).astype(float) * sd
424
+ if pp_new.size > 0 else np.array([sd]))
425
+ return num_min, den_min
@@ -0,0 +1,197 @@
1
+ """
2
+ Riccati-equation solvers robust to singular weights and unit-circle
3
+ eigenvalues.
4
+
5
+ Port of MATLAB ``dsdlinalg/dare1.m`` (K. Polyakov) — the extended-pencil
6
+ (Arnold & Laub 1984) DARE solver used by ``dsdsspace/h2reg.m``'s discrete
7
+ Chen-Francis branch. Unlike ``scipy.linalg.solve_discrete_are`` it
8
+
9
+ * accepts a SINGULAR control weight ``R`` (the pencil is compressed by a QR
10
+ step instead of inverting R) — sampled-data lifted H2/L2 problems have
11
+ ``R = D12'D12`` genuinely tiny/singular and ``D21 = 0``;
12
+ * handles pencil eigenvalues ON the unit circle (marginal integrator modes)
13
+ by splitting each near-1 eigenvalue into a (1-eps, 1+eps) pair distributed
14
+ between the stable/antistable deflating subspaces ("Polyakov 2005"), with
15
+ a conditioning-driven retry over the split assignment (the permUcPoles
16
+ search in dare1.m).
17
+
18
+ References
19
+ ----------
20
+ [1] W.F. Arnold, A.J. Laub, "Generalized Eigenproblem Algorithms and
21
+ Software for Algebraic Riccati Equations", Proc. IEEE 72 (1984).
22
+ [2] K. Polyakov, "Separation of eigenvalues on the unit circle", 2005.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import numpy as np
28
+ import scipy.linalg as la
29
+
30
+ __all__ = ['dare1']
31
+
32
+ _SQRT_EPS = np.sqrt(np.finfo(float).eps)
33
+
34
+
35
+ def _select_patterns(n_uc):
36
+ """Assignment patterns for the near-unit eigenvalues (True = antistable
37
+ group). MATLAB's default is alternating starting 'inside' (dir=-1 →
38
+ first gets 1-eps); permUcPoles then searches other distributions when
39
+ X1 is ill-conditioned. We enumerate the practical patterns."""
40
+ base = [False, True] * ((n_uc + 1) // 2)
41
+ alt1 = base[:n_uc] # inside, outside, inside, ...
42
+ alt2 = [not v for v in alt1] # outside, inside, ...
43
+ yield alt1
44
+ if n_uc > 0:
45
+ yield alt2
46
+ yield [False] * n_uc
47
+ yield [True] * n_uc
48
+
49
+
50
+ def dare1(A, B, Q, R, S=None, E=None, uc_tol=None):
51
+ """
52
+ Solve the discrete algebraic Riccati equation
53
+
54
+ A'XA - E'XE - (A'XB + S)(R + B'XB)^{-1}(B'XA + S') + Q = 0
55
+
56
+ via the extended matrix pencil + QZ, tolerating singular ``R`` and
57
+ unit-circle pencil eigenvalues. Port of ``dare1.m``.
58
+
59
+ Returns
60
+ -------
61
+ X : (n, n) ndarray
62
+ Stabilizing (symmetric) solution.
63
+ poles : ndarray
64
+ Closed-loop pole set (the stable half of the pencil spectrum).
65
+ err : float
66
+ Relative residual of the Riccati equation (0 when R + B'XB is
67
+ numerically singular and the residual cannot be formed — matching
68
+ dare1.m).
69
+
70
+ Raises
71
+ ------
72
+ numpy.linalg.LinAlgError
73
+ If no eigenvalue split yields an invertible X1 (dare1.m returns
74
+ X = inf / err = -1 in that case).
75
+ """
76
+ A = np.atleast_2d(np.asarray(A, float))
77
+ B = np.atleast_2d(np.asarray(B, float))
78
+ Q = np.atleast_2d(np.asarray(Q, float))
79
+ R = np.atleast_2d(np.asarray(R, float))
80
+ n = A.shape[0]
81
+ m = B.shape[1]
82
+ if S is None:
83
+ S = np.zeros((n, m))
84
+ S = np.atleast_2d(np.asarray(S, float))
85
+ if E is None:
86
+ E = np.eye(n)
87
+ E = np.atleast_2d(np.asarray(E, float))
88
+ n2 = 2 * n
89
+
90
+ if uc_tol is None:
91
+ uc_tol = _SQRT_EPS
92
+
93
+ # ── Scaling of the weights (dare1.m) ─────────────────────────────────
94
+ scale = (np.linalg.norm(Q, 1) + np.linalg.norm(R, 1)
95
+ + np.linalg.norm(S, 1))
96
+ if scale < 1e-300:
97
+ raise np.linalg.LinAlgError("dare1: Q, R, S are all zero")
98
+ QQ, RR, SS = Q / scale, R / scale, S / scale
99
+
100
+ # ── Extended pencil (2n+m)×(2n+m): a·x = λ·b·x ─────────────────────
101
+ a_ext = np.block([
102
+ [E, np.zeros((n, n)), np.zeros((n, m))],
103
+ [np.zeros((n, n)), A.T, np.zeros((n, m))],
104
+ [np.zeros((m, n)), -B.T, np.zeros((m, m))],
105
+ ])
106
+ b_ext = np.block([
107
+ [A, np.zeros((n, n)), B],
108
+ [-QQ, E.T, -SS],
109
+ [SS.T, np.zeros((m, n)), RR],
110
+ ])
111
+
112
+ # ── Compression step (works even when R is singular): project onto the
113
+ # orthogonal complement of b's last m columns ────────────────────────
114
+ q_full, _ = np.linalg.qr(b_ext[:, n2:n2 + m], mode='complete')
115
+ # MATLAB: q(:, 2n+m:-1:m+1)' — the complement columns (reversed order;
116
+ # the reversal does not change the span but is kept for parity).
117
+ qc = q_full[:, m:n2 + m][:, ::-1]
118
+ a_c = qc.T @ a_ext[:, :n2]
119
+ b_c = qc.T @ b_ext[:, :n2]
120
+
121
+ # ── Eigenvalues of the compressed pencil (plain QZ first, to identify
122
+ # the near-unit-circle ones deterministically) ───────────────────────
123
+ aa0, bb0, alpha0, beta0, _, _ = la.ordqz(a_c, b_c, sort=lambda a_, b_:
124
+ np.ones_like(np.atleast_1d(a_), dtype=bool),
125
+ output='complex')
126
+ with np.errstate(divide='ignore', invalid='ignore'):
127
+ lam0 = np.where(np.abs(beta0) < 1e-10, np.inf, alpha0 / beta0)
128
+ n_uc = int(np.sum(np.abs(lam0 - 1.0) < uc_tol))
129
+
130
+ best = None # (condX1, X1, X2, poles)
131
+ for pattern in _select_patterns(n_uc):
132
+ # Stateful selection: True → antistable (top-left) group.
133
+ state = {'k': 0}
134
+
135
+ def _sel(alpha, beta):
136
+ alpha = np.atleast_1d(alpha)
137
+ beta = np.atleast_1d(beta)
138
+ out = np.empty(alpha.shape, dtype=bool)
139
+ for i in range(len(alpha)):
140
+ if abs(beta[i]) < 1e-10:
141
+ out[i] = True # λ = ∞ → antistable
142
+ continue
143
+ lam = alpha[i] / beta[i]
144
+ if abs(lam - 1.0) < uc_tol:
145
+ j = state['k']
146
+ out[i] = pattern[j] if j < len(pattern) else True
147
+ state['k'] = j + 1
148
+ else:
149
+ out[i] = bool(abs(lam) > 1.0)
150
+ return out
151
+
152
+ try:
153
+ aa, bb, alpha, beta, q_z, z = la.ordqz(a_c, b_c, sort=_sel,
154
+ output='complex')
155
+ except Exception:
156
+ continue
157
+
158
+ X1 = z[:n, :n]
159
+ X2 = z[n:n2, :n]
160
+ try:
161
+ cond_X1 = np.linalg.cond(X1)
162
+ except Exception:
163
+ continue
164
+ if not np.isfinite(cond_X1):
165
+ continue
166
+
167
+ with np.errstate(divide='ignore', invalid='ignore'):
168
+ lam_all = np.where(np.abs(beta) < 1e-10, np.inf, alpha / beta)
169
+ poles = lam_all[n:]
170
+
171
+ if best is None or cond_X1 < best[0]:
172
+ best = (cond_X1, X1, X2, poles)
173
+ if cond_X1 < 1e12:
174
+ break
175
+
176
+ if best is None:
177
+ raise np.linalg.LinAlgError("dare1: QZ ordering failed")
178
+ cond_X1, X1, X2, poles = best
179
+
180
+ # ── X = X2 / X1 (dare1.m: LU with rcond check) ───────────────────────
181
+ if cond_X1 > 1.0 / np.finfo(float).eps:
182
+ raise np.linalg.LinAlgError(
183
+ f"dare1: X1 is numerically singular (cond={cond_X1:.3g}); "
184
+ f"no stabilizing solution found")
185
+ X = np.real(X2 @ np.linalg.inv(X1))
186
+ X = scale * (X + X.T) / 2.0
187
+
188
+ # ── Residual (dare1.m: err = 0 when R + B'XB is singular) ────────────
189
+ RBX = R + B.T @ X @ B
190
+ err = 0.0
191
+ if np.linalg.cond(RBX) < 1.0 / np.finfo(float).eps:
192
+ res = (A.T @ X @ A - E.T @ X @ E
193
+ - (A.T @ X @ B + S) @ np.linalg.solve(RBX, (B.T @ X @ A + S.T))
194
+ + Q)
195
+ err = float(np.linalg.norm(res) / max(1.0, np.linalg.norm(X)))
196
+
197
+ return X, poles, err
@@ -0,0 +1,11 @@
1
+ from directsd.polynomial.poln import Poln, s_var, z_var, q_var, p_var, d_var
2
+ from directsd.polynomial.operations import (
3
+ compat, deg, gcd, coprime, triple, factor,
4
+ striplz, recip, vec,
5
+ )
6
+ from directsd.polynomial.diophantine import dioph, dioph2, diophsys, diophsys2
7
+ from directsd.polynomial.spectral import sfactor, sfactfft
8
+ from directsd.polynomial.transforms import ztrm, dtfm
9
+ from directsd.polynomial.utils import (
10
+ bilintr, improper, tf2nd, separtf, sumzpk, bilinss,
11
+ )