Finite block-Hankel MIMO model reduction¶
Tutorial goal
Generalize the SISO finite-Hankel reducer to MIMO Markov matrices and return a reduced state-space model.
Note
New to the terminology? See the lattice DSP concept map and the causality/data-use guide for how online, offline, block, and MIMO examples should be read.
Context¶
The SISO reducer works with one impulse response. A MIMO system has a sequence of Markov matrices instead, where each matrix maps input channels to output channels at a particular lag. The natural finite-Hankel baseline is therefore a block-Hankel matrix.
This tutorial is deliberately a baseline: it gives a reference MIMO Ho–Kalman/ERA-style reduction as a finite block-Hankel baseline separate from matrix Nehari or AAK optimality claims.
Key idea and equations¶
For Markov matrices M_k with shape outputs x inputs, the finite block-Hankel matrix is
The reduced model is returned as state-space matrices A, B, C, D rather than as scalar
numerator/denominator coefficients.
How to read the result¶
Look for block-Hankel singular-value decay, retained energy, Markov-response error, and stable reduced state matrices.
Run command¶
python examples/mimo_finite_hankel_model_reduction.py
Source code¶
1"""Tutorial: finite block-Hankel model reduction for a MIMO system.
2
3The SISO finite-Hankel reducer works with one impulse response. The MIMO
4baseline generalizes this to a sequence of Markov matrices M_k, where each
5matrix maps input channels to output channels at lag k.
6
7This tutorial builds a stable coupled 3-input/3-output state-space system,
8computes its Markov parameters, performs finite block-Hankel reduction, and
9compares the reduced Markov responses against the full system.
10"""
11
12from __future__ import annotations
13
14import csv
15import os
16from pathlib import Path
17
18import numpy as np
19
20import lattice_dsp as ld
21
22
23def artifact_dir() -> Path:
24 path = Path(os.environ.get("LATTICE_DSP_ARTIFACT_DIR", "reports/example-artifacts"))
25 path.mkdir(parents=True, exist_ok=True)
26 return path
27
28
29def stable_random_state_space(order: int, outputs: int, inputs: int, seed: int = 7):
30 rng = np.random.default_rng(seed)
31 q, _ = np.linalg.qr(rng.normal(size=(order, order)))
32 radii = np.linspace(0.82, 0.18, order)
33 A = q @ np.diag(radii) @ q.T
34 B = 0.45 * rng.normal(size=(order, inputs))
35 C = 0.45 * rng.normal(size=(outputs, order))
36 D = 0.05 * rng.normal(size=(outputs, inputs))
37 return A, B, C, D
38
39
40def main() -> None:
41 out_dir = artifact_dir()
42
43 full_order = 10
44 inputs = outputs = 3
45 n_markov = 180
46 block_rows = block_cols = 24
47 reduced_orders = [2, 4, 6]
48
49 A, B, C, D = stable_random_state_space(full_order, outputs, inputs)
50 markov = ld.mimo_state_space_markov_response(A, B, C, D, n_markov)
51
52 summary = []
53 reduced_markov = {}
54 for order in reduced_orders:
55 result = ld.finite_hankel_reduce_mimo(
56 markov,
57 reduced_order=order,
58 block_rows=block_rows,
59 block_cols=block_cols,
60 )
61 approx = ld.mimo_state_space_markov_response(
62 result["A"], result["B"], result["C"], result["D"], n_markov
63 )
64 rel_error = float(np.sum((markov - approx) ** 2) / np.sum(markov**2))
65 reduced_markov[order] = approx
66 summary.append(
67 {
68 "order": order,
69 "stable": bool(result["stable"]),
70 "retained_hankel_energy": float(result["retained_hankel_energy"]),
71 "relative_markov_error": rel_error,
72 }
73 )
74
75 csv_path = out_dir / "mimo_finite_hankel_model_reduction_summary.csv"
76 with csv_path.open("w", newline="", encoding="utf-8") as f:
77 writer = csv.DictWriter(f, fieldnames=list(summary[0]))
78 writer.writeheader()
79 writer.writerows(summary)
80
81 hsv = np.asarray(
82 ld.finite_hankel_reduce_mimo(markov, 6, block_rows, block_cols)["hankel_singular_values"]
83 )
84 print("full state order:", full_order)
85 print("inputs:", inputs, "outputs:", outputs)
86 print("block Hankel matrix:", f"{block_rows * outputs} x {block_cols * inputs}")
87 print("leading block-Hankel singular values:", [round(float(v), 6) for v in hsv[:10]])
88 for row in summary:
89 print(
90 "order={order}: stable={stable}, retained_energy={retained_hankel_energy:.6f}, "
91 "relative_markov_error={relative_markov_error:.3e}".format(**row)
92 )
93 print(f"wrote {csv_path}")
94
95 try:
96 import matplotlib.pyplot as plt
97 except Exception:
98 print("matplotlib is not installed; skipped figures")
99 return
100
101 fig, ax = plt.subplots(figsize=(8.5, 4.5))
102 idx = np.arange(1, min(30, hsv.size) + 1)
103 ax.semilogy(idx, hsv[: idx.size], marker="o")
104 ax.set_title("MIMO block-Hankel singular-value decay")
105 ax.set_xlabel("index")
106 ax.set_ylabel("singular value")
107 ax.grid(True, alpha=0.3)
108 fig.tight_layout()
109 fig_path = out_dir / "mimo_block_hankel_singular_values.png"
110 fig.savefig(fig_path, dpi=160)
111 print(f"wrote {fig_path}")
112
113 fig2, axes = plt.subplots(outputs, inputs, figsize=(9, 7), sharex=True)
114 t = np.arange(80)
115 for y in range(outputs):
116 for u in range(inputs):
117 ax = axes[y, u]
118 ax.plot(t, markov[: t.size, y, u], linewidth=1.8, label="full")
119 ax.plot(
120 t, reduced_markov[4][: t.size, y, u], "--", linewidth=1.2, label="reduced order 4"
121 )
122 ax.grid(True, alpha=0.25)
123 if y == outputs - 1:
124 ax.set_xlabel(f"input {u}")
125 if u == 0:
126 ax.set_ylabel(f"output {y}")
127 axes[0, 0].legend(loc="upper right")
128 fig2.suptitle("Selected MIMO Markov responses: full vs reduced", y=1.02)
129 fig2.tight_layout()
130 fig2_path = out_dir / "mimo_reduced_markov_responses.png"
131 fig2.savefig(fig2_path, dpi=160)
132 print(f"wrote {fig2_path}")
133
134 err = np.sqrt(np.mean((markov - reduced_markov[4]) ** 2, axis=0))
135 fig3, ax3 = plt.subplots(figsize=(5.2, 4.4))
136 im = ax3.imshow(err)
137 ax3.set_title("RMS Markov error per output-input channel, order 4")
138 ax3.set_xlabel("input")
139 ax3.set_ylabel("output")
140 fig3.colorbar(im, ax=ax3)
141 fig3.tight_layout()
142 fig3_path = out_dir / "mimo_reduction_error_heatmap.png"
143 fig3.savefig(fig3_path, dpi=160)
144 print(f"wrote {fig3_path}")
145
146
147if __name__ == "__main__":
148 main()