ntsa 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ntsa/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """ntsa — nonlinear time-series analysis for dynamical-system models.
2
+
3
+ - `ntsa.tools` — delay embeddings, return/Poincaré maps, recurrence, MDS,
4
+ regime classification, bifurcation sweeps.
5
+ - `ntsa.lyapunov` — Benettin QR spectrum, leading exponent, Kaplan-Yorke.
6
+ - `ntsa.characterize` — per-case diagnostic figure rows and a demo driver
7
+ (``python -m ntsa.characterize``).
8
+
9
+ Works with any model implementing the `dynamodels.Model` interface (see the
10
+ "Model protocol" section of the README) — `dynamodels` is the reference
11
+ implementation used by the demos and tests, but any duck-type is accepted.
12
+
13
+ Reference: Kantz & Schreiber, *Nonlinear Time Series Analysis* (2nd ed., CUP 2004).
14
+ """
15
+
16
+ __version__ = "0.1.0"
ntsa/characterize.py ADDED
@@ -0,0 +1,567 @@
1
+ """Per-case nonlinear time-series characterization figures (Kantz & Schreiber style).
2
+
3
+ One row of 8 panels per case [time series + zoom inset | semilogy PSD | 3-D delay
4
+ portrait | first-return map | plane-crossing Poincaré section | recurrence plot |
5
+ 3-D MDS | Lyapunov spectrum], followed by Lyapunov-fit, Lyapunov-spectrum and
6
+ MDS-embedding pages, all saved to a single PDF.
7
+
8
+ Run ``python -m ntsa.characterize --help`` for the demo driver.
9
+ """
10
+
11
+ import os
12
+
13
+ import matplotlib.pyplot as plt
14
+ import numpy as np
15
+ from matplotlib.backends.backend_pdf import PdfPages
16
+
17
+ from ntsa import lyapunov as lyap
18
+ from ntsa import tools as ntsa_tools
19
+ from ntsa.tools import fun_PSD
20
+
21
+
22
+ def save_figs_pdf_tight(pdf_name, figs):
23
+ """Multi-page PDF with pages cropped to content (a save_figs_to_pdf
24
+ cannot pass bbox_inches, and src/ is off-limits from dev/). Closes the figures."""
25
+ with PdfPages(pdf_name) as pdf:
26
+ for fig in figs:
27
+ pdf.savefig(fig, dpi=300, bbox_inches='tight')
28
+ plt.close(fig)
29
+
30
+
31
+ def _tight(fig):
32
+ """Shrink constrained-layout padding so subplots fill the canvas."""
33
+ eng = fig.get_layout_engine()
34
+ if eng is not None:
35
+ eng.set(w_pad=0.01, h_pad=0.0, wspace=0.02, hspace=0.0)
36
+ return fig
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Row of 5 diagnostic panels
40
+ # ---------------------------------------------------------------------------
41
+
42
+ def plot_row(fig, gs_row, t, x, dt, zeta, dim, t_CR, title=None, evidence=None,
43
+ gamma=None, t_gamma=None, exponents=None, lam1=None, lam1_std=0.0):
44
+ """Draw one 8-panel diagnostic row into a gridspec row: time series, PSD,
45
+ delay portrait (with D_KY box), maxima return map, plane-crossing Poincare
46
+ section, recurrence plot, 3-D MDS, Lyapunov spectrum.
47
+
48
+ Parameters
49
+ ----------
50
+ fig : matplotlib.figure.Figure
51
+ Target figure.
52
+ gs_row : matplotlib.gridspec.SubplotSpec
53
+ A gridspec slot spanning the row (subdivided into 8 columns here).
54
+ t, x : np.ndarray
55
+ Time vector and scalar observable, shape (Nt,).
56
+ dt : float
57
+ Sampling time of `t`.
58
+ zeta : int
59
+ Delay (samples) for the embedding.
60
+ dim : int
61
+ Embedding dimension (used by the recurrence panel).
62
+ t_CR : float
63
+ Characteristic time of the model; fallback for the zoom/recurrence windows.
64
+ title : str, optional
65
+ Multi-line row title, drawn as the y-label of the first panel.
66
+ evidence : dict, optional
67
+ ``classify_regime`` evidence; its PSD peaks (f1, f2, ratio) are marked and
68
+ listed on the PSD panel so the LC/QP/CH call can be checked by eye.
69
+ gamma, t_gamma : np.ndarray, optional
70
+ ``classical_mds`` coordinates (T, >=2) and their times (colour scale) for
71
+ the MDS panel; blank panel if omitted.
72
+ exponents : np.ndarray, optional
73
+ Lyapunov spectrum for the last panel; when omitted, a finite `lam1` (+/-
74
+ `lam1_std`) is drawn as a single leading-exponent marker instead.
75
+ """
76
+ sub = gs_row.subgridspec(1, 8, wspace=0.05)
77
+ ax_ts = fig.add_subplot(sub[0])
78
+ ax_psd = fig.add_subplot(sub[1])
79
+ ax_3d = fig.add_subplot(sub[2], projection='3d')
80
+ ax_map = fig.add_subplot(sub[3])
81
+ ax_poin = fig.add_subplot(sub[4])
82
+ ax_rec = fig.add_subplot(sub[5])
83
+ ax_mds = fig.add_subplot(sub[6], projection='3d')
84
+ ax_lyap = fig.add_subplot(sub[7])
85
+
86
+ # PSD first: the dominant frequency sizes the panel windows
87
+ f, psd = fun_PSD(dt, x)
88
+ p = psd[0]
89
+ f1 = (evidence or {}).get('f1')
90
+ if f1 is None:
91
+ ip = 1 + int(np.argmax(p[1:])) # skip DC
92
+ f1 = f[ip] if f[ip] > 0 else None
93
+
94
+ # oscillation timescale: mean inter-maximum interval (robust for broadband chaos,
95
+ # where the PSD argmax can sit on a low-frequency bump far from the orbit frequency)
96
+ xm_i, xm_ip1, pk_idx = ntsa_tools.first_return_map(x)
97
+ if pk_idx.size > 2:
98
+ T_osc = dt * float(np.mean(np.diff(pk_idx)))
99
+ elif f1:
100
+ T_osc = 1.0 / f1
101
+ else:
102
+ T_osc = t_CR
103
+
104
+ # -- time series (red); zoom inset spans ~5 oscillation periods
105
+ ax_ts.plot(t, x, color='tab:red', lw=0.4)
106
+ ax_ts.set_xlabel('$t$')
107
+ ax_ts.margins(x=0)
108
+ if title:
109
+ ax_ts.set_ylabel(title, fontsize=8)
110
+ n_zoom = max(2, min(len(t) - 1, int(round(5 * T_osc / dt))))
111
+ axins = ax_ts.inset_axes([0.08, 0.68, 0.55, 0.28])
112
+ axins.plot(t[-n_zoom:], x[-n_zoom:], color='tab:red', lw=0.5)
113
+ axins.tick_params(labelsize=5, length=2)
114
+ axins.margins(x=0)
115
+
116
+ # -- PSD (purple, semilogy)
117
+ ax_psd.semilogy(f[1:], p[1:], color='purple', lw=0.4)
118
+ top = p[1:].max()
119
+ if top > 0:
120
+ ax_psd.set_ylim(top * 1e-8, top * 3)
121
+ # xlim: 5x the dominant frequency (peaks+harmonics), extended to the
122
+ # frequency holding 99% of cumulative power so broadband chaos stays visible
123
+ power = p[1:] ** 2
124
+ f99 = f[1 + int(np.searchsorted(np.cumsum(power), 0.99 * power.sum()))]
125
+ if f1:
126
+ ax_psd.set_xlim(0, min(f[-1], max(5 * f1, f99)))
127
+ ax_psd.set(xlabel='Frequency', ylabel='PSD')
128
+ if evidence is not None:
129
+ _annotate_psd_peaks(ax_psd, f, p, evidence)
130
+
131
+ # -- 3-D delay portrait (green), with the Kaplan-Yorke dimension when a spectrum exists
132
+ ax_3d.plot(x[:-2 * zeta], x[zeta:-zeta], x[2 * zeta:], color='green', lw=0.4)
133
+ ax_3d.set_xlabel('$x(t)$', fontsize=8, labelpad=-2)
134
+ ax_3d.set_ylabel(r'$x(t+\zeta)$', fontsize=8, labelpad=-2)
135
+ ax_3d.set_zlabel(r'$x(t+2\zeta)$', fontsize=8, labelpad=-2)
136
+ ax_3d.tick_params(labelsize=6, pad=-1)
137
+ for axis in (ax_3d.xaxis, ax_3d.yaxis, ax_3d.zaxis):
138
+ axis.pane.fill = False
139
+ if exponents is not None:
140
+ ax_3d.text2D(0.02, 0.92, f'$D_{{KY}}$={lyap.kaplan_yorke(exponents):.2f}',
141
+ transform=ax_3d.transAxes, fontsize=6,
142
+ bbox=dict(boxstyle='round', fc='w', ec='0.6', alpha=0.8))
143
+
144
+ # -- first-return map of local maxima (blue) with identity line
145
+ if xm_i.size:
146
+ lo = min(xm_i.min(), xm_ip1.min())
147
+ hi = max(xm_i.max(), xm_ip1.max())
148
+ # floor the span at 5% of the signal range: a period-1 cycle then shows as a
149
+ # single dot instead of a magnified cloud of peak-sampling jitter
150
+ min_span = 0.05 * np.ptp(x)
151
+ if hi - lo < min_span:
152
+ mid = 0.5 * (hi + lo)
153
+ lo, hi = mid - 0.5 * min_span, mid + 0.5 * min_span
154
+ pad = 0.05 * ((hi - lo) or max(abs(hi), 1.0))
155
+ lo, hi = lo - pad, hi + pad
156
+ else:
157
+ lo, hi = 0.0, 1.0
158
+ ax_map.plot([lo, hi], [lo, hi], color='grey', lw=0.8, zorder=1)
159
+ if xm_i.size:
160
+ ax_map.scatter(xm_i, xm_ip1, s=8, color='tab:blue', zorder=2)
161
+ ax_map.set(xlim=(lo, hi), ylim=(lo, hi),
162
+ xlabel=r'$x_{\max}(i)$', ylabel=r'$x_{\max}(i+1)$')
163
+ ax_map.set_aspect('equal', adjustable='box')
164
+
165
+ # -- plane-crossing Poincare section (orange): x(t+2*zeta) = median, upward
166
+ # crossings — a period-k cycle gives k dots, a 2-torus a closed loop, chaos
167
+ # a fractal scatter (complements the maxima return map)
168
+ P = ntsa_tools.poincare_section(x, zeta)
169
+ if len(P):
170
+ plo = min(P[:, 0].min(), P[:, 1].min())
171
+ phi = max(P[:, 0].max(), P[:, 1].max())
172
+ min_span = 0.05 * np.ptp(x) # same jitter floor as the return map
173
+ if phi - plo < min_span:
174
+ mid = 0.5 * (phi + plo)
175
+ plo, phi = mid - 0.5 * min_span, mid + 0.5 * min_span
176
+ pad = 0.05 * (phi - plo)
177
+ ax_poin.scatter(P[:, 0], P[:, 1], s=4, color='darkorange')
178
+ ax_poin.set(xlim=(plo - pad, phi + pad), ylim=(plo - pad, phi + pad))
179
+ ax_poin.set(xlabel='$x(t)$', ylabel=r'$x(t+\zeta)$')
180
+ ax_poin.set_aspect('equal', adjustable='box')
181
+
182
+ # -- recurrence plot (binary) over a trailing window of ~10 oscillation periods,
183
+ # floored at 500 samples: strongly chaotic signals carry many maxima per orbit, which
184
+ # shrinks T_osc below the recurrence time and leaves only the main diagonal visible
185
+ n_win = int(np.clip(10 * T_osc / dt, 500, 2000))
186
+ n_win = min(n_win, len(x))
187
+ n_min = (dim - 1) * zeta + 2
188
+ if n_win <= n_min: # ponytail: guard against windows shorter than the embedding
189
+ n_win = min(len(x), n_min + 50)
190
+ Y = ntsa_tools.delay_embed(x[-n_win:], dim, zeta)
191
+ R = ntsa_tools.recurrence_matrix(Y)
192
+ if not 0.03 <= R.mean() <= 0.4: # degenerate density -> fixed 10% recurrence rate
193
+ R = ntsa_tools.recurrence_matrix(Y, rr=0.10)
194
+ # embedded vector i sits at t[-n_win + i]: the axis ends (dim-1)*zeta samples before t[-1]
195
+ t_end = t[-n_win] + (R.shape[0] - 1) * dt
196
+ ax_rec.imshow(R, cmap='binary', origin='lower',
197
+ extent=[t[-n_win], t_end, t[-n_win], t_end], aspect='auto')
198
+ ax_rec.set(xlabel='$t$', ylabel='$t$')
199
+
200
+ # -- 3-D MDS portrait (gamma_1, gamma_2, gamma_3) coloured by time
201
+ if gamma is not None:
202
+ c = t_gamma if t_gamma is not None else np.arange(len(gamma))
203
+ g3 = gamma[:, 2] if gamma.shape[1] > 2 else np.zeros(len(gamma))
204
+ ax_mds.scatter(gamma[:, 0], gamma[:, 1], g3, s=2, c=c, cmap='viridis',
205
+ alpha=0.6, rasterized=True)
206
+ ax_mds.set_xlabel(r'$\gamma_1$', fontsize=8, labelpad=-2)
207
+ ax_mds.set_ylabel(r'$\gamma_2$', fontsize=8, labelpad=-2)
208
+ ax_mds.set_zlabel(r'$\gamma_3$', fontsize=8, labelpad=-2)
209
+ ax_mds.tick_params(labelsize=6, pad=-1)
210
+ for axis in (ax_mds.xaxis, ax_mds.yaxis, ax_mds.zaxis):
211
+ axis.pane.fill = False
212
+ else:
213
+ ax_mds.set_axis_off()
214
+
215
+ # -- Lyapunov spectrum (or the leading exponent alone when no spectrum was run)
216
+ if exponents is not None:
217
+ exponents = np.asarray(exponents)
218
+ pos = exponents > 0
219
+ kk = np.arange(1, len(exponents) + 1)
220
+ ax_lyap.axhline(0.0, color='grey', lw=0.6)
221
+ ax_lyap.scatter(kk[pos], exponents[pos], s=14, color='tab:red', zorder=3)
222
+ ax_lyap.scatter(kk[~pos], exponents[~pos], s=14, color='tab:blue', zorder=3)
223
+ for j, lam in zip(kk[pos], exponents[pos]): # print every positive exponent
224
+ ax_lyap.annotate(f'{lam:.3g}', xy=(j, lam), textcoords='offset points',
225
+ xytext=(4, 3), fontsize=6)
226
+ linthresh = max(0.05, 2 * exponents[pos].max()) if pos.any() else 0.05
227
+ ax_lyap.set_yscale('symlog', linthresh=linthresh)
228
+ ax_lyap.set(xlabel='$j$', ylabel=r'$\lambda_j$')
229
+ elif lam1 is not None and np.isfinite(lam1):
230
+ ax_lyap.axhline(0.0, color='grey', lw=0.6)
231
+ ax_lyap.errorbar([1], [lam1], yerr=[lam1_std], fmt='o', ms=4,
232
+ color='tab:red' if lam1 > 0 else 'tab:blue', capsize=3)
233
+ if lam1 > 0:
234
+ ax_lyap.annotate(f'{lam1:.3g}', xy=(1, lam1), textcoords='offset points',
235
+ xytext=(6, 3), fontsize=6)
236
+ ax_lyap.set(xlim=(0.5, 1.5), xticks=[1], xlabel='$j$', ylabel=r'$\lambda_1$')
237
+ else:
238
+ ax_lyap.axis('off')
239
+
240
+ for ax in (ax_ts, ax_psd, ax_map, ax_poin, ax_rec, ax_lyap):
241
+ ax.tick_params(labelsize=7)
242
+
243
+
244
+ def _annotate_psd_peaks(ax, f, p, evidence):
245
+ """Mark classify_regime's PSD peaks and list their values in a text box."""
246
+ peaks = evidence.get('psd_peak_freqs')
247
+ xmax = ax.get_xlim()[1]
248
+ if peaks is not None and len(peaks):
249
+ fp = np.asarray(peaks)
250
+ fp = fp[fp <= xmax]
251
+ idx = np.searchsorted(f, fp).clip(1, len(f) - 1)
252
+ if idx.size > 6: # only the strongest few, or broadband spectra drown in markers
253
+ keep = np.argsort(p[idx])[-6:]
254
+ fp, idx = fp[keep], idx[keep]
255
+ ax.plot(fp, p[idx] * 2.0, marker='v', ls='none', ms=3, color='k', zorder=3)
256
+ lines = []
257
+ for key in ('f1', 'f2'):
258
+ if evidence.get(key) is not None:
259
+ lines.append(f'$f_{key[1]}$={evidence[key]:.4g}')
260
+ if evidence.get('f1') and evidence.get('f2'):
261
+ ratio = f'$f_2/f_1$={evidence["f2"] / evidence["f1"]:.3f}'
262
+ if evidence.get('rational_match'):
263
+ ratio += f'$\\approx${evidence["rational_match"]}'
264
+ lines.append(ratio)
265
+ if lines:
266
+ ax.text(0.97, 0.95, '\n'.join(lines), transform=ax.transAxes, fontsize=6,
267
+ ha='right', va='top', family='monospace',
268
+ bbox=dict(boxstyle='round,pad=0.25', fc='white', ec='0.7', alpha=0.8))
269
+
270
+
271
+ # ---------------------------------------------------------------------------
272
+ # Lyapunov and MDS pages
273
+ # ---------------------------------------------------------------------------
274
+
275
+ def plot_lyapunov_fit(res):
276
+ """Plot the leading-Lyapunov log-separation curves and the linear fit.
277
+
278
+ Parameters
279
+ ----------
280
+ res : dict
281
+ Result dict from ``lyapunov.leading_lyapunov`` (keys ``t``, ``log_sep``,
282
+ ``mean_log_sep``, ``i1``, ``i2``, ``r2``, ``lam1``, ``lam1_std``).
283
+ """
284
+ fig, ax = plt.subplots(figsize=(7, 4), layout='constrained')
285
+ _tight(fig)
286
+ t = np.asarray(res['t'])
287
+ ax.plot(t, res['log_sep'], color='grey', lw=0.5, alpha=0.5)
288
+ ax.plot(t, res['mean_log_sep'], color='k', lw=1.8, label='mean')
289
+
290
+ lam1 = res['lam1']
291
+ if lam1 is not None and np.isfinite(lam1):
292
+ i1, i2 = res['i1'], res['i2']
293
+ tf = t[i1:i2]
294
+ line = np.asarray(res['mean_log_sep'])[i1] + lam1 * (tf - tf[0])
295
+ ax.plot(tf, line, color='tab:red', ls='--', lw=1.5,
296
+ label=rf'$\lambda_1 = {lam1:.3f} \pm {res["lam1_std"]:.3f}$ ($R^2={res["r2"]:.2f}$)')
297
+ else:
298
+ ax.set_title(f'no reliable exponential growth ($R^2$ = {res["r2"]:.2f})', fontsize=9)
299
+ ax.set(xlabel='$t$', ylabel='log separation')
300
+ ax.legend(fontsize=8)
301
+ return fig
302
+
303
+
304
+ def plot_lyapunov_spectrum(exponents):
305
+ """Scatter the Lyapunov spectrum vs. index on a symlog axis."""
306
+ exponents = np.asarray(exponents, dtype=float)
307
+ fig, ax = plt.subplots(figsize=(6, 4), layout='constrained')
308
+ _tight(fig)
309
+ idx = np.arange(1, len(exponents) + 1)
310
+ pos = exponents > 0
311
+ linthresh = max(0.05, 2 * np.abs(exponents[pos]).max()) if pos.any() else 0.05
312
+
313
+ ax.plot(idx, exponents, color='grey', lw=0.8, alpha=0.5, zorder=1)
314
+ ax.scatter(idx[pos], exponents[pos], color='tab:red', s=40, zorder=3, label=r'$\lambda_j > 0$')
315
+ ax.scatter(idx[~pos], exponents[~pos], color='tab:blue', s=40, zorder=3, label=r'$\lambda_j \leq 0$')
316
+ ax.axhline(0, color='k', lw=0.8, ls='--')
317
+ ax.set_yscale('symlog', linthresh=linthresh, linscale=0.5)
318
+ ax.set(xlabel='Index $j$', ylabel=r'$\lambda_j$')
319
+ if pos.any(): # print the value of every positive exponent
320
+ for j, lam in zip(idx[pos], exponents[pos]):
321
+ ax.annotate(f'{lam:.3g}', xy=(j, lam), textcoords='offset points',
322
+ xytext=(6, 4), fontsize=8)
323
+ else:
324
+ ax.annotate(rf'$\lambda_1 = {exponents[0]:.3g}$', xy=(1, exponents[0]),
325
+ xytext=(1 + max(len(exponents) / 8, 1.0), exponents[0]),
326
+ arrowprops=dict(arrowstyle='->', color='k'), fontsize=10)
327
+ ax.legend(fontsize=8, loc='lower left')
328
+ return fig
329
+
330
+
331
+ def plot_mds(gamma, t_sub):
332
+ """Classical-MDS embedding: 2-D (gamma_1, gamma_2) and 3-D (gamma_1..3) coloured by time."""
333
+ fig = plt.figure(figsize=(11, 5), layout='constrained')
334
+ _tight(fig)
335
+ ax2d = fig.add_subplot(1, 2, 1)
336
+ ax3d = fig.add_subplot(1, 2, 2, projection='3d')
337
+
338
+ kw = dict(c=t_sub, cmap='viridis', s=4, alpha=0.5, rasterized=True)
339
+ sc = ax2d.scatter(gamma[:, 0], gamma[:, 1], **kw)
340
+ ax3d.scatter(gamma[:, 0], gamma[:, 1], gamma[:, 2], **kw)
341
+ fig.colorbar(sc, ax=ax2d, label='$t$', shrink=0.7)
342
+
343
+ ax2d.set(xlabel=r'$\gamma_1$', ylabel=r'$\gamma_2$')
344
+ ax3d.set(xlabel=r'$\gamma_1$', ylabel=r'$\gamma_2$', zlabel=r'$\gamma_3$')
345
+ for axis in (ax3d.xaxis, ax3d.yaxis, ax3d.zaxis):
346
+ axis.pane.fill = False
347
+ ax3d.grid(False)
348
+ ax3d.set_box_aspect([1, 1, 1])
349
+ return fig
350
+
351
+
352
+ # ---------------------------------------------------------------------------
353
+ # Batch driver
354
+ # ---------------------------------------------------------------------------
355
+
356
+ def _lam1_text(res):
357
+ """Displayable leading exponent: prefer the Benettin spectrum; flag transient-growth
358
+ fits that are not significantly positive with '*' (they contradict non-chaotic labels)."""
359
+ if res['spectrum'] is not None:
360
+ return f'{res["spectrum"][0]:.3f}'
361
+ lam = res['lambda1']
362
+ if lam is None or not np.isfinite(lam):
363
+ return ''
364
+ return (f'{lam:.3f}$\\pm${res["lambda1_std"]:.2g}'
365
+ + ('*' if lam - 2 * res['lambda1_std'] <= 0 else ''))
366
+
367
+
368
+ def _default_label(model):
369
+ pars = ', '.join(f'{p}={getattr(model, p):.4g}' for p in model.params)
370
+ return f'{model.name} ({pars})' if pars else model.name
371
+
372
+
373
+ def characterize(models, labels=None, obs_idx=0, t_run=None, t_transient=None,
374
+ lyapunov='auto', mds=True,
375
+ spectrum='auto', rows_per_page=4, pdf_name='figs/ntsa_characterization.pdf'):
376
+ """Characterize one or more models: run, embed, classify, and plot to a multi-page PDF.
377
+
378
+ Parameters
379
+ ----------
380
+ models : Model or list of Model
381
+ Model instance(s); each is respawned so the caller's copies are untouched.
382
+ labels : list of str, optional
383
+ Case labels; defaults to ``name (param=value, ...)``.
384
+ obs_idx : int
385
+ Index of the observable used for the scalar analysis.
386
+ t_run : float, optional
387
+ Simulation horizon (defaults to ``100 * model.t_CR`` per case).
388
+ t_transient : float, optional
389
+ Discarded transient (defaults to ``model.t_transient``). Raise it for
390
+ cases near a bifurcation (critical slowing down), where residual drift
391
+ pollutes the return map — ``run_long`` trims detectable amplitude drift,
392
+ but structural drift (e.g. slow phase locking) it cannot see.
393
+ lyapunov : {'auto', True, False}
394
+ Run ``leading_lyapunov`` (works for any model); 'auto'/True wraps in try/except.
395
+ mds : bool
396
+ Compute and plot the classical-MDS embedding of the full state.
397
+ spectrum : {'auto', True, False}
398
+ Run ``lyapunov_spectrum``; 'auto' only when the model has ``time_derivative``.
399
+ rows_per_page : int
400
+ Diagnostic rows per PDF page.
401
+ pdf_name : str
402
+ Output PDF path; the first row-grid page is also saved as a same-name PNG.
403
+
404
+ Returns
405
+ -------
406
+ list of dict
407
+ Per case: label, zeta, dim, fnn, lambda1, lambda1_std, lyap_fit, spectrum,
408
+ regime, evidence, stats, gamma, t, x.
409
+ """
410
+ if not isinstance(models, (list, tuple)):
411
+ models = [models]
412
+ if labels is None:
413
+ labels = [_default_label(mi) for mi in models]
414
+ elif isinstance(labels, str):
415
+ labels = [labels]
416
+
417
+ results, row_data = [], []
418
+ for mi, case_label in zip(models, labels):
419
+ print(f'-- characterizing: {case_label}')
420
+ run_model = ntsa_tools.respawn(mi)
421
+ t, y, psi = ntsa_tools.run_long(run_model, t_run if t_run is not None else 100 * mi.t_CR,
422
+ t_transient=t_transient)
423
+ x = y[:, obs_idx]
424
+ dt = float(t[1] - t[0])
425
+
426
+ zeta = ntsa_tools.optimal_lag(x, max_lag=max(2, int(mi.t_CR / mi.dt)))
427
+ dim, fnn = ntsa_tools.false_nearest_neighbours(x, zeta)
428
+
429
+ lam1, lam1_std, lyap_res = None, 0.0, None
430
+ if lyapunov:
431
+ try:
432
+ lam1, lam1_std, lyap_res = lyap.leading_lyapunov(ntsa_tools.respawn(mi))
433
+ except Exception as err:
434
+ print(f' [warning] leading_lyapunov failed: {err}')
435
+
436
+ exps = None
437
+ if spectrum and (spectrum != 'auto' or hasattr(mi, 'time_derivative')):
438
+ try:
439
+ exps = lyap.lyapunov_spectrum(ntsa_tools.respawn(mi))
440
+ except Exception as err:
441
+ print(f' [warning] lyapunov_spectrum failed: {err}')
442
+
443
+ # classify with the Benettin lam1 whenever a spectrum was computed: it is
444
+ # convergence-controlled (std ~ 0), whereas the perturbation-growth fit
445
+ # carries a member spread that inflates the 3-sigma trust floor and can be
446
+ # biased by non-normal transients. Without a spectrum, use the growth fit.
447
+ lam1_cls, lam1_std_cls = lam1, lam1_std
448
+ if exps is not None:
449
+ lam1_cls, lam1_std_cls = float(exps[0]), 0.0
450
+ regime, evidence = ntsa_tools.classify_regime(x, dt, lam1=lam1_cls,
451
+ lam1_std=lam1_std_cls,
452
+ t_total=t[-1] - t[0],
453
+ exponents=exps)
454
+
455
+ gamma, t_sub = None, None
456
+ if mds:
457
+ gamma, idx = ntsa_tools.classical_mds(psi)
458
+ t_sub = t[idx]
459
+
460
+ stats = ntsa_tools.signal_stats(x, dt)
461
+
462
+ results.append({'label': case_label, 'zeta': zeta, 'dim': dim, 'fnn': fnn,
463
+ 'lambda1': lam1, 'lambda1_std': lam1_std, 'lyap_fit': lyap_res,
464
+ 'spectrum': exps, 'regime': regime, 'evidence': evidence,
465
+ 'stats': stats, 'gamma': gamma, 't': t, 'x': x})
466
+ row_data.append({'dt': dt, 't_CR': mi.t_CR, 't_sub': t_sub})
467
+
468
+ # -- figure pages: row grids, then per-case Lyapunov fit / spectrum / MDS
469
+ figs = []
470
+ for page0 in range(0, len(results), rows_per_page):
471
+ chunk = results[page0:page0 + rows_per_page]
472
+ fig = plt.figure(figsize=(19, 2.7 * len(chunk)), layout='constrained')
473
+ _tight(fig)
474
+ gs = fig.add_gridspec(len(chunk), 1)
475
+ for r, res in enumerate(chunk):
476
+ rd = row_data[page0 + r]
477
+ lam_txt = _lam1_text(res)
478
+ lam_txt = f', $\\lambda_1$={lam_txt}' if lam_txt else ''
479
+ title = f'{res["label"]}\n{res["regime"]} $\\zeta$={res["zeta"]}, d={res["dim"]}{lam_txt}'
480
+ plot_row(fig, gs[r], res['t'], res['x'], rd['dt'], res['zeta'], res['dim'],
481
+ rd['t_CR'], title=title, evidence=res['evidence'],
482
+ gamma=res['gamma'], t_gamma=rd['t_sub'], exponents=res['spectrum'],
483
+ lam1=res['lambda1'], lam1_std=res['lambda1_std'])
484
+ figs.append(fig)
485
+ n_rows_pages = len(figs)
486
+
487
+ for res in results:
488
+ if res['lyap_fit'] is not None:
489
+ fig = plot_lyapunov_fit(res['lyap_fit'])
490
+ fig.suptitle(res['label'], fontsize=10)
491
+ figs.append(fig)
492
+ for res in results:
493
+ if res['spectrum'] is not None:
494
+ fig = plot_lyapunov_spectrum(res['spectrum'])
495
+ fig.suptitle(res['label'], fontsize=10)
496
+ figs.append(fig)
497
+ for res, rd in zip(results, row_data):
498
+ if res['gamma'] is not None:
499
+ fig = plot_mds(res['gamma'], rd['t_sub'])
500
+ fig.suptitle(res['label'], fontsize=10)
501
+ figs.append(fig)
502
+
503
+ out_dir = os.path.dirname(pdf_name)
504
+ if out_dir:
505
+ os.makedirs(out_dir, exist_ok=True)
506
+ if n_rows_pages: # PNG of the first row-grid page (save before the PDF closes the figures)
507
+ figs[0].savefig(os.path.splitext(pdf_name)[0] + '.png', dpi=150, bbox_inches='tight')
508
+ save_figs_pdf_tight(pdf_name, figs)
509
+ print(f'Saved figures --> {pdf_name}')
510
+ return results
511
+
512
+
513
+ # ---------------------------------------------------------------------------
514
+ # Demo driver
515
+ # ---------------------------------------------------------------------------
516
+
517
+ if __name__ == '__main__':
518
+ import matplotlib
519
+ matplotlib.use('Agg')
520
+ import argparse
521
+
522
+ from dynamodels.physical import Lorenz63, Lorenz96, VdP
523
+
524
+ parser = argparse.ArgumentParser(description='NTSA characterization of dynamodels-style models.')
525
+ parser.add_argument('--model', choices=['lorenz63', 'lorenz96', 'vdp'], default=None,
526
+ help='single-model sweep (requires --param and --values); default: 4-case demo')
527
+ parser.add_argument('--param', default=None, help='parameter to sweep, e.g. rho')
528
+ parser.add_argument('--values', nargs='+', type=float, default=None, help='parameter values')
529
+ parser.add_argument('--t-run', type=float, default=None, help='run horizon (default 100*t_CR)')
530
+ parser.add_argument('--t-transient', type=float, default=None,
531
+ help='discarded transient (default model.t_transient); raise near bifurcations')
532
+ parser.add_argument('--no-lyapunov', action='store_true')
533
+ parser.add_argument('--no-spectrum', action='store_true')
534
+ parser.add_argument('--no-mds', action='store_true')
535
+ args = parser.parse_args()
536
+
537
+ if args.model:
538
+ if not args.param or not args.values:
539
+ parser.error('--model requires --param and --values')
540
+ cls = {'lorenz63': Lorenz63, 'lorenz96': Lorenz96, 'vdp': VdP}[args.model]
541
+ base = cls()
542
+ cases = [ntsa_tools.respawn(base, **{args.param: v}) for v in args.values]
543
+ tag = f'{args.model}_{args.param}'
544
+ else:
545
+ l63 = Lorenz63()
546
+ cases = [l63, # chaotic
547
+ ntsa_tools.respawn(l63, rho=350., dt=0.005), # period-1 window (finer dt: fast orbit)
548
+ VdP(), # limit cycle
549
+ Lorenz96(Nx=10)] # chaotic, F=8
550
+ tag = 'defaults'
551
+
552
+ out = characterize(cases,
553
+ t_run=args.t_run,
554
+ t_transient=args.t_transient,
555
+ lyapunov=False if args.no_lyapunov else 'auto',
556
+ spectrum=False if args.no_spectrum else 'auto',
557
+ mds=not args.no_mds,
558
+ pdf_name=f'figs/ntsa_{tag}.pdf')
559
+
560
+ hdr = f'{"case":<42} {"regime":<24} {"zeta":>5} {"d":>3} {"lam1":>16}'
561
+ print('\n' + hdr)
562
+ print('-' * len(hdr))
563
+ for res in out:
564
+ lam_txt = _lam1_text(res) or '--'
565
+ print(f'{res["label"]:<42} {res["regime"]:<24} {res["zeta"]:>5d} {res["dim"]:>3d} {lam_txt:>16}')
566
+ if any(_lam1_text(res).endswith('*') for res in out):
567
+ print('* transient-growth fit not significantly positive (no spectrum available)')