MIMO block-Hankel to matrix-lattice bridge diagnostics

Tutorial goal

Use reduced MIMO Markov data to seed a stable matrix-lattice all-pass scaffold and measure the realization gap.

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

This page defines the bridge scope between the MIMO block-Hankel reducer and the matrix-lattice direction. A general reduced MIMO state-space model has frequency-dependent gains, while a matrix-lattice all-pass is unitary. The example therefore compares a stable lattice scaffold with the reduced model’s unitary polar factor as a diagnostic, not an exact realization solver.

Key idea and equations

For a reduced response H(e^{j\omega}), the polar factor is the unitary matrix

\[U_p(e^{j\omega}) = U(e^{j\omega})V(e^{j\omega})^H,\]

where H=U\Sigma V^H. The scaffold error reports how close a finite matrix-lattice all-pass response is to this unitary part.

How to read the result

Look for a stable reduced state model, a unitary scaffold, and the polar-factor error. This is a diagnostic/initialization bridge, not a matrix AAK/Nehari solver.

Run command

python examples/mimo_hankel_to_matrix_lattice_bridge.py

Run status

Return code: 0

Captured stdout

full state order: 12
reduced state order: 6
channels: 3
reduced state radius: 0.8562
retained block-Hankel energy: 0.997184
relative Markov error: 3.630e-03
matrix-lattice scaffold order: 6
max scaffold reflection singular value: 0.5005
scaffold unitarity error: 2.016e-14
polar-factor relative error: 1.373e+00
reduced response gain condition span: 1.247e+02
bridge status: scaffold diagnostic, not an exact matrix-lattice realization

Figures

mimo bridge block hankel singular values

mimo_bridge_block_hankel_singular_values.png

mimo bridge polar error

mimo_bridge_polar_error.png

mimo bridge reduced response gains

mimo_bridge_reduced_response_gains.png

Generated data files

Source code

  1"""Tutorial: bridge diagnostics from MIMO block-Hankel reduction to matrix lattices.
  2
  3This is deliberately a bridge diagnostic, not a matrix AAK/Nehari or exact
  4matrix-lattice realization solver.  A general stable MIMO state-space model has
  5frequency-dependent gains, while :class:`lattice_dsp.MatrixLatticeAllPass`
  6represents unitary/all-pass scattering.  The useful question at this stage is:
  7can the reduced MIMO model provide stable matrix-lattice *initialization data*
  8and how far is that all-pass scaffold from the reduced model's unitary/polar
  9part?
 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
 22try:
 23    from examples.mimo_coupled_model_reduction import coupled_state_space, state_spectral_radius
 24except Exception:  # pragma: no cover - direct script execution uses examples/ on sys.path.
 25    from mimo_coupled_model_reduction import coupled_state_space, state_spectral_radius
 26
 27
 28def artifact_dir() -> Path:
 29    path = Path(os.environ.get("LATTICE_DSP_ARTIFACT_DIR", "reports/example-artifacts"))
 30    path.mkdir(parents=True, exist_ok=True)
 31    return path
 32
 33
 34def state_space_frequency_response(A, B, C, D, omega: np.ndarray) -> np.ndarray:
 35    """Evaluate ``D + C z^-1 (I - A z^-1)^-1 B`` on the unit circle."""
 36
 37    A = np.asarray(A, dtype=float)
 38    B = np.asarray(B, dtype=float)
 39    C = np.asarray(C, dtype=float)
 40    D = np.asarray(D, dtype=float)
 41    omega = np.asarray(omega, dtype=float).reshape(-1)
 42    n_outputs, n_inputs = D.shape
 43    n_state = A.shape[0]
 44    response = np.empty((omega.size, n_outputs, n_inputs), dtype=np.complex128)
 45    eye = np.eye(n_state, dtype=np.complex128)
 46    for i, w in enumerate(omega):
 47        zinv = np.exp(-1j * float(w))
 48        if n_state:
 49            response[i] = D + C @ (zinv * np.linalg.solve(eye - zinv * A, B))
 50        else:
 51            response[i] = D
 52    return response
 53
 54
 55def polar_factor_per_frequency(response: np.ndarray) -> np.ndarray:
 56    """Return the unitary polar factor of each square frequency-response matrix."""
 57
 58    response = np.asarray(response, dtype=np.complex128)
 59    if response.ndim != 3 or response.shape[1] != response.shape[2]:
 60        raise ValueError("response must have shape (frequency, channels, channels)")
 61    out = np.empty_like(response)
 62    for i, h in enumerate(response):
 63        u, _, vh = np.linalg.svd(h, full_matrices=False)
 64        out[i] = u @ vh
 65    return out
 66
 67
 68def matrix_lattice_scaffold_from_markov(
 69    markov: np.ndarray, *, order: int, gain: float = 0.55
 70) -> ld.MatrixLatticeAllPass:
 71    """Build a stable matrix-lattice scaffold from early reduced Markov matrices.
 72
 73    The result is an all-pass/unitary scaffold.  It is not an exact realization
 74    of the reduced MIMO model; the Markov matrices only provide coupling
 75    directions for contractive reflection initializers.
 76    """
 77
 78    markov = np.asarray(markov, dtype=float)
 79    if markov.ndim != 3 or markov.shape[1] != markov.shape[2]:
 80        raise ValueError("markov must have shape (samples, channels, channels)")
 81    channels = markov.shape[1]
 82    reflections = []
 83    for k in range(order):
 84        idx = min(k + 1, markov.shape[0] - 1)
 85        raw = markov[idx]
 86        norm = np.linalg.norm(raw, ord=2)
 87        scaled = (
 88            np.zeros((channels, channels), dtype=np.complex128)
 89            if norm == 0.0
 90            else gain * raw / norm
 91        )
 92        reflections.append(ld.contractive_matrix_from_raw(scaled, margin=1e-5))
 93    residue = ld.unitary_polar_factor(markov[0] + 1e-6 * np.eye(channels))
 94    return ld.MatrixLatticeAllPass(reflections, residue=residue, margin=1e-9)
 95
 96
 97def response_relative_error(reference: np.ndarray, estimate: np.ndarray) -> float:
 98    return float(np.linalg.norm(reference - estimate) / max(np.linalg.norm(reference), 1e-30))
 99
100
101def main() -> None:
102    out_dir = artifact_dir()
103
104    full_order = 12
105    reduced_order = 6
106    channels = 3
107    n_markov = 220
108    block_rows = block_cols = 28
109    lattice_order = 6
110    omega = np.linspace(0.0, np.pi, 384)
111
112    A, B, C, D = coupled_state_space(order=full_order, outputs=channels, inputs=channels, seed=31)
113    markov = ld.mimo_state_space_markov_response(A, B, C, D, n_markov)
114    reduced = ld.finite_hankel_reduce_mimo(
115        markov, reduced_order=reduced_order, block_rows=block_rows, block_cols=block_cols
116    )
117    reduced_markov = ld.mimo_state_space_markov_response(
118        reduced["A"], reduced["B"], reduced["C"], reduced["D"], n_markov
119    )
120
121    lattice = matrix_lattice_scaffold_from_markov(reduced_markov, order=lattice_order)
122    h_reduced = state_space_frequency_response(
123        reduced["A"], reduced["B"], reduced["C"], reduced["D"], omega
124    )
125    polar_target = polar_factor_per_frequency(h_reduced)
126    h_lattice = lattice.frequency_response(omega, n_threads=1)
127
128    gain_singular = np.linalg.svd(h_reduced, compute_uv=False)
129    polar_error = response_relative_error(polar_target, h_lattice)
130    gain_flatness = float(np.max(gain_singular) / max(np.min(gain_singular), 1e-30))
131    markov_error = float(np.sum((markov - reduced_markov) ** 2) / (np.sum(markov**2) + 1e-30))
132    lattice_unitarity = lattice.unitarity_error(omega)
133
134    summary = {
135        "full_order": full_order,
136        "reduced_order": reduced_order,
137        "channels": channels,
138        "lattice_order": lattice_order,
139        "reduced_state_radius": state_spectral_radius(reduced["A"]),
140        "retained_hankel_energy": float(reduced["retained_hankel_energy"]),
141        "relative_markov_error": markov_error,
142        "lattice_max_reflection_singular_value": lattice.max_reflection_singular_value(),
143        "lattice_unitarity_error": lattice_unitarity,
144        "polar_factor_relative_error": polar_error,
145        "reduced_response_gain_condition_span": gain_flatness,
146        "note": "matrix-lattice scaffold, not exact realization",
147    }
148
149    csv_path = out_dir / "mimo_hankel_to_matrix_lattice_bridge_summary.csv"
150    with csv_path.open("w", newline="", encoding="utf-8") as f:
151        writer = csv.DictWriter(f, fieldnames=list(summary))
152        writer.writeheader()
153        writer.writerow(summary)
154
155    print("full state order:", full_order)
156    print("reduced state order:", reduced_order)
157    print("channels:", channels)
158    print("reduced state radius:", f"{summary['reduced_state_radius']:.4f}")
159    print("retained block-Hankel energy:", f"{summary['retained_hankel_energy']:.6f}")
160    print("relative Markov error:", f"{markov_error:.3e}")
161    print("matrix-lattice scaffold order:", lattice_order)
162    print(
163        "max scaffold reflection singular value:",
164        f"{summary['lattice_max_reflection_singular_value']:.4f}",
165    )
166    print("scaffold unitarity error:", f"{lattice_unitarity:.3e}")
167    print("polar-factor relative error:", f"{polar_error:.3e}")
168    print("reduced response gain condition span:", f"{gain_flatness:.3e}")
169    print("bridge status: scaffold diagnostic, not an exact matrix-lattice realization")
170    print(f"wrote {csv_path}")
171
172    try:
173        import matplotlib.pyplot as plt
174    except Exception:
175        print("matplotlib is not installed; skipped figures")
176        return
177
178    fig, ax = plt.subplots(figsize=(8.0, 4.5))
179    for i in range(channels):
180        ax.plot(omega, gain_singular[:, i], label=f"gain s{i + 1}")
181    ax.set_title("Reduced MIMO response singular values")
182    ax.set_xlabel("radian frequency")
183    ax.set_ylabel("singular value")
184    ax.grid(True, alpha=0.3)
185    ax.legend()
186    fig.tight_layout()
187    fig_path = out_dir / "mimo_bridge_reduced_response_gains.png"
188    fig.savefig(fig_path, dpi=160)
189    print(f"wrote {fig_path}")
190
191    per_freq_error = np.linalg.norm(polar_target - h_lattice, axis=(1, 2)) / np.maximum(
192        np.linalg.norm(polar_target, axis=(1, 2)), 1e-30
193    )
194    fig2, ax2 = plt.subplots(figsize=(8.0, 4.5))
195    ax2.plot(omega, per_freq_error)
196    ax2.set_title("Matrix-lattice scaffold error against reduced-model polar factor")
197    ax2.set_xlabel("radian frequency")
198    ax2.set_ylabel("relative Frobenius error")
199    ax2.grid(True, alpha=0.3)
200    fig2.tight_layout()
201    fig2_path = out_dir / "mimo_bridge_polar_error.png"
202    fig2.savefig(fig2_path, dpi=160)
203    print(f"wrote {fig2_path}")
204
205    fig3, ax3 = plt.subplots(figsize=(8.0, 4.5))
206    hsv = np.asarray(reduced["hankel_singular_values"], dtype=float)
207    idx = np.arange(1, min(40, hsv.size) + 1)
208    ax3.semilogy(idx, hsv[: idx.size], marker="o")
209    ax3.axvline(reduced_order, linestyle="--", linewidth=1.2)
210    ax3.set_title("Block-Hankel singular values feeding the bridge")
211    ax3.set_xlabel("index")
212    ax3.set_ylabel("singular value")
213    ax3.grid(True, alpha=0.3)
214    fig3.tight_layout()
215    fig3_path = out_dir / "mimo_bridge_block_hankel_singular_values.png"
216    fig3.savefig(fig3_path, dpi=160)
217    print(f"wrote {fig3_path}")
218
219
220if __name__ == "__main__":
221    main()