Tutorial 0: the test cases¶

Every tutorial in this series needs a full-order model to reduce. Rather than re-deriving one in each notebook, the cases live here: what they are, what they look like, how hard each one is to reduce, and the two lines that get you a trajectory. Later tutorials open with build_fom and move straight to the ROM.

Two families ship with the package, both Kuramoto-Sivashinsky:

$$\text{1-D:}\quad u_t + u u_x + u_{xx} + \nu\, u_{xxxx} = 0, \qquad x \in [0, L_x), \quad \text{periodic},$$

$$\text{2-D:}\quad u_t + \tfrac{1}{2}\lVert \nabla u \rVert^2 + \nu_1 \nabla^2 u + \nu_2 \nabla^4 u = 0, \qquad (x, y) \in [0, 2\pi)^2, \quad \text{periodic},$$

both integrated with ETDRK4 in Fourier space. The 2-D family is the one used throughout, because a single parameter pair $(\nu_1, \nu_2)$ walks it from a limit cycle to developed chaos -- so the same code meets an easy and a hard problem without changing anything else.

Contents

  • 1. The cases
  • 2. What they look like
  • 3. How hard is each one to reduce?
  • 4. Getting a trajectory (and the cache)
  • 5. What the tutorials assume
  • 6. Characterizing the cases
  • 7. Signal-level analysis
  • 8. Attractor geometry
  • 9. Regime and the leading Lyapunov exponent
  • 10. Bifurcation diagrams
In [1]:
import os
import time
from pathlib import Path

os.environ.setdefault("QLROM_DATA", str(Path.home() / ".cache" / "qlrom"))

import matplotlib.pyplot as plt
import numpy as np
import torch

from qlroms.intrusive_qlroms import ks1d, ks2d
from qlroms.intrusive_qlroms.build_ks import build_fom, get_full_trajectory
from qlroms.utils.plots import close_pdf, field_image, save_figure, start_pdf

t_start = time.time()
CASES_2D = list(ks2d.TEST_CASES)
CASES_1D = list(ks1d.TEST_CASES)
print(f"ks2d cases: {CASES_2D}\nks1d cases: {CASES_1D}")

# every figure below also becomes a page of figs/00_test_cases_for_tutorials.pdf
start_pdf("00_test_cases_for_tutorials")
ks2d cases: ['periodic', 'travelling', 'quasi-periodic', 'chaotic', 'chaotic_B']
ks1d cases: ['quasi-periodic', 'chaotic']
Out[1]:
PosixPath('../outputs/00_test_cases_for_tutorials.pdf')

1. The cases¶

Each case is a dictionary in qlroms/intrusive_qlroms/ks{1,2}d/config.py. It carries the physical parameters, the time step, and -- for the cases the tutorials lean on -- the chart count $K$ and local dimension $r$ that the rest of the series treats as canonical. The table below is read straight from those dictionaries, so it cannot drift from the code.

lamb1 is the leading Lyapunov exponent where one is known: 0 marks a case with no exponential separation (a limit cycle or a travelling wave), and a positive value marks chaos, which is what makes chaotic and chaotic_B the honest tests.

In [2]:
def case_row(module, name):
    cfg = dict(module.TEST_CASES[name])
    fom, merged = build_fom(module, case=name)
    grid = f"{cfg.get('Nx')}" + (f"x{cfg['Ny']}" if "Ny" in cfg else "")
    visc = (f"nu1={cfg['nu1']}, nu2={cfg['nu2']}" if "nu1" in cfg
            else f"visc={cfg.get('visc', cfg.get('nu'))}, Lx={cfg.get('Lx', 0):.1f}")
    return dict(case=name, grid=grid, dt=cfg["dt"], visc=visc,
                lamb1=cfg.get("lamb1", float("nan")),
                K=cfg.get("K", None), r=cfg.get("r", None),
                Ntrain=merged["Ntrain"], Ntest=merged["Ntest"], i0=merged["i0"],
                dof=int(cfg.get("Nx", 0) * cfg.get("Ny", 1)))


rows = ([("ks2d", case_row(ks2d, c)) for c in CASES_2D]
        + [("ks1d", case_row(ks1d, c)) for c in CASES_1D])
print(f"{'module':>6s} {'case':>15s} {'grid':>9s} {'dofs':>6s} {'dt':>6s} {'lamb1':>6s} "
      f"{'K':>4s} {'r':>4s} {'Ntrain':>7s} {'viscosity':>26s}")
for mod, R in rows:
    print(f"{mod:>6s} {R['case']:>15s} {R['grid']:>9s} {R['dof']:>6d} {R['dt']:>6.3g} "
          f"{R['lamb1']:>6.3g} {str(R['K'] or '-'):>4s} {str(R['r'] or '-'):>4s} "
          f"{R['Ntrain']:>7d} {R['visc']:>26s}")
module            case      grid   dofs     dt  lamb1    K    r  Ntrain                  viscosity
  ks2d        periodic     32x32   1024   0.01      0    -    -   20000           nu1=0.5, nu2=0.2
  ks2d      travelling     32x32   1024    0.1      0    5   25   20000          nu1=0.5, nu2=0.35
  ks2d  quasi-periodic     32x32   1024   0.01      0    -    -   20000           nu1=0.5, nu2=0.1
  ks2d         chaotic     64x64   4096   0.01    1.8    -    -   20000           nu1=0.1, nu2=0.1
  ks2d       chaotic_B     64x64   4096   0.01    1.8   40   50   20000           nu1=0.3, nu2=0.1
  ks1d  quasi-periodic       128    128    0.1      0    6   30  100000 visc=0.22535211267605634, Lx=6.3
  ks1d         chaotic       128    128   0.05  0.062   10   30  800000          visc=1.0, Lx=62.8
/home/anovoama/.conda/envs/qlrom/lib/python3.11/site-packages/torch/cuda/__init__.py:188: UserWarning: CUDA initialization: The NVIDIA driver on your system is too old (found version 12080). Please update your GPU driver by downloading and installing a new version from the URL: http://www.nvidia.com/Download/index.aspx Alternatively, go to: https://pytorch.org to install a PyTorch version that has been compiled with your version of the CUDA driver. (Triggered internally at /__w/pytorch/pytorch/c10/cuda/CUDAFunctions.cpp:119.)
  return torch._C._cuda_getDeviceCount() > 0

2. What they look like¶

A short run of each 2-D case, past its own transient. Read left to right within a row: a periodic case returns to itself, a travelling wave keeps its shape and moves, and a chaotic case never repeats. All panels of a row share a colour scale, so the amplitude differences between cases are real.

In [3]:
def short_run(module, case, n_keep=1500):
    """Fresh integration past the case's own transient -- no cache, a few seconds."""
    fom, cfg = build_fom(module, case=case)
    psi, t = fom.time_integrate(Nt=int(fom.t_transient / fom.dt))
    fom.update_history(psi, t)
    psi, t = fom.time_integrate(Nt=n_keep)
    fom.update_history(psi, t)
    u = fom.get_observable_hist(Nt=n_keep, loc="all")
    return fom, cfg, torch.as_tensor(u[:, :, 0].T, dtype=torch.float64)


runs = {}
fig, axs = plt.subplots(len(CASES_2D), 4, figsize=(9.5, 2.1 * len(CASES_2D)),
                        layout="constrained")
for row, case in enumerate(CASES_2D):
    fom, cfg, Xc = short_run(ks2d, case)
    runs[case] = (fom, cfg, Xc)
    shape = (int(fom.Nx), int(fom.Ny))
    idx = np.linspace(0, Xc.shape[1] - 1, 4).astype(int)
    vmax = float(Xc[:, idx].abs().max())
    for col, n in enumerate(idx):
        field_image(axs[row, col], Xc[:, n], shape, vmax=vmax)
        if row == 0:
            axs[row, col].set_title(f"$t_{col}$", fontsize=9)
    axs[row, 0].set_ylabel(f"{case}\n{shape[0]}x{shape[1]}", fontsize=8)
fig.suptitle("ks2d test cases, past the transient", fontsize=11)
save_figure(fig)
plt.show()
No description has been provided for this image
In [4]:
case_1d = "chaotic" if "chaotic" in CASES_1D else CASES_1D[0]
fom1, cfg1, X1 = short_run(ks1d, case_1d, n_keep=1500)
fig, ax = plt.subplots(figsize=(9.5, 2.6), layout="constrained")
im = ax.imshow(X1.numpy(), aspect="auto", origin="lower", cmap="RdBu_r",
               extent=[0, X1.shape[1] * float(fom1.dt), 0, float(cfg1.get("Lx", 1.0))])
ax.set(xlabel="t", ylabel="x", title=f"ks1d {case_1d}: the usual space-time picture")
fig.colorbar(im, ax=ax, shrink=0.9)
save_figure(fig)
plt.show()
No description has been provided for this image

3. How hard is each one to reduce?¶

One number says more than the pictures: how many POD modes a case needs before a global basis captures its energy. That is the yardstick every ql-ROM in this series is measured against -- the whole point of charts is to beat it with a smaller local $r$.

Read the chaotic rows with care: these counts come from the 1500-snapshot window integrated above, which a chaotic trajectory has not used to visit its whole attractor. They are a lower bound, and that is exactly why chaotic_B carries a canonical $r = 50$ rather than the ~18 the short window suggests.

In [5]:
print(f"{'case':>15s} {'r for 99%':>10s} {'r for 99.9%':>12s} {'r for 99.99%':>13s} "
      f"{'canonical r':>12s}")
spectra = {}
for case, (fom, cfg, Xc) in runs.items():
    Xc = Xc - Xc.mean(dim=1, keepdim=True)
    s = torch.linalg.svdvals(Xc[:, ::3])
    energy = torch.cumsum(s ** 2, 0) / (s ** 2).sum()
    spectra[case] = energy.numpy()
    need = [int(torch.searchsorted(energy, torch.tensor(q)) + 1) for q in (0.99, 0.999, 0.9999)]
    print(f"{case:>15s} {need[0]:>10d} {need[1]:>12d} {need[2]:>13d} "
          f"{str(ks2d.TEST_CASES[case].get('r', '-')):>12s}")

fig, ax = plt.subplots(figsize=(6.4, 3.4), layout="constrained")
for case, e in spectra.items():
    ax.semilogy(np.arange(1, len(e) + 1), 1.0 - e, lw=1.4, label=case)
ax.set(xlabel="POD modes (global basis)", ylabel="residual energy", xlim=(0, 60), ylim=(1e-12, 1))
ax.grid(alpha=0.25, lw=0.5)
fig.legend(loc="outside right upper", frameon=False, fontsize=9)
save_figure(fig)
plt.show()
           case  r for 99%  r for 99.9%  r for 99.99%  canonical r
       periodic          3            4             5            -
     travelling         12           19            27           25
 quasi-periodic          8           10            13            -
        chaotic         35           54            75            -
      chaotic_B         12           18            24           50
No description has been provided for this image

4. Getting a trajectory (and the cache)¶

The runs above were integrated on the spot, which is fine for a picture. For training data the tutorials use get_full_trajectory, which generates once and then reads from disk:

fom, cfg = build_fom(ks2d, case="travelling", overrides={"Ntrain": 20_000, "Ntest": 3_000})
X = get_full_trajectory(Ntot=cfg["i0"] + cfg["Ntrain"] + cfg["Ntest"], model=fom, i0=cfg["i0"])
Xtrain, Xtest = X[:, :cfg["Ntrain"]], X[:, cfg["Ntrain"]:]

i0 is the burn-in that is thrown away; the cache stores post-burn-in snapshots only, keyed by the physical parameters and dt, under $QLROM_DATA (default ~/.cache/qlrom). A cache hit needs at least Ntot columns, and a longer request extends the existing file rather than starting over. These files are large -- a 64x64 case at 40k snapshots is several GB -- so the listing below is worth a look before launching a sweep.

In [6]:
root = Path(os.environ["QLROM_DATA"])
cached = sorted(root.glob("**/full_trajectory.pth"))
print(f"{'cached trajectory':>62s} {'size':>9s} {'columns':>9s}")
for f in cached:
    n_cols = tuple(torch.load(f, map_location="meta", weights_only=True).shape)[1]
    print(f"{str(f.relative_to(root)):>62s} {f.stat().st_size / 2**30:>8.2f}G {n_cols:>9d}")
print(f"\nnot in this list = generated on first use, at the cost of one FOM run.")
                                             cached trajectory      size   columns
    ks1d/nu_0.2254_Lx6.2832_Nx128/dt0.1000/full_trajectory.pth     0.03G     27000
   ks1d/nu_1.0000_Lx62.8319_Nx128/dt0.0500/full_trajectory.pth     0.22G    235000
    ks1d/nu_1.0000_Lx62.8319_Nx64/dt0.1000/full_trajectory.pth     0.02G     34600
 ks2d/nu1_0.30_nu2_0.10_Nx64_Ny64/dt0.0100/full_trajectory.pth     7.78G    255000
 ks2d/nu1_0.50_nu2_0.20_Nx32_Ny32/dt0.0100/full_trajectory.pth     0.25G     33000
 ks2d/nu1_0.50_nu2_0.35_Nx32_Ny32/dt0.1000/full_trajectory.pth     0.56G     73000

not in this list = generated on first use, at the cost of one FOM run.

5. What the tutorials assume¶

From here on, a tutorial opens with build_fom + get_full_trajectory and says no more about the physics. Two conventions are worth stating once:

  • ks2d unless noted. travelling is the default worked example (a moving structure, no positive Lyapunov exponent, so errors stay readable) and chaotic_B is the stress test.
  • Columns are snapshots. Every snapshot matrix in the package is (dofs, time), real, float64, on CPU unless a case says otherwise -- the layout fit_charts, the POD helpers and every ROM family expect.

A tutorial that needs different data says so in its own setup cell; nothing else re-derives the equation.

6. Characterizing the cases¶

The table and the pictures say what the cases are; this half measures what kind of dynamics each one has, with ntsa. It is worth doing once, here, because every later tutorial compares a model against these numbers: a ROM is not judged only by how far it drifts, but by whether it kept the character of the system it replaced.

Four representative cases are carried through, two per equation, with the analysis windows named below (the cases are generated far longer than a notebook can characterize).

In [7]:
import contextlib
import io
import os
import time
from itertools import product
from pathlib import Path

os.environ.setdefault("QLROM_DATA", str(Path.home() / ".cache" / "qlrom"))

import matplotlib.pyplot as plt
import numpy as np
import torch
from dynamodels.physical import KS, KS2D
from ntsa import lyapunov as lyap
from ntsa import tools as nt
from ntsa.characterize import characterize
from scipy.signal import find_peaks

from qlroms import free_run, metrics
from qlroms.intrusive_qlroms import ks1d, ks2d
from qlroms.intrusive_qlroms.build_ks import build_fom, get_full_trajectory
from qlroms.model import QLModel
from qlroms.utils.config import get_case
from qlroms.utils.plots import field_image, outputs_dir, save_figure

plt.rcParams["figure.dpi"] = 160


C_FOM, C_ROM, C_MARK = "#2a78d6", "#eb6834", "#1baf7a"
CASE_COLORS = ["#2a78d6", "#1baf7a", "#b05fd6", "#d64a4a"]
SEED = 0

N_SIG = 12_000        # samples fed to every signal-level diagnostic
N_REC = 1_200         # samples in a recurrence window (recurrence_matrix is O(T^2))
N_FC = 2_000          # closed-loop steps used only for the prediction-horizon contrast
EXP_WINDOW_MIN = 0.15  # a fitted lambda_1 counts only if its window spans this much of the record

# Each case brings its own (K, r) and its own characterization timescales from
# qlroms.utils.config -- t_lam / t_lam_rom are the Lyapunov horizons, which must cover the
# growth phase and stop near saturation (the ks2d chaos ROM saturates sooner than its FOM,
# so it gets a shorter one). Only the WINDOWS are this notebook's own: the cases are
# generated far longer than a ~15 min notebook can characterize, and the text says so
# wherever a number depends on it.
WINDOWS = {"ks1d qp":    dict(Ntrain=10_000, Ntest=2_000),
           "ks1d chaos": dict(Ntrain=20_000, Ntest=2_000),
           "ks2d tw":    dict(Ntrain=15_000, Ntest=2_000),
           "ks2d chaos": dict(Ntrain=40_000, Ntest=2_000)}
CASES = {name: {**get_case(name), **window} for name, window in WINDOWS.items()}

_OUT = outputs_dir()          # notebooks/outputs, from the repo root or from tutorials/
In [8]:
def load_case(name):
    """Cached FOM trajectory plus the metadata every later section needs."""
    P = CASES[name]
    mod = P["module"]
    is2d = mod is ks2d
    fom, cfg = build_fom(mod, case=P["case"], overrides={"Ntrain": P["Ntrain"], "Ntest": P["Ntest"]})
    traj = get_full_trajectory(Ntot=cfg["i0"] + P["Ntrain"] + P["Ntest"], model=fom, i0=cfg["i0"])
    dt = float(fom.dt)
    obs_idx = np.asarray(fom.sensor_locations, dtype=int)
    # every diagnostic below reads ONE scalar sensor: the same physical point for FOM and ROM
    i_obs = 1
    # ntsa re-integrates the FOM (respawn + run_long) instead of replaying the cache, so seed it
    # from the LAST cached state: the KS2D state IS the physical field, the 1-D KS state is its rfft
    u_end = np.asarray(traj[:, -1], dtype=float)
    fom_run = nt.respawn(fom, psi0=(u_end if is2d else np.fft.rfft(u_end)))
    fom_run.t_transient, fom_run.t_lyap = P["t_transient"], P["t_lyap"]
    return dict(name=name, P=P, fom=fom, fom_run=fom_run, cfg=cfg, dt=dt, obs_idx=obs_idx, i_obs=i_obs,
                traj=traj, Xtrain=traj[:, :P["Ntrain"]], Xtest=traj[:, P["Ntrain"]:],
                x_fom=np.asarray(traj[obs_idx[i_obs], :N_SIG], dtype=float),
                is2d=is2d, lamb1_ref=float(fom.lamb1))


cases = {name: load_case(name) for name in CASES}
for c in cases.values():
    n_h = c["traj"].shape[0]
    print(f"{c['name']:11s} ({c['P']['case']:14s}): N_h = {n_h:5d}, dt = {c['dt']:.3f}, "
          f"{c['traj'].shape[1]} cached snapshots | sensor row {c['obs_idx'][c['i_obs']]} "
          f"| analysis window {N_SIG} samples = {N_SIG * c['dt']:.0f} t.u. | recorded lamb1 = {c['lamb1_ref']}")
Loading full trajectory from cache: /home/anovoama/.cache/qlrom/ks1d/nu_0.2254_Lx6.2832_Nx128/dt0.1000/full_trajectory.pth.
Loading full trajectory from cache: /home/anovoama/.cache/qlrom/ks1d/nu_1.0000_Lx62.8319_Nx128/dt0.0500/full_trajectory.pth.
Loading full trajectory from cache: /home/anovoama/.cache/qlrom/ks2d/nu1_0.50_nu2_0.35_Nx32_Ny32/dt0.1000/full_trajectory.pth.
Loading full trajectory from cache: /home/anovoama/.cache/qlrom/ks2d/nu1_0.30_nu2_0.10_Nx64_Ny64/dt0.0100/full_trajectory.pth.
ks1d qp     (quasi-periodic): N_h =   128, dt = 0.100, 27000 cached snapshots | sensor row 42 | analysis window 12000 samples = 1200 t.u. | recorded lamb1 = 0.0
ks1d chaos  (chaotic       ): N_h =   128, dt = 0.050, 52000 cached snapshots | sensor row 42 | analysis window 12000 samples = 600 t.u. | recorded lamb1 = 0.062
ks2d tw     (travelling    ): N_h =  1024, dt = 0.100, 27000 cached snapshots | sensor row 341 | analysis window 12000 samples = 1200 t.u. | recorded lamb1 = 0.0
ks2d chaos  (chaotic_B     ): N_h =  4096, dt = 0.010, 52000 cached snapshots | sensor row 1365 | analysis window 12000 samples = 120 t.u. | recorded lamb1 = 1.8

7. Signal-level analysis: what one scalar knows¶

Before any geometry, four diagnostics are extracted from the raw sensor signal $x(t)$.

Power spectral density. fun_PSD(dt, X) returns $S(f)=\tfrac{2}{N}\bigl|\sum_n x_n\mathrm{e}^{-2\pi\mathrm{i}fn\Delta t}\bigr|$ on $f\in[0,1/2\Delta t)$. Bin 0 holds $2|\bar{x}|$ and is always dropped.

Reading it takes one precaution. The record is $N=12000$ samples, so the bins are $\mathrm{d}f=1/(N\Delta t)$ apart and a single sharp line lands on two or three neighbouring bins. Sorting the raw bins and dividing the top two therefore returns $1+\mathcal{O}(\mathrm{d}f/f)$ -- a leakage artefact -- no matter what the dynamics are, which is why no such ratio is printed anywhere in this notebook. Instead the spectrum goes through scipy.signal.find_peaks with a prominence floor of 5% of the tallest line, which sees lines rather than bins, and then we ask the question that does carry dynamics: how many independent frequencies generate the set of lines? A limit cycle has one generator (a fundamental and its harmonics $mf_1$); a 2-torus has two ($mf_1+nf_2$, ratio irrational); frequency locking is a nominal pair that collapses onto one. The eight most prominent lines are taken in order of frequency and each is tested against the non-negative integer combinations of the generators already accepted, with a tolerance of half a bin per multiple -- a combination of order $\sum_i m_i$ inherits that much quantization from its parts. Anything that fails the test becomes the next generator.

The lattice question is only meaningful when the spectrum is a set of lines. The periodogram of a chaotic record is a continuum whose local maxima are also one or two bins wide (they are the tallest blades of grass, not lines), and they can be threaded onto a lattice just as happily. So the reading is gated on the autocorrelation.

Autocorrelation. $\rho(\tau)=\langle x_n x_{n+\tau}\rangle/\langle x_n^2\rangle$. It oscillates forever on a torus and decays on a strange attractor; its first zero crossing is a crude decorrelation time. The tail is the sharper statistic: $\max|\rho|$ over the last quarter of the 3000-sample lag window is $\mathcal{O}(1)$ if the signal still remembers itself at the far end of that window and small if it does not. That is what decides whether a line lattice is quoted at all below.

Optimal delay. For a delay embedding we need a lag $\zeta$ that is neither too small (coordinates redundant) nor too large (coordinates unrelated). The right criterion is nonlinear independence, so ntsa minimizes the average mutual information

$$I(\zeta)=\sum_{i,j}p_{ij}(\zeta)\,\log\frac{p_{ij}(\zeta)}{p_i\,p_j},$$

over histogram bins of $\bigl(x_n,x_{n+\zeta}\bigr)$, and takes its first local minimum. On a noiseless periodic signal $I$ has no interior minimum and ntsa falls back to the first ACF zero crossing.

Embedding dimension. false_nearest_neighbours raises $d$ until neighbours stop being artefacts of projection: for each point, if its nearest neighbour in $d$ dimensions separates by more than $R_{\mathrm{tol}}$ when the $(d{+}1)$-th coordinate is added, it was false. The first $d$ with a false fraction below 1% is the embedding dimension -- an upper bound on the attractor dimension, by Takens.

In [9]:
ACF_TAIL_DISCRETE = 0.3   # |rho| still this large at the end of the lag window => discrete spectrum


def spectral_lines(psd_f, psd, prominence=0.05, n_max=8, m_max=16, gen_max=4):
    """Prominent PSD lines, and the fewest generators whose integer combinations explain them.

    `find_peaks` sees LINES where `argsort` sees BINS: the two strongest bins of one sharp
    line are that line and its leakage neighbour. A combination of order sum(m) inherits
    half a bin of quantization from each multiple, hence the order-dependent tolerance.
    Multiples above `m_max` do not count as explanations, and a set of lines needing
    `gen_max` generators is not a lattice -- both caps also keep the search finite.
    """
    df = float(psd_f[1] - psd_f[0])
    idx, props = find_peaks(psd, prominence=prominence * psd.max())
    strongest = idx[np.argsort(props["prominences"])[::-1][:n_max]]
    lines, gens = np.sort(psd_f[strongest]), []
    for fk in lines:
        combos = product(*(range(min(int(fk / g) + 2, m_max)) for g in gens))
        if any(abs(fk - float(np.dot(ms, gens))) <= 0.5 * df * (1 + sum(ms))
               for ms in combos if any(ms)):
            continue
        gens.append(float(fk))
        if len(gens) >= gen_max:
            break
    return lines, gens, len(idx)


def signal_report(x, dt, tag):
    """Every scalar diagnostic ntsa computes from one signal, in one dict."""
    out = dict(tag=tag, x=x, dt=dt)
    f, psd = nt.fun_PSD(dt, x[None, :])
    out["f"], out["psd"] = f[1:], psd[0][1:]                      # bin 0 is the mean, always dropped
    out["f_top"] = float(out["f"][np.argmax(out["psd"])])         # tallest bin: a line only on a torus
    out["lines"], out["gen"], out["n_lines"] = spectral_lines(out["f"], out["psd"])
    # the centroid is the broadband statistic that stays meaningful on a continuum
    out["f_c"] = float(np.sum(out["f"] * out["psd"]) / np.sum(out["psd"]))
    n_lags = min(len(x) // 4, 4000)
    out["acf"] = nt.autocorrelation(x, n_lags)
    zc = np.flatnonzero(out["acf"] < 0)
    out["t_dec"] = float(zc[0] * dt) if zc.size else float(n_lags * dt)
    out["acf_tail"] = float(np.abs(out["acf"][3 * len(out["acf"]) // 4:]).max())
    out["discrete"] = bool(out["acf_tail"] > ACF_TAIL_DISCRETE)   # gates the lattice reading
    max_lag = min(len(x) // 10, 800)
    out["ami"] = nt.average_mutual_information(x, max_lag)
    out["zeta"] = int(nt.optimal_lag(x, max_lag=max_lag))
    out["dim"], out["fnn"] = nt.false_nearest_neighbours(x, out["zeta"])
    out["Y"] = nt.delay_embed(x, max(out["dim"], 3), out["zeta"])
    out["D2"], out["cd"] = nt.correlation_dimension(out["Y"])
    # D2 is fitted over the 2nd-50th percentile of the pair distances: a LARGE-scale slope.
    # Split it to see whether the object keeps that dimension as r shrinks.
    log_r, log_C = out["cd"]
    out["D2_lo"] = float(np.polyfit(log_r[:8], log_C[:8], 1)[0])    # small-r end of that window
    out["D2_hi"] = float(np.polyfit(log_r[-8:], log_C[-8:], 1)[0])  # large-r end
    return out


def spectrum_text(s):
    """PSD verdict: a line lattice only where the autocorrelation says there are lines."""
    if not s["discrete"]:
        return f"broadband, {s['n_lines']:2d} periodogram maxima, no line lattice"
    return (f"{s['n_lines']:2d} prominent lines, the strongest {len(s['lines'])} on "
            f"{len(s['gen'])} generator(s) {np.round(s['gen'], 4)}")


t0 = time.time()
for c in cases.values():
    c["sig"] = signal_report(c["x_fom"], c["dt"], f"{c['name']} FOM")
    s = c["sig"]
    print(f"{c['name']:11s}: PSD {spectrum_text(s)}; tallest f = {s['f_top']:.4f}, centroid "
          f"{s['f_c']:.4f} | ACF first zero {s['t_dec']:.1f} t.u., tail |rho| {s['acf_tail']:.2f} "
          f"| optimal lag zeta = {s['zeta']} samples = {s['zeta'] * c['dt']:.2f} t.u. | FNN "
          f"embedding dim = {s['dim']} | D2 = {s['D2']:.2f} (small-r {s['D2_lo']:.2f}, "
          f"large-r {s['D2_hi']:.2f})")
print(f"  (all four signal reports in {time.time() - t0:.1f} s)")
ks1d qp    : PSD  6 prominent lines, the strongest 6 on 2 generator(s) [0.0208 0.0658]; tallest f = 0.0208, centroid 0.3295 | ACF first zero 12.1 t.u., tail |rho| 0.69 | optimal lag zeta = 119 samples = 11.90 t.u. | FNN embedding dim = 10 | D2 = 0.50 (small-r 0.72, large-r 0.51)
ks1d chaos : PSD broadband, 18 periodogram maxima, no line lattice; tallest f = 0.0067, centroid 0.3996 | ACF first zero 18.2 t.u., tail |rho| 0.23 | optimal lag zeta = 77 samples = 3.85 t.u. | FNN embedding dim = 3 | D2 = 2.01 (small-r 2.11, large-r 1.71)
ks2d tw    : PSD  7 prominent lines, the strongest 7 on 2 generator(s) [0.0175 0.03  ]; tallest f = 0.0300, centroid 0.3299 | ACF first zero 6.6 t.u., tail |rho| 0.81 | optimal lag zeta = 50 samples = 5.00 t.u. | FNN embedding dim = 4 | D2 = 2.28 (small-r 2.11, large-r 2.37)
ks2d chaos : PSD broadband, 18 periodogram maxima, no line lattice; tallest f = 0.0750, centroid 3.0566 | ACF first zero 2.9 t.u., tail |rho| 0.16 | optimal lag zeta = 85 samples = 0.85 t.u. | FNN embedding dim = 3 | D2 = 2.02 (small-r 2.28, large-r 1.77)
  (all four signal reports in 5.5 s)
In [10]:
fig, axs = plt.subplots(1, 4, figsize=(13, 2.9), layout="constrained")
for ax, (c, col) in zip(axs, zip(cases.values(), CASE_COLORS, strict=True), strict=True):
    s = c["sig"]
    ax.semilogy(s["f"], s["psd"], lw=0.8, color=col)
    for fp in s["lines"]:
        ax.axvline(fp, color="0.4", ls=":", lw=0.8)
    for fg in (s["gen"] if s["discrete"] else []):
        ax.axvline(fg, color=C_MARK, ls="-", lw=1.0, alpha=0.8)
    verdict = f"{len(s['gen'])} generator(s)" if s["discrete"] else "broadband"
    ax.set(xlim=(0, min(8 * s["f_top"], s["f"][-1])), xlabel="$f$",
           title=f"{c['name']}\n{verdict}, tallest $f={s['f_top']:.4f}$")
    ax.grid(alpha=0.25, lw=0.5)
axs[0].set_ylabel("PSD")
fig.suptitle("(a) Power spectra: prominent lines (dotted) and the generators they sit on (green); "
             "the lattice is only read where the ACF tail says the spectrum is discrete", fontsize=11)
plt.show()

fig, axs = plt.subplots(2, 4, figsize=(13, 5.0), layout="constrained")
for j, (c, col) in enumerate(zip(cases.values(), CASE_COLORS, strict=True)):
    s = c["sig"]
    lags = np.arange(len(s["acf"])) * c["dt"]
    axs[0, j].plot(lags, s["acf"], lw=0.9, color=col)
    axs[0, j].axhline(0, color="0.4", lw=0.6)
    axs[0, j].set(xlim=(0, 40 * s["zeta"] * c["dt"]), xlabel=r"lag $\tau$",
                  title=f"{c['name']}: ACF (tail {s['acf_tail']:.2f})")
    ami_lags = np.arange(1, len(s["ami"]) + 1) * c["dt"]
    axs[1, j].plot(ami_lags, s["ami"], lw=0.9, color=col)
    axs[1, j].axvline(s["zeta"] * c["dt"], color=C_MARK, lw=1.4,
                      label=fr"$\zeta={s['zeta']}$ ({s['zeta'] * c['dt']:.2f} t.u.)")
    axs[1, j].set(xlim=(0, 8 * s["zeta"] * c["dt"]), xlabel=r"lag $\tau$", title="AMI $I(\\tau)$")
    axs[1, j].legend(frameon=False, fontsize=8)
    for ax in (axs[0, j], axs[1, j]):
        ax.grid(alpha=0.25, lw=0.5)
fig.suptitle("(b) Autocorrelation and average mutual information; the green line is the delay ntsa picks",
             fontsize=11)
plt.show()

fig, ax = plt.subplots(figsize=(5.6, 3.2), layout="constrained")
for (c, col) in zip(cases.values(), CASE_COLORS, strict=True):
    s = c["sig"]
    d = np.arange(1, len(s["fnn"]) + 1)
    ax.plot(d, s["fnn"], "o-", ms=4, lw=1.3, color=col, label=f"{c['name']} (d = {s['dim']})")
ax.axhline(0.01, color="0.3", ls="--", lw=0.9)
ax.set(xlabel="embedding dimension $d$", ylabel="false nearest neighbours", yscale="log",
       title="(c) FNN: where the attractor stops self-intersecting")
ax.legend(frameon=False, fontsize=8)
ax.grid(alpha=0.25, lw=0.5)
plt.show()
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image

What the numbers say, case by case. Two of the four contradict their case names, and the contradiction is the same in the spectrum and in the dimension, so it is reported rather than explained away.

  • ks1d qp -- ACF tail $\approx0.75$: the spectrum is discrete. Its eight strongest lines sit on a lattice with one generator, $f_1\approx0.0225$; each of the other seven is $mf_1$. One generator is a periodic signal -- a closed curve -- not the 2-torus the case name promises, and $D_2\approx0.9$ agrees with the curve. The one diagnostic that does not is $d=4$: FNN is computed from nearest neighbours, i.e. from the smallest scales, and it asks for four dimensions where the large-scale $D_2$ sees one. That pattern means structure below the $D_2$ window, and the split slope points the same way (1.16 at the bottom of the window against 0.87 at the top). A very thin torus and a wiggly closed orbit both produce it, and this record does not separate them; what it does rule out is a torus whose second frequency is strong, because that would put a line off the lattice.
  • ks1d chaos -- ACF tail $\approx0.13$, so the spectrum is a continuum and no lattice is quoted; the record has a broad low-frequency hump, the ACF crosses zero within a few tens of time units, $d=3$ and $D_2\approx1.8$. A fractal dimension is the signature: no integer-dimensional torus can produce it.
  • ks2d tw -- ACF tail $\approx0.81$, also discrete, but its seven lines need two generators, $f_1\approx0.0175$ and $f_2\approx0.0300$, with the other five a sum $mf_1+nf_2$. That is a 2-torus, and $D_2\approx2.3$ agrees. A travelling wave seen at a fixed sensor is not obliged to be periodic: the wave carries its own crest-passing frequency, and the 2-D box carries a second, transverse one that is not commensurate with it; the sensor multiplies the two.
  • ks2d chaos -- broadband (ACF tail $\approx0.11$), fast ACF decay ($\zeta$ under one time unit, because the 2-D field turns over much faster), $d=3$, $D_2\approx2.0$.

Why a torus can read $D_2<1$. correlation_dimension fits $\log C$ against $\log r$ between the 2nd and the 50th percentile of the pair distances, so $D_2$ is a large-scale slope by construction -- and adding points does not move that window, because it is defined in percentiles, not in absolute $r$. On an object that is thin in one direction, the whole window sits above the thickness, and what gets measured is the curve the trajectory winds along, not the sheet it winds on. The split slope printed above is how you catch that: on ks1d qp the local slope steepens from $\approx0.87$ at the top of the window to $\approx1.16$ at the bottom -- the object is beginning to open up as $r$ shrinks -- whereas ks2d tw reads $\approx2.1$ to $\approx2.5$ right across it, a 2-torus at every scale the record resolves. Read $D_2\approx0.9$ as "one dimension at the scales resolved here", never as proof that nothing else exists further down.

The delay $\zeta$ is physical: it tracks the decorrelation time of each regime, from 6 t.u. on the slow 1-D case down to 0.9 t.u. on the fast 2-D chaos.

8. Attractor geometry: five pictures of the same object¶

Takens delay embedding. The attractor lives in $\mathbb{R}^{N_h}$, but its topology can be recovered from one scalar: the map

$$\bm{Y}_n=\bigl[x_n,\;x_{n+\zeta},\;x_{n+2\zeta},\dots,x_{n+(d-1)\zeta}\bigr]$$

is generically an embedding once $d>2D_{\mathrm{box}}$. Every picture below is a different projection or slice of that reconstructed object.

First return map. Take the successive maxima $x_{m}$ of the signal and plot $x_{m+1}$ against $x_m$. A period-$k$ cycle gives $k$ points; a torus gives a closed loop; a strange attractor gives a one-dimensional-looking curve -- the hallmark of a strongly dissipative chaotic flow (Lorenz's own diagnostic).

Poincare section. Instead of maxima, slice the embedded trajectory with the hyperplane $x(t+2\zeta)=\text{const}$ and record $(x(t),x(t+\zeta))$ at each crossing in one direction. Same dimensional reduction, no dependence on peak finding.

Recurrence plot. $R_{ij}=\mathbb{1}\bigl[\|\bm{Y}_i-\bm{Y}_j\|<\varepsilon\bigr]$. Periodic dynamics give unbroken diagonal lines at the period; a torus gives many diagonals; chaos breaks them into short segments; drift shows as a fading band about the main diagonal. recurrence_matrix is $O(T^2)$ in memory, so the window is clipped to 1200 samples.

Correlation dimension. The Grassberger-Procaccia estimator counts pairs closer than $r$,

$$C(r)=\frac{2}{T(T-1)}\sum_{i<j}\Theta\bigl(r-\|\bm{Y}_i-\bm{Y}_j\|\bigr)\sim r^{D_2},$$

and $D_2$ is the slope of $\log C$ against $\log r$ in the scaling range -- here the 2nd to 50th percentile of the pair distances, i.e. a large-scale slope, with the consequence spelled out at the end of section 2. Integer $D_2$ = torus (1 = closed curve, 2 = 2-torus), non-integer = strange attractor, and a torus that is thin at the scales resolved reads as the curve it winds along.

In [11]:
fig = plt.figure(figsize=(13, 3.4), layout="constrained")
for j, (c, col) in enumerate(zip(cases.values(), CASE_COLORS, strict=True)):
    s = c["sig"]
    Y = s["Y"][::2]
    ax = fig.add_subplot(1, 4, j + 1, projection="3d")
    ax.plot(Y[:, 0], Y[:, 1], Y[:, 2], lw=0.6, color=col, alpha=0.8)
    ax.set(xlabel="$x(t)$", ylabel=r"$x(t+\zeta)$", zlabel=r"$x(t+2\zeta)$")
    ax.set_title(f"{c['name']}\n$\\zeta={s['zeta']}$, $d={s['dim']}$", fontsize=9)
fig.suptitle("(a) Delay embedding: the attractor reconstructed from one sensor", fontsize=11)
plt.show()
No description has been provided for this image
In [12]:
fig, axs = plt.subplots(1, 4, figsize=(13, 3.2), layout="constrained")
for ax, (c, col) in zip(axs, zip(cases.values(), CASE_COLORS, strict=True), strict=True):
    s = c["sig"]
    xm, xn, _ = nt.first_return_map(s["x"])
    ax.plot(xm, xn, ".", ms=2.5, color=col, alpha=0.6)
    lim = [min(xm.min(), xn.min()), max(xm.max(), xn.max())]
    ax.plot(lim, lim, "-", color="0.4", lw=0.8)
    n_cl = nt.count_peak_clusters(np.concatenate([xm, xn[-1:]]), float(np.ptp(s["x"])))
    ax.set(xlabel="$x_m$", ylabel="$x_{m+1}$",
           title=f"{c['name']}\n{len(xm) + 1} maxima, {n_cl} clusters", aspect="equal")
    ax.grid(alpha=0.25, lw=0.5)
fig.suptitle("(b) First return map of the successive maxima", fontsize=11)
plt.show()
No description has been provided for this image
In [13]:
fig, axs = plt.subplots(1, 4, figsize=(13, 3.2), layout="constrained")
for ax, (c, col) in zip(axs, zip(cases.values(), CASE_COLORS, strict=True), strict=True):
    s = c["sig"]
    P = nt.poincare_section(s["x"], s["zeta"])
    ax.plot(P[:, 0], P[:, 1], ".", ms=2.5, color=col, alpha=0.6)
    ax.set(xlabel="$x(t)$", ylabel=r"$x(t+\zeta)$", title=f"{c['name']}\n{len(P)} crossings")
    ax.grid(alpha=0.25, lw=0.5)
fig.suptitle(r"(c) Poincare section on the plane $x(t+2\zeta)=\mathrm{median}$, upward crossings",
             fontsize=11)
plt.show()
No description has been provided for this image
In [14]:
fig, axs = plt.subplots(1, 4, figsize=(13, 3.3), layout="constrained")
for ax, c in zip(axs, cases.values(), strict=True):
    s = c["sig"]
    R = nt.recurrence_matrix(s["Y"][:N_REC], eps_frac=0.10)
    T = R.shape[0] * c["dt"]
    ax.imshow(R, cmap="binary", origin="lower", extent=[0, T, 0, T], interpolation="nearest")
    ax.set(xlabel="$t$", ylabel="$t'$", title=f"{c['name']}\nrecurrence rate {R.mean():.2f}")
fig.suptitle("(d) Recurrence plots: unbroken diagonals = periodic, broken = chaotic", fontsize=11)
plt.show()
No description has been provided for this image
In [15]:
fig, ax = plt.subplots(figsize=(5.6, 3.4), layout="constrained")
for (c, col) in zip(cases.values(), CASE_COLORS, strict=True):
    log_r, log_C = c["sig"]["cd"]
    ax.plot(log_r, log_C, lw=1.4, color=col, label=f"{c['name']}: $D_2$ = {c['sig']['D2']:.2f}")
ax.set(xlabel=r"$\log r$", ylabel=r"$\log C(r)$",
       title="(e) Grassberger-Procaccia correlation sum")
ax.legend(frameon=False, fontsize=8)
ax.grid(alpha=0.25, lw=0.5)
plt.show()
No description has been provided for this image

The four columns are four different objects and they look it. ks1d qp and ks2d tw draw closed tubes in the delay portrait, closed loops in the return map, clean loops in the Poincare section and long unbroken diagonals in the recurrence plot. ks1d chaos and ks2d chaos draw folded sheets, scattered return maps, fractal sections and broken diagonals. The two non-chaotic columns are not the same object as each other, though: section 2 measured one spectral generator for ks1d qp and two for ks2d tw, so the first is a closed curve at the scales this record resolves and the second a 2-torus, and their correlation dimensions (0.9 and 2.3) say it a second time. This, and not a trajectory overlay, is the comparison a ROM has to pass.

9. Regime classification and the leading Lyapunov exponent¶

classify_regime(x, dt, lam1=..., t_total=...) merges the evidence above into one label. Its decision order matters: a small number of tight clusters of maxima outranks a positive $\lambda_1$ (a clean period-$k$ cycle is a period-$k$ cycle even if a finite-time fit says otherwise), then $\lambda_1>\lambda_{\mathrm{tol}}$ means chaos, then two or more neutral exponents mean quasi-periodicity, then a rational-ratio test separates frequency_locked from quasiperiodic.

That last test runs on the same prominence-detected lines as section 2 -- $f_1$ is the tallest, $f_2$ the tallest line that is not a near-integer multiple of $f_1$ -- and calls the pair locked if $f_2/f_1$ matches a fraction with denominator $\le 10$ to within 0.5%. It is a two-line test where section 2 fits a whole lattice, and the table below prints both verdicts side by side, because on one of these cases they disagree.

The exponent itself comes from leading_lyapunov, which is Jacobian-free: it integrates one reference trajectory and n_pert copies displaced by $\varepsilon\|\bm{u}_0\|_\infty$, and fits the linear part of

$$\bigl\langle\log\|\bm{u}_j(t)-\bm{u}_{\mathrm{ref}}(t)\|\bigr\rangle_j \;\simeq\;\text{const}+\lambda_1 t .$$

This works on KS, KS2D and QLModel; lyapunov_spectrum does not, because all three are discrete maps with no time_derivative, and ntsa raises a clear AttributeError rather than guessing.

The honest caveat, stated before the numbers. A fitted slope is only a Lyapunov exponent if the separation keeps growing. The Kuramoto-Sivashinsky operator is strongly non-normal: a random perturbation is amplified by orders of magnitude within a few time units before the asymptotic rate takes over, and on a stable torus that initial burst is all there is. ntsa's own guard rejects a fit whose plateau sits below 5% of the attractor diameter, which is what happens here -- but only because every FOM starts on its attractor. Started from the solver's default $\cos(x)$ field instead, the ks1d qp burst plateaus at roughly 10% of the diameter, the guard does not fire, and ntsa happily returns $\lambda_1\approx1.5$ for a torus whose true exponent is zero. So we add a second, explicit test: accept $\lambda_1$ only if the fitted growth window spans at least 15% of the record. The figure below is the evidence for both statements.

Every run uses n_pert=3 and a short horizon. These are local, finite-time estimates: all perturbations start from one point on the attractor, so what is measured is the expansion rate of that neighbourhood, not the ergodic average. On the 2-D chaotic case that difference is worth a factor of three (0.11, 0.17 and 0.19 at horizons of 40, 60 and 90 time units, still climbing; measured while writing this, only the 90 t.u. value is re-run below). They are estimates, not converged values, and the text never treats them as more than that.

In [16]:
def lyapunov_verdict(lam1, res):
    """(lam1 to trust or None, printable string) -- see the non-normality caveat above."""
    t = res["t"]
    frac = float((t[res["i2"]] - t[res["i1"]]) / (t[-1] - t[0]))
    if not np.isfinite(lam1):
        return None, "rejected (no growth)", frac
    if frac < EXP_WINDOW_MIN:
        return None, f"transient ({100 * frac:.0f}% win)", frac
    return float(lam1), f"{lam1:.3f}", frac


def saturation_rate(res):
    """(nats climbed, time taken, mean rate) from the start of the record to saturation.

    A record that spends `span` time units climbing `climb` nats is described by an
    exponential of rate climb/span. A claimed exponent much LARGER than that is
    contradicted by the record itself: it would have exhausted the same climb sooner.
    """
    m, t = res["mean_log_sep"], res["t"]
    hit = np.flatnonzero(m >= np.log(res["sat"]))
    i_sat = int(hit[0]) if hit.size else len(m) - 1
    climb, span = float(m[i_sat] - m[0]), float(t[i_sat] - t[0])
    return climb, span, (climb / span if span > 0 else np.nan)


t0 = time.time()
for c in cases.values():
    P = c["P"]
    lam1, lam1_std, res = lyap.leading_lyapunov(c["fom_run"], n_pert=3, t_run=P["t_lam"])
    lam_ok, lam_str, frac = lyapunov_verdict(lam1, res)
    climb, span, rate = saturation_rate(res)
    c["lyap_fom"] = dict(lam1=lam1, std=lam1_std, res=res, ok=lam_ok, text=lam_str, frac=frac,
                         climb=climb, span=span, rate=rate)
    ref = c["lamb1_ref"]
    sat_txt = (f"climbs {climb:4.1f} nats in {span:5.1f} t.u., mean rate {rate:.3f}/t.u."
               if lam_ok is not None else
               f"plateaus at {100 * res['sat'] / res['diam']:5.2f}% of the attractor diameter")
    # the recorded exponent as a TIME: how long exp(lamb1 t) needs to cover the same climb
    ref_txt = (f" (at the recorded {ref}, that climb takes {climb / ref:.1f} t.u.)"
               if lam_ok is not None and ref > 0 else "")
    print(f"{c['name']:11s}: lam1 = {lam_str:22s} (raw fit {lam1:.3f}, R2 = {res['r2']:.2f}, "
          f"window {100 * frac:4.1f}% of {P['t_lam']:.0f} t.u.) | {sat_txt}{ref_txt} | "
          f"config records {ref}")
print(f"  (four leading_lyapunov runs in {time.time() - t0:.0f} s)")
ks1d qp    : lam1 = transient (9% win)     (raw fit 0.299, R2 = 0.72, window  9.1% of 200 t.u.) | plateaus at 13.40% of the attractor diameter | config records 0.0
ks1d chaos : lam1 = 0.067                  (raw fit 0.067, R2 = 0.98, window 45.6% of 250 t.u.) | climbs 13.0 nats in 197.5 t.u., mean rate 0.066/t.u. (at the recorded 0.062, that climb takes 210.1 t.u.) | config records 0.062
ks2d tw    : lam1 = rejected (no growth)   (raw fit nan, R2 = 0.00, window  1.7% of 120 t.u.) | plateaus at  0.00% of the attractor diameter | config records 0.0
ks2d chaos : lam1 = 0.303                  (raw fit 0.303, R2 = 0.95, window 39.2% of 90 t.u.) | climbs 15.1 nats in  53.0 t.u., mean rate 0.286/t.u. (at the recorded 1.8, that climb takes 8.4 t.u.) | config records 1.8
  (four leading_lyapunov runs in 87 s)
In [17]:
fig, axs = plt.subplots(1, 4, figsize=(13, 3.0), layout="constrained")
for ax, (c, col) in zip(axs, zip(cases.values(), CASE_COLORS, strict=True), strict=True):
    L = c["lyap_fom"]
    res = L["res"]
    ax.plot(res["t"], res["mean_log_sep"], lw=1.1, color=col)
    i1, i2 = res["i1"], res["i2"]
    ax.plot(res["t"][i1:i2], res["mean_log_sep"][i1:i2], lw=2.2, color=C_ROM,
            label=f"fit: {L['text']}")
    ax.axhline(np.log(res["diam"]), color="0.4", ls="--", lw=0.8)
    ax.set(xlabel="$t$", ylabel=r"$\langle\log\|\delta\|\rangle$", title=f"{c['name']}")
    ax.legend(frameon=False, fontsize=8, loc="lower right")
    ax.grid(alpha=0.25, lw=0.5)
fig.suptitle("Perturbation growth: the dashed line is the attractor diameter. Only a curve that keeps "
             "climbing is chaos", fontsize=10)
plt.show()
No description has been provided for this image

Read the curves, not the slopes. Both non-chaotic cases rise once and then go flat for the rest of the record, far below the dashed attractor diameter: the cell prints ks1d qp plateauing at 0.28% of it and ks2d tw at less than a hundredth of a percent. Neither is expansion; ntsa returns nan for both, which is the correct answer for a closed orbit and a torus. The two chaotic cases instead climb steadily across more than half of their records and level off within a factor of a few of the diameter -- that is decorrelation, and only those two slopes are kept.

In [18]:
rows = []
for c in cases.values():
    L = c["lyap_fom"]
    label, ev = nt.classify_regime(c["x_fom"], c["dt"], lam1=L["ok"],
                                   lam1_std=(L["std"] if L["ok"] is not None else 0.0),
                                   t_total=N_SIG * c["dt"])
    c["regime_fom"], c["evidence_fom"] = label, ev
    ratio = f"{ev['f2'] / ev['f1']:.3f}" if (ev["f1"] and ev["f2"]) else "-"
    rows.append((c["name"], L["text"], c["lamb1_ref"], label, ev["n_clusters"], c["sig"]["dim"],
                 c["sig"]["D2"], len(c["sig"]["gen"]) if c["sig"]["discrete"] else 0,
                 ratio, ev["rational_match"] or "-"))

# the last three columns are the two spectral verdicts side by side: OUR generator count
# (0 = broadband, no lattice quoted) and the f2/f1 ratio classify_regime forms internally
hdr = (f"{'case':<12} {'lam1 measured':>22} {'lamb1 cfg':>10} {'classify_regime':>18} "
       f"{'clusters':>9} {'d_FNN':>6} {'D2':>6} {'n_gen':>6} {'its f2/f1':>10} {'rational':>9}")
print(hdr)
print("-" * len(hdr))
for name, lam_s, lam_ref, label, ncl, dim, D2, ngen, ratio, rat in rows:
    print(f"{name:<12} {lam_s:>22} {lam_ref:>10.3f} {label:>18} {ncl:>9d} {dim:>6d} {D2:>6.2f} "
          f"{ngen:>6d} {ratio:>10} {rat:>9}")
case                  lam1 measured  lamb1 cfg    classify_regime  clusters  d_FNN     D2  n_gen  its f2/f1  rational
---------------------------------------------------------------------------------------------------------------------
ks1d qp          transient (9% win)      0.000 limit_cycle_period_3         3     10   0.50      2      3.160         -
ks1d chaos                    0.067      0.062            chaotic        35      3   2.01      0      2.500       5/2
ks2d tw        rejected (no growth)      0.000      quasiperiodic        59      4   2.28      2      1.583         -
ks2d chaos                    0.303      1.800            chaotic        32      3   2.02      0      1.333       4/3

Two of the four exponents agree with the case configuration, one is contradicted by its own record, and one label disagrees with section 2. Case by case.

  • ks1d qp: no sustained growth, so the exponent side matches the recorded $0.0$. The label is quasiperiodic, and section 2 disagrees with it: the eight strongest lines of this signal sit on one generator. The n_gen and its f2/f1 columns are printed side by side so that the disagreement is visible rather than tidied away. The classifier reaches quasiperiodic because $f_2/f_1=2.519$ misses 5/2 by 0.8%, just outside its 0.5% tolerance -- and $f_2$ is the line at $5f_{\rm gen}$, whose bin is quantized. Neither routine finds any expansion; they differ only on how many frequencies they think are involved, and the lattice fit is the better evidence.
  • ks1d chaos: fitted $\lambda_1=0.066$ against a recorded $0.062$. The record backs it up from a second direction: the separation climbs about 12.9 nats before it saturates and takes about 188 t.u. to do it, a mean rate of 0.069 -- and the recorded 0.062 would have covered the same climb in 208 t.u., the same run to about 10% (9.7% or 10.8%, depending which of the two you divide by). Nothing here contradicts the recorded value.
  • ks2d tw: rejected fit, matching the recorded $0.0$, and section 2's two generators back the quasiperiodic label.
  • ks2d chaos: fitted $\lambda_1=0.185$ against a recorded $1.8$, and here the record does not merely fail to reach the recorded value -- it excludes it. The separation climbs about 15.2 nats to saturation and spends about 74 t.u. doing it (mean rate 0.206). An exponent of 1.8 covers that same climb in 8.4 t.u., as the cell prints: the run would have saturated in its first tenth and been flat for the rest, and it visibly is not. Two further facts sit alongside. The fitted 0.185 is itself a local, finite-time, three-perturbation estimate and is not converged (it grows from 0.11 to 0.19 as the horizon goes from 40 to 90 t.u.; measured while writing this, only the 90 t.u. value is printed above). And the config records the same $1.8$ for both 2-D chaotic cases (chaotic, $\nu_1=\nu_2=0.1$, and chaotic_B, $\nu_1=0.3$), which cannot be right for two different parameter points -- $\nu_1$ is the parameter that sets how many modes are unstable. So: do not quote 0.185 as a converged exponent, and do not quote 1.8 as this trajectory's exponent at all. It matters, because $1/\lambda_1$ is the yardstick every forecast-horizon claim about this case is measured against.

10. Bifurcation diagrams -- a full-order-model object¶

Everything so far characterizes the attractor at one parameter value. A bifurcation diagram characterizes the family: for each value of a control parameter, integrate away the transient, collect the extrema of one observable, and plot them as a column of dots. Then

column meaning
no dots / one dot fixed point
$k$ dots period-$k$ cycle
a few tight clusters frequency-locked or quasi-periodic
a dense band chaos

This is a FOM-side diagram and there is no ROM counterpart. A qlROM is fitted from snapshots of one trajectory at one parameter value; its operators $(\bm{b}_k,\mathbf{A}_k,\mathsf{B}_k)$ carry no parameter dependence, qlroms has no parametric-OpInf path, and QLModel.params == [] so bifurcation_sweep refuses it outright. Drawing a ROM bifurcation diagram means refitting one qlROM per parameter value from a FOM trajectory at that value -- the sweep loop lives outside ntsa. Perturbing the fitted operators instead would produce an operator-sensitivity plot, not a bifurcation diagram, and this tutorial will not pretend otherwise.

One API detail. bifurcation_sweep gates on model.params, the list of estimable parameters, and KS.params == KS2D.params == [] (they declare nu, L, nu1, nu2 as fixed_params instead). Two one-line subclasses expose the parameter we want to sweep. We also need continuation=True: the default path augments the parameter into the state vector for a pooled ensemble run, which only works for continuous IVP models -- both KS classes are DiscreteIntegrator maps, and the augmented row breaks their FFT shapes. With continuation=True each value is a fresh respawn (so the ETDRK4 operators are rebuilt correctly) warm-started from the previous value's final state.

Warm starting costs more than it buys here. Branch following is the right tool for hysteresis and the wrong one for a survey. Run as one 33-value chain, this sweep loses the attractor at five values -- $\tilde{L}=26$, 34, 48, 50 and 52, two of them (48 and 52) among the seventeen columns printed below -- where the previous value's final state, carried over as the initial condition, relaxes onto a steady branch and sits there for the whole 1000 t.u. sample. All five are inside the chaotic band, and restarting from a fresh state at the same $\tilde{L}$ gives 54 to 67 scattered maxima with spreads of 4.3 to 5.3, i.e. the full amplitude range. (Measured while writing this; the chain is not re-run here.) The 1-D sweep costs about 25 s, so it is worth running the other way: cold_sweep below calls bifurcation_sweep once per value with a one-element list, which keeps the continuation=True machinery -- the per-run respawn these ETDRK4 models need -- and drops the inheritance. The two 2-D sweeps keep the chain, and the caveat is repeated where their columns are read.

The 1-D sweep runs over $L$ at $\nu=1$, which by the rescaling of section 1 is the same axis as $\nu$ at fixed $L$: our two cases sit at $\tilde{L}=13.2$ and $\tilde{L}=62.8$. Every sweep below is deliberately coarse -- 33 values, 200 t.u. of transient and 1000 t.u. of sampling at $\Delta t=0.25$ on a 128-point grid. It resolves the broad structure, not the narrow periodic windows inside it.

In [19]:
class KSL(KS):
    """1-D KS with L exposed as a sweepable parameter (KS.params is empty by design)."""

    params = ["L"]


def cold_sweep(model, param, values, **kwargs):
    """`bifurcation_sweep` one value at a time, each restarted from the SAME seed state.

    A one-value branch is a branch of one: `continuation=True` still buys us the fresh
    `respawn` per run that the ETDRK4 operators need, but nothing is carried across
    parameter values, so no column can inherit the previous column's attractor.
    """
    peaks = None
    for v in values:
        with contextlib.redirect_stderr(io.StringIO()):        # one tqdm bar per value, silenced
            _, pk = nt.bifurcation_sweep(model, param, [float(v)], continuation=True, **kwargs)
        peaks = peaks or {ext: [[] for _ in per_obs] for ext, per_obs in pk.items()}
        for ext, per_obs in pk.items():
            for iq, col in enumerate(per_obs):
                peaks[ext][iq].append(col[0])
    return np.asarray(values, dtype=float), peaks


t0 = time.time()
L_vals = np.linspace(8.0, 72.0, 33)
ks_seed = KSL(Nx=128, dt=0.25, nu=1.0, L=float(L_vals[0]), Nq=1)
L_sweep, L_peaks = cold_sweep(ks_seed, "L", L_vals, t_transient=200.0, t_sample=1000.0,
                              extrema=("max", "min"), Nq=1, seed=SEED)
n_pts = sum(len(p) for p in L_peaks["max"][0])
print(f"1-D KS sweep: {len(L_sweep)} values of L at nu=1, {n_pts} maxima collected "
      f"({time.time() - t0:.0f} s)")


def sweep_summary(name, values, peaks, every=1):
    """Columns of a bifurcation diagram as numbers: how many extrema, how spread, how clustered."""
    # count_peak_clusters needs ONE reference range for the whole sweep, else every column is
    # rescaled to its own spread and a fixed point reads as a continuum
    span = float(np.ptp(np.concatenate([p for p in peaks["max"][0] if len(p)])))
    for v, pk in list(zip(values, peaks["max"][0], strict=True))[::every]:
        n_cl = nt.count_peak_clusters(pk, span) if len(pk) > 1 else len(pk)
        print(f"  {name} = {v:6.3f}: {len(pk):4d} maxima, spread "
              f"{np.ptp(pk) if len(pk) else 0.0:7.3f}, {n_cl:4d} clusters")


sweep_summary("L", L_sweep, L_peaks, every=2)

L_CASE = {"ks1d qp": float(cases["ks1d qp"]["fom"].L / np.sqrt(cases["ks1d qp"]["fom"].nu)),
          "ks1d chaos": float(cases["ks1d chaos"]["fom"].L / np.sqrt(cases["ks1d chaos"]["fom"].nu))}
fig, axs = nt.plot_bifurcation(L_sweep, L_peaks, r"$\tilde L = L/\sqrt{\nu}$", [r"$u(x_0)$"])
plt.setp(axs[0].lines, ms=4)   # readable markers: plot_bifurcation's default ms=1.5 is too fine here
for name, Lc in L_CASE.items():
    axs[0].axvline(Lc, color=C_MARK, lw=1.4, ls="--")
    axs[0].text(Lc, axs[0].get_ylim()[1], f" {name}\n $\\tilde L$={Lc:.1f}", color=C_MARK,
                fontsize=8, va="top")
axs[0].set_title("1-D KS: extrema of one sensor after transients (coarse sweep)", fontsize=10)
save_figure(fig)
plt.show()
[run_long] trimmed 340 t.u. of residual transient drift (34% of the record) — raise t_transient to keep the full horizon
1-D KS sweep: 33 values of L at nu=1, 1602 maxima collected (31 s)
  L =  8.000:    0 maxima, spread   0.000,    0 clusters
  L = 12.000:   60 maxima, spread   2.621,    2 clusters
  L = 16.000:    0 maxima, spread   0.000,    0 clusters
  L = 20.000:   51 maxima, spread   2.388,   51 clusters
  L = 24.000:   14 maxima, spread   2.451,   14 clusters
  L = 28.000:   71 maxima, spread   0.143,   71 clusters
  L = 32.000:    7 maxima, spread   0.004,    1 clusters
  L = 36.000:   57 maxima, spread   4.676,   57 clusters
  L = 40.000:   55 maxima, spread   4.444,   55 clusters
  L = 44.000:   59 maxima, spread   4.531,   59 clusters
  L = 48.000:   63 maxima, spread   4.677,   63 clusters
  L = 52.000:   60 maxima, spread   4.313,   60 clusters
  L = 56.000:   36 maxima, spread   3.431,   36 clusters
  L = 60.000:   58 maxima, spread   4.605,   58 clusters
  L = 64.000:   54 maxima, spread   3.991,   54 clusters
  L = 68.000:   57 maxima, spread   4.358,   57 clusters
  L = 72.000:   64 maxima, spread   4.510,   64 clusters
No description has been provided for this image

Read the printed columns alongside the figure.

  • $\tilde{L}=8$ and 16 collect no extrema at all, and the reason is not that nothing is unstable -- section 1's count gives one unstable mode at 8 and two at 16. What grows saturates into a steady cellular pattern, and a steady state gives a constant sensor trace, which has no local maxima. An empty column is a fixed point, not an empty run.
  • $\tilde{L}=12$ gives 60 maxima on 2 tight clusters: a periodic branch, and the nearest column to ks1d qp at $\tilde{L}=13.2$. $\tilde{L}=32$ gives 7 maxima spanning 0.004 -- a fixed point with round-off ripple. $\tilde{L}=24$ (14 maxima, spread 2.5) and $\tilde{L}=28$ (71 maxima, spread 0.14) are neither: both are still on their way down to a steady state when the sampling window opens, and raising the per-value transient from 200 to 2000 t.u. empties both columns (measured while writing this, not re-run here). A fixed per-value transient is not always enough near a steady branch, which is worth knowing before reading any bifurcation diagram.
  • From $\tilde{L}=36$ upward every printed column carries 36 to 64 unclustered maxima with a spread of 3.4 to 4.7: sustained broadband dynamics, with ks1d chaos at $\tilde{L}=62.8$ deep inside it and no dropouts. The two tutorial cases sit on opposite sides of the same curve, which is the point of drawing it.

Two things the printout teaches about reading such a diagram at all.

  1. Count clusters, not extrema. A column reporting hundreds of "maxima" spanning a spread of $10^{-3}$ is a fixed point with round-off ripple; count_peak_clusters collapses it to a single cluster, which is why that is the number to read.
  2. One column is one initial condition. Every column here is a 1200 t.u. run from the same low-amplitude seed, and KS is multistable. Two of the broad columns are long transients: at $\tilde{L}=40$ and 56, discarding 3000 t.u. instead of 200 leaves the same seed sitting on a steady state, and so does an $\mathcal{O}(1)$ random field at the same parameters (measured while writing this, not re-run here). Those columns honestly report what the run did; they are not proof that the chaos is the attractor. Read the diagram as a map of what is reachable in 1200 t.u. from a small perturbation of the flat state, which is exactly what it is.

The 2-D sweeps. $\nu_1$ and $\nu_2$ do not collapse into one number, so two lines through the parameter plane are needed. The first fixes $\nu_1=0.5$ and sweeps $\nu_2$ (i.e. the anisotropy $\alpha=\nu_2/\nu_1$ from 0.1 to 1), passing through the quasi-periodic (0.1), periodic (0.2) and travelling (0.35) cases. The second fixes $\nu_2=0.1$ and sweeps $\nu_1$, passing through chaotic (0.1), chaotic_B (0.3) and quasi-periodic (0.5). Both run on a $32\times32$ grid -- defensible because the unstable band is $k<1/\sqrt{\nu_1}\le 4.5$ on integer wavenumbers, so 16 modes per direction resolve every point of the sweep -- with 10 and 12 values respectively and short sampling windows. These are the coarsest diagrams in this notebook; treat each column as one short run, not as a converged attractor.

In [20]:
class KS2Dnu2(KS2D):
    """2-D KS with nu2 exposed as a sweepable parameter."""

    params = ["nu2"]


class KS2Dnu1(KS2D):
    """2-D KS with nu1 exposed as a sweepable parameter."""

    params = ["nu1"]


t0 = time.time()
nu2_vals = np.linspace(0.05, 0.50, 10)          # step 0.05: hits the 0.1 / 0.2 / 0.35 cases exactly
seed2 = KS2Dnu2(Nx=32, Ny=32, nu1=0.5, nu2=float(nu2_vals[0]), dt=0.1, Nq=1)
nu2_sweep, nu2_peaks = nt.bifurcation_sweep(seed2, "nu2", nu2_vals, continuation=True,
                                            t_transient=300.0, t_sample=250.0, extrema=("max",),
                                            Nq=1, seed=SEED)
print(f"2-D KS anisotropy sweep (nu1 = 0.5): {len(nu2_sweep)} values ({time.time() - t0:.0f} s)")

t0 = time.time()
nu1_vals = np.linspace(0.05, 0.60, 12)          # step 0.05: hits the 0.1 / 0.3 / 0.5 cases exactly
seed1 = KS2Dnu1(Nx=32, Ny=32, nu1=float(nu1_vals[0]), nu2=0.1, dt=0.05, Nq=1)
nu1_sweep, nu1_peaks = nt.bifurcation_sweep(seed1, "nu1", nu1_vals, continuation=True,
                                            t_transient=150.0, t_sample=150.0, extrema=("max",),
                                            Nq=1, seed=SEED)
print(f"2-D KS dissipation sweep (nu2 = 0.1): {len(nu1_sweep)} values ({time.time() - t0:.0f} s)")
nu2 sweep:   0%|          | 0/10 [00:00<?, ?it/s]
nu2 sweep:  10%|█         | 1/10 [00:04<00:44,  4.90s/it]
nu2 sweep:  20%|██        | 2/10 [00:09<00:38,  4.87s/it]
nu2 sweep:  30%|███       | 3/10 [00:14<00:32,  4.69s/it]
nu2 sweep:  40%|████      | 4/10 [00:18<00:27,  4.66s/it]
nu2 sweep:  50%|█████     | 5/10 [00:22<00:22,  4.47s/it]
nu2 sweep:  60%|██████    | 6/10 [00:27<00:17,  4.41s/it]
nu2 sweep:  70%|███████   | 7/10 [00:31<00:13,  4.45s/it]
nu2 sweep:  80%|████████  | 8/10 [00:36<00:09,  4.50s/it]
nu2 sweep:  90%|█████████ | 9/10 [00:40<00:04,  4.41s/it]
nu2 sweep: 100%|██████████| 10/10 [00:45<00:00,  4.59s/it]
nu2 sweep: 100%|██████████| 10/10 [00:45<00:00,  4.56s/it]

2-D KS anisotropy sweep (nu1 = 0.5): 10 values (46 s)
nu1 sweep:   0%|          | 0/12 [00:00<?, ?it/s]
nu1 sweep:   8%|▊         | 1/12 [00:05<01:00,  5.53s/it]
nu1 sweep:  17%|█▋        | 2/12 [00:10<00:54,  5.43s/it]
nu1 sweep:  25%|██▌       | 3/12 [00:16<00:48,  5.43s/it]
nu1 sweep:  33%|███▎      | 4/12 [00:21<00:43,  5.43s/it]
nu1 sweep:  42%|████▏     | 5/12 [00:27<00:37,  5.38s/it]
nu1 sweep:  50%|█████     | 6/12 [00:31<00:31,  5.22s/it]
nu1 sweep:  58%|█████▊    | 7/12 [00:36<00:25,  5.13s/it]
nu1 sweep:  67%|██████▋   | 8/12 [00:43<00:22,  5.54s/it]
nu1 sweep:  75%|███████▌  | 9/12 [00:50<00:17,  5.90s/it]
nu1 sweep:  83%|████████▎ | 10/12 [00:57<00:12,  6.24s/it]
nu1 sweep:  92%|█████████▏| 11/12 [01:04<00:06,  6.56s/it]
nu1 sweep: 100%|██████████| 12/12 [01:12<00:00,  7.00s/it]
nu1 sweep: 100%|██████████| 12/12 [01:12<00:00,  6.02s/it]
2-D KS dissipation sweep (nu2 = 0.1): 12 values (72 s)

In [21]:
for sweep, peaks, lab, marks, fname in [
        (nu2_sweep, nu2_peaks, r"$\nu_2$ (at $\nu_1=0.5$)",
         {"quasi-periodic": 0.10, "periodic": 0.20, "ks2d tw": 0.35}, "04_bifurcation_ks2d_nu2.png"),
        (nu1_sweep, nu1_peaks, r"$\nu_1$ (at $\nu_2=0.1$)",
         {"chaotic": 0.10, "ks2d chaos": 0.30, "quasi-periodic": 0.50}, "04_bifurcation_ks2d_nu1.png")]:
    fig, axs = nt.plot_bifurcation(sweep, peaks, lab, [r"$u(x_0)$"])
    plt.setp(axs[0].lines, ms=4)
    for name, v in marks.items():
        axs[0].axvline(v, color=C_MARK, lw=1.4, ls="--")
        axs[0].text(v, axs[0].get_ylim()[1], f" {name}", color=C_MARK, fontsize=8, va="top",
                    rotation=90)
    axs[0].set_title(f"2-D KS: sensor extrema vs {lab} (coarse sweep, 32x32)", fontsize=10)
    save_figure(fig)
    plt.show()

sweep_summary("nu2", nu2_sweep, nu2_peaks)
sweep_summary("nu1", nu1_sweep, nu1_peaks)
No description has been provided for this image
No description has been provided for this image
  nu2 =  0.050:    0 maxima, spread   0.000,    0 clusters
  nu2 =  0.100:   35 maxima, spread  14.184,   35 clusters
  nu2 =  0.150:   36 maxima, spread   7.940,    6 clusters
  nu2 =  0.200:   50 maxima, spread   0.005,    1 clusters
  nu2 =  0.250:   18 maxima, spread   0.002,    1 clusters
  nu2 =  0.300:   17 maxima, spread   6.163,   11 clusters
  nu2 =  0.350:   12 maxima, spread   5.457,   12 clusters
  nu2 =  0.400:    6 maxima, spread   4.919,    6 clusters
  nu2 =  0.450:  417 maxima, spread   0.000,    1 clusters
  nu2 =  0.500:    9 maxima, spread   0.000,    1 clusters
  nu1 =  0.050:  251 maxima, spread  17.759,  251 clusters
  nu1 =  0.100:  131 maxima, spread  13.978,  131 clusters
  nu1 =  0.150:   90 maxima, spread  14.514,   90 clusters
  nu1 =  0.200:   53 maxima, spread  12.534,   53 clusters
  nu1 =  0.250:   44 maxima, spread   4.556,   44 clusters
  nu1 =  0.300:   37 maxima, spread   9.628,   37 clusters
  nu1 =  0.350:   17 maxima, spread   3.632,    2 clusters
  nu1 =  0.400:   16 maxima, spread   3.773,    2 clusters
  nu1 =  0.450:   18 maxima, spread  10.230,   18 clusters
  nu1 =  0.500:   15 maxima, spread   6.558,   15 clusters
  nu1 =  0.550:   14 maxima, spread   3.800,    5 clusters
  nu1 =  0.600:   14 maxima, spread   2.997,   14 clusters

The anisotropy sweep at $\nu_1=0.5$ is a non-chaotic line, and it reproduces the three case labels it passes through: at $\nu_2=0.20$ the column collapses to a single cluster (the periodic case really is a limit cycle), at $\nu_2=0.35$ it opens into the dozen scattered points of the travelling wave, and at $\nu_2=0.10$ it spreads right across the quasi-periodic band. At the damped end ($\nu_2\ge0.45$) the spread collapses to 0.000 at the three decimals printed -- the smallest non-zero spreads on this line are $5\times10^{-3}$ at $\nu_2=0.20$ and $2\times10^{-3}$ at 0.25 -- a fixed point.

Chaos needs weaker fourth-order damping, and the second sweep is where it appears: as $\nu_1$ drops from 0.60 to 0.05 the maxima counted in the 150 t.u. sample window climb from about 14 to 250 and their spread from 3 to 18, with chaotic_B ($\nu_1=0.3$) already in the broad regime and chaotic ($\nu_1=0.1$) further inside it. The few-cluster columns near $\nu_1\approx0.35$-$0.40$ are the kind of narrow periodic window a coarse sweep either lands in or misses entirely -- or an inherited steady branch, because these two sweeps are warm-started chains and the 1-D sweep showed exactly that failure at five of its thirty-three values. A single narrow column in a warm chain is a hypothesis, not a result; the way to test it is to restart from a fresh state at that one parameter value, which is what cold_sweep does above. Read these two figures as a map, not a measurement.

In [22]:
print(f"SUMMARY: {len(CASES_2D)} ks2d cases + {len(CASES_1D)} ks1d case, "
      f"{len(cached)} trajectories cached under {root}\n"
      f"  canonical settings: " + ", ".join(
          f"{c} K={ks2d.TEST_CASES[c].get('K', '-')} r={ks2d.TEST_CASES[c].get('r', '-')}"
          for c in CASES_2D if "K" in ks2d.TEST_CASES[c]) + "\n"
      f"  global POD modes for 99.9% energy: " + ", ".join(
          f"{c}: {int(np.searchsorted(spectra[c], 0.999) + 1)}" for c in CASES_2D) + "\n"
      f"  runtime {time.time() - t_start:.0f}s")
SUMMARY: 5 ks2d cases + 2 ks1d case, 6 trajectories cached under /home/anovoama/.cache/qlrom
  canonical settings: travelling K=5 r=25, chaotic_B K=40 r=50
  global POD modes for 99.9% energy: periodic: 4, travelling: 19, quasi-periodic: 10, chaotic: 54, chaotic_B: 18
  runtime 331s
In [23]:
close_pdf()
15 figures written to ../outputs/00_test_cases_for_tutorials.pdf
Out[23]:
PosixPath('../outputs/00_test_cases_for_tutorials.pdf')