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 load a trajectory. Later tutorials open with build_fom and move straight to the reduced-order model (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}\bigl(u_x^2 + \alpha\, u_y^2\bigr) + \bigl(u_{xx} + \alpha\, u_{yy}\bigr) + \nu_1\bigl(\partial_x^2 + \alpha\,\partial_y^2\bigr)^2 u = 0, \qquad (x, y) \in [0, 2\pi)^2, \quad \text{periodic},$$

in which $\alpha = \nu_2/\nu_1$ is the anisotropy ratio. Both are integrated with ETDRK4 in Fourier space. The tutorials use the 2-D family 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. Loading a trajectory (and the cache)
  • 5. What the tutorials assume
  • 6. Characterizing the cases
  • 7. Signal-level analysis
  • 8. Attractor geometry
  • 9. Regime classification 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
from qlroms.utils.plots import close_pdf, save_figure, start_pdf
# the figures only this tutorial draws (tutorials/plots_00.py)
from plots_00 import plot_ks1d_spacetime, plot_ks2d_snapshots, plot_residual_energy

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('figs/00_test_cases_for_tutorials.pdf')

1. The cases¶

Each case is a dictionary in qlroms/intrusive_qlroms/ks{1,2}d/config.py, which 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.

The column lamb1 is the leading Lyapunov exponent that the configuration records, and section 9 checks it against the trajectory. A value of 0 marks a case with no exponential separation (a limit cycle, a torus or a travelling wave), and a positive value marks chaos, which makes chaotic and chaotic_B the demanding 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 = {case: short_run(ks2d, case) for case in CASES_2D}
save_figure(plot_ks2d_snapshots(runs))
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)
save_figure(plot_ks1d_spacetime(X1, float(fom1.dt), float(cfg1.get("Lx", 1.0)), case_1d))
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 short runs of section 2 (n_keep snapshots each), which are too short for a chaotic trajectory to visit its whole attractor. The counts are a lower bound on what a long training trajectory needs, which is why the canonical r column can exceed them.

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}")

save_figure(plot_residual_energy(spectra))
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. Loading 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"]:]

The burn-in i0 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 stores 4096 float64 values, i.e. 32 kB, per snapshot -- 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.31G     40000
 ks2d/nu1_0.50_nu2_0.35_Nx32_Ny32/dt0.1000/full_trajectory.pth     0.86G    113000

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. Doing so once, here, pays off: a ROM is judged not only by how far it drifts, but by whether it keeps the character of the system it replaces, and these are the diagnostics that measure that character.

The cases in WINDOWS (two per equation, in the next cell) are carried through sections 7-9, each on the analysis window set there, because the cases are generated far longer than a notebook can characterize.

In [7]:
import contextlib
import io
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
from dynamodels.physical import KS, KS2D
from ntsa import lyapunov as lyap
from ntsa import tools as nt

# the signal diagnostics that tutorial 3 shares (qlroms/utils/characterization.py)
from qlroms.utils.characterization import (load_characterization_case, lyapunov_verdict, saturation_rate,
                                           signal_report, spectrum_text)
from qlroms.utils.plots import save_figure
# the figures only this tutorial draws (tutorials/plots_00.py)
from plots_00 import (plot_acf_ami, plot_bifurcation_ks1d, plot_bifurcation_ks2d, plot_correlation_sum,
                      plot_delay_embedding, plot_fnn, plot_perturbation_growth, plot_poincare, plot_recurrence,
                      plot_return_maps, plot_spectra)

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

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))
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
# its module's TEST_CASES -- 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)}
In [8]:
# cached FOM trajectory, the sensor signal x_fom and the respawned FOM that ntsa integrates
cases = {name: load_characterization_case(name, n_sig=N_SIG, **window) for name, window in WINDOWS.items()}
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 holds $N$ = N_SIG 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. The autocorrelation, $\rho(\tau)=\langle x_n x_{n+\tau}\rangle/\langle x_n^2\rangle$, 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 lag window (a quarter of the record, capped) is $\mathcal{O}(1)$ if the signal still remembers itself at the far end of that window and small if it does not. A tail above ACF_TAIL_DISCRETE marks the spectrum as discrete, and only then is a line lattice quoted.

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. When $I$ is nearly independent of the lag, as on a noiseless periodic signal, ntsa falls back to the first ACF zero crossing.

Embedding dimension. The function 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. If no $d$ up to d_max reaches that threshold, ntsa returns the $d$ with the smallest fraction, which is then not an embedding dimension; the FNN figure shows whether each curve crosses the dashed 1% line.

In [9]:
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.1777 | ACF first zero 6.7 t.u., tail |rho| 0.81 | optimal lag zeta = 57 samples = 5.70 t.u. | FNN embedding dim = 4 | D2 = 2.30 (small-r 2.10, large-r 2.48)
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 3.6 s)
In [10]:
plot_spectra(cases)
plt.show()
plot_acf_ami(cases)
plt.show()
plot_fnn(cases)
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

Read the printed line of each case in the order in which the diagnostics are gated.

  • ACF tail first. A tail above ACF_TAIL_DISCRETE means that the signal still remembers itself at the far end of the lag window: the spectrum is a set of lines, and the generator count is quoted. A smaller tail means a continuum, and the line reports broadband with no lattice, however many periodogram maxima it finds.
  • Generators, on a discrete spectrum. One generator is a periodic signal, i.e. a closed curve; two generators are a 2-torus; more are a higher torus, or lines that the lattice test fails to combine. On a torus, $D_2$ should match the generator count, and the paragraph after this list explains why it can fall short.
  • Correlation dimension, on a broadband spectrum. A non-integer $D_2$ is the signature of a strange attractor, because no integer-dimensional torus produces it.
  • FNN dimension against correlation dimension. FNN works from nearest neighbours, i.e. from the smallest scales, whereas $D_2$ is a large-scale slope. An FNN dimension $d$ well above $D_2$ therefore signals structure below the $D_2$ window, and the split slope points the same way when its small-$r$ value exceeds its large-$r$ value.
  • A travelling wave at a fixed sensor need not be periodic. A pattern that drifts obliquely across the periodic box, or changes shape as it moves, can pass the sensor with two frequencies, and the lattice then needs two generators. The case name describes the field; the spectrum describes one sensor.

Why a torus can read a $D_2$ below its dimension. The function 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 is measured is the curve the trajectory winds along, not the sheet it winds on. The split slope printed above is how to catch that: a small-$r$ slope above the large-$r$ slope means that the object opens up as $r$ shrinks, whereas two equal slopes mean that the dimension holds at every scale the record resolves. Read $D_2$ as "this many dimensions at the scales resolved here", never as proof that nothing else exists further down.

The delay $\zeta$ is printed in time units as well as in samples because it is a physical timescale. Compare it across cases in time units, together with the ACF first zero, because a count of samples depends on $\Delta t$.

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. The function recurrence_matrix is $O(T^2)$ in memory, so the window is clipped to N_REC 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 7. 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]:
plot_delay_embedding(cases)
plt.show()
No description has been provided for this image
In [12]:
plot_return_maps(cases)
plt.show()
No description has been provided for this image
In [13]:
plot_poincare(cases)
plt.show()
No description has been provided for this image
In [14]:
plot_recurrence(cases, N_REC)
plt.show()
No description has been provided for this image
In [15]:
plot_correlation_sum(cases)
plt.show()
No description has been provided for this image

Read each figure against the verdicts of section 7. A discrete case retraces itself in the delay portrait (a closed curve, or a tube wound on a torus); it gives a finite set of points (a closed orbit) or a closed curve (a 2-torus) in the return map and in the Poincare section; and it draws unbroken diagonals in the recurrence plot wherever the N_REC-sample window contains a return. A broadband case draws a folded portrait that never closes, a scattered or curve-like return map, a cloud of section points and broken diagonals. Two discrete cases need not be the same object: the generator count and $D_2$ of section 7 separate a closed curve from a 2-torus, and these pictures should say the same 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¶

The function 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 7 -- $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%. The locking test compares two lines where section 7 fits a whole lattice, so the table below prints both verdicts side by side: the two can 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 and KS2D; lyapunov_spectrum does not, because both are discrete maps with no time_derivative, and ntsa raises a clear AttributeError rather than guessing.

The 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 separation plateaus, flat, below 5% of the attractor diameter. A burst that ends above that floor passes the guard, and ntsa then returns a positive slope for an orbit whose true exponent is zero. So we add a second, explicit test: accept $\lambda_1$ only if the fitted growth window spans at least a fraction EXP_WINDOW_MIN of the record. The perturbation-growth figure below shows which of the two tests decides each case.

Every run uses a few perturbations (n_pert) and the horizon t_lam of its case. 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 a chaotic case the estimate typically moves with t_lam, so re-run the cell at a few horizons before quoting a value. The printed exponents are estimates, not converged values, and the text never treats them as more than that.

In [16]:
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, min_window=EXP_WINDOW_MIN)
    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 72 s)
In [17]:
plot_perturbation_growth(cases)
plt.show()
No description has been provided for this image

Read the curves, not the slopes. Three shapes occur, and the verdict in each legend names the test that decides it.

  • A curve that stays flat far below the dashed attractor diameter shows no expansion: ntsa's floor fires, and the verdict is rejected (no growth).
  • A curve that rises in one short burst and then stops climbing, whether it plateaus or fluctuates, is a transient, not a sustained exponential rate. Its fit window is short, and the EXP_WINDOW_MIN test reports it as transient.
  • A curve that climbs steadily across a large fraction of the record and levels off near the diameter is decorrelation, i.e. chaos. Only such slopes are kept.

The Lyapunov cell prints the numbers behind these shapes: the plateau, as a percentage of the diameter, for a rejected fit, and the climb and its duration for an accepted fit.

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.30      2      1.583         -
ks2d chaos                    0.303      1.800            chaotic        32      3   2.02      0      1.333       4/3

Read the table one pair of columns at a time.

  • lam1 measured against lamb1 cfg. The measured column holds a number only when the fit passes both tests of section 9; otherwise it reads rejected (no growth) (ntsa's floor) or transient (a fit window shorter than EXP_WINDOW_MIN). A rejected or transient fit is consistent with a recorded 0. An accepted fit is compared with the recorded value, and the Lyapunov cell above adds a fit-free test: it converts the recorded exponent into the time that $\mathrm{e}^{\lambda_1 t}$ needs to cover the measured climb. If that time is a small fraction of the time the record took, the record excludes the recorded value, because an exponent that large would have saturated the separation early and left the curve flat for the rest of the run.
  • The recorded lamb1 cfg is a configuration entry, not a measurement made here. Check it against the measured column before quoting it, and be wary of two parameter points that record the same value in the table of section 1.
  • classify_regime against n_gen. These are two spectral verdicts. The classifier ranks a few tight clusters of maxima above everything else, so a torus whose second frequency is weak can be labelled limit_cycle_period_k while the lattice fit of section 7 still counts two generators. Its locking test compares only $f_2/f_1$ (its f2/f1) with low-order fractions, so a ratio just outside the tolerance turns frequency_locked into quasiperiodic. When the two verdicts disagree, read the cluster count as how the maxima group and the generator count as how many frequencies the lines need.
  • A rational match on a chaotic row is harmless: $\lambda_1$ decides before the ratio test is reached.

An accepted $\lambda_1$ is still a local, finite-time estimate: quote it with its horizon t_lam, never as a converged exponent. This matters because the Lyapunov time, $1/\lambda_1$, is the yardstick against which every predictability time of a case is measured.

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 and qlroms has no parametric path, so there is nothing for bifurcation_sweep to vary. 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. The function bifurcation_sweep gates on model.params, the list of estimable parameters. The list KS.params is empty, because KS declares nu and L as fixed_params, so the one-line subclass KSL exposes L. The list KS2D.params already holds nu1 and nu2, and the two 2-D subclasses only restate it. 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 can cost more than it buys. Branch following is the right tool for hysteresis and the wrong one for a survey. In a warm chain, the previous value's final state, carried over as the initial condition, can relax onto a steady branch and sit there for the whole sample, so a column inside the chaotic band reads as a fixed point where a fresh start finds broadband dynamics. The 1-D sweep is cheap (its cell prints the cost), so it runs 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$. The rescaling $x=\sqrt{\nu}\,x'$, $t=\nu\,t'$, $v=\sqrt{\nu}\,u$ maps the equation with viscosity $\nu$ on $[0,L)$ onto $\nu=1$ on $[0,\tilde{L})$, with $\tilde{L}=L/\sqrt{\nu}$, so this one axis covers both 1-D cases, which the figure marks at their $\tilde{L}$. Every sweep below is deliberately coarse (see L_vals, t_transient and t_sample in the code): 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 = {name: float(cases[name]["fom"].L / np.sqrt(cases[name]["fom"].nu)) for name in ("ks1d qp", "ks1d chaos")}
save_figure(plot_bifurcation_ks1d(L_sweep, L_peaks, L_CASE))
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 (26 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. Each kind of column has one reading.

  • An empty column is a fixed point, not an empty run. On a short domain few modes are unstable (the linear growth rate $q^2-q^4$ of the wavenumber $q=2\pi k/\tilde{L}$ is positive for $k<\tilde{L}/2\pi$), and what grows can saturate into a steady cellular pattern. A steady state gives a constant sensor trace, which has no local maxima.
  • One or two tight clusters are a periodic branch. A single cluster whose spread is at round-off level is a fixed point with ripple.
  • A few maxima, each its own cluster, with an $\mathcal{O}(1)$ spread can be a run still on its way down to a steady state when the sampling window opens. Raising t_transient at that one value decides it: if the column empties, the run was a transient. A fixed per-value transient is not always enough near a steady branch, which is worth knowing before reading any bifurcation diagram. A [run_long] trimmed line above the table, when present, is ntsa reporting residual drift in one run, which it cuts from that run's record.
  • Many unclustered maxima with an $\mathcal{O}(1)$ spread are sustained broadband dynamics. The green dashed lines mark the two 1-D cases at their $\tilde{L}$; read their columns against these four kinds.

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 one run of t_transient plus t_sample from the same low-amplitude seed, and KS is multistable. A broad column can therefore be a long transient, which a longer t_transient or a different initial field turns into a steady state. The columns report what each run did; they are not proof that the chaos is the attractor. Read the diagram as a map of what is reachable within t_transient plus t_sample 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$), 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, which is defensible while the unstable band, $k_x^2+\alpha k_y^2<1/\nu_1$ on integer wavenumbers, stays well inside the 16 modes resolved per direction; check this before widening nu1_vals or nu2_vals. Both sweeps have few values and short sampling windows, so 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 (KS2D labels both nu1 and
    nu2, and dynamodels requires the labelled parameters to be declared)."""

    params = ["nu1", "nu2"]


class KS2Dnu1(KS2D):
    """2-D KS with nu1 exposed as a sweepable parameter (KS2D labels both nu1 and
    nu2, and dynamodels requires the labelled parameters to be declared)."""

    params = ["nu1", "nu2"]


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.97s/it]
nu2 sweep:  20%|██        | 2/10 [00:09<00:35,  4.50s/it]
nu2 sweep:  30%|███       | 3/10 [00:13<00:31,  4.49s/it]
nu2 sweep:  40%|████      | 4/10 [00:18<00:27,  4.56s/it]
nu2 sweep:  50%|█████     | 5/10 [00:23<00:24,  4.92s/it]
nu2 sweep:  60%|██████    | 6/10 [00:29<00:21,  5.31s/it]
nu2 sweep:  70%|███████   | 7/10 [00:35<00:16,  5.53s/it]
nu2 sweep:  80%|████████  | 8/10 [00:42<00:11,  5.85s/it]
nu2 sweep:  90%|█████████ | 9/10 [00:48<00:05,  5.90s/it]
nu2 sweep: 100%|██████████| 10/10 [00:54<00:00,  5.93s/it]
nu2 sweep: 100%|██████████| 10/10 [00:54<00:00,  5.44s/it]

2-D KS anisotropy sweep (nu1 = 0.5): 10 values (54 s)
nu1 sweep:   0%|          | 0/12 [00:00<?, ?it/s]
nu1 sweep:   8%|▊         | 1/12 [00:06<01:11,  6.49s/it]
nu1 sweep:  17%|█▋        | 2/12 [00:12<01:04,  6.49s/it]
nu1 sweep:  25%|██▌       | 3/12 [00:19<00:59,  6.57s/it]
nu1 sweep:  33%|███▎      | 4/12 [00:25<00:51,  6.45s/it]
nu1 sweep:  42%|████▏     | 5/12 [00:31<00:44,  6.32s/it]
nu1 sweep:  50%|█████     | 6/12 [00:40<00:42,  7.10s/it]
nu1 sweep:  58%|█████▊    | 7/12 [00:48<00:36,  7.38s/it]
nu1 sweep:  67%|██████▋   | 8/12 [00:53<00:26,  6.62s/it]
nu1 sweep:  75%|███████▌  | 9/12 [00:58<00:17,  5.98s/it]
nu1 sweep:  83%|████████▎ | 10/12 [01:03<00:11,  5.77s/it]
nu1 sweep:  92%|█████████▏| 11/12 [01:07<00:05,  5.31s/it]
nu1 sweep: 100%|██████████| 12/12 [01:11<00:00,  4.96s/it]
nu1 sweep: 100%|██████████| 12/12 [01:11<00:00,  5.99s/it]
2-D KS dissipation sweep (nu2 = 0.1): 12 values (72 s)

In [21]:
save_figure(plot_bifurcation_ks2d(nu2_sweep, nu2_peaks, r"$\nu_2$ (at $\nu_1=0.5$)",
                                  {"quasi-periodic": 0.10, "periodic": 0.20, "ks2d tw": 0.35}))
plt.show()
save_figure(plot_bifurcation_ks2d(nu1_sweep, nu1_peaks, r"$\nu_1$ (at $\nu_2=0.1$)",
                                  {"chaotic": 0.10, "ks2d chaos": 0.30, "quasi-periodic": 0.50}))
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

Read the two 2-D figures with the key at the start of this section. On both sweeps, compare each marked column with its case: section 9 measures the regime of the marked cases that are in WINDOWS, and the names of the other cases state the regime they are expected to have. The columns are $32\times32$ runs with the sweep's own time step and windows, so they test a case label rather than reproduce the case.

The dissipation sweep is where chaos is expected: as $\nu_1$ drops, the unstable band $k_x^2+\alpha k_y^2<1/\nu_1$ widens, and the number of maxima and their spread typically grow with it. A narrow column (one or a few clusters) inside a broad region is either a periodic window, which a coarse sweep either lands in or misses entirely, or an inherited steady branch, because these two sweeps are warm-started chains. 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 for the 1-D sweep. 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 285s
In [23]:
close_pdf()
15 figures written to figs/00_test_cases_for_tutorials.pdf
Out[23]:
PosixPath('figs/00_test_cases_for_tutorials.pdf')