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.
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']
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.
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.
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()
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()
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.
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
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.
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:
ks2dunless noted.travellingis the default worked example (a moving structure, no positive Lyapunov exponent, so errors stay readable) andchaotic_Bis 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 layoutfit_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).
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/
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.
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)
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()
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.
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()
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()
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()
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()
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()
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.
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)
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()
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.
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 isquasiperiodic, and section 2 disagrees with it: the eight strongest lines of this signal sit on one generator. Then_genandits f2/f1columns are printed side by side so that the disagreement is visible rather than tidied away. The classifier reachesquasiperiodicbecause $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 thequasiperiodiclabel.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$, andchaotic_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.
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
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 qpat $\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 chaosat $\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.
- 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_clusterscollapses it to a single cluster, which is why that is the number to read. - 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.
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)
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)
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.
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
close_pdf()
15 figures written to ../outputs/00_test_cases_for_tutorials.pdf
PosixPath('../outputs/00_test_cases_for_tutorials.pdf')