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 +16 -0
- ntsa/characterize.py +567 -0
- ntsa/lyapunov.py +379 -0
- ntsa/tools.py +590 -0
- ntsa-0.1.0.dist-info/METADATA +85 -0
- ntsa-0.1.0.dist-info/RECORD +9 -0
- ntsa-0.1.0.dist-info/WHEEL +5 -0
- ntsa-0.1.0.dist-info/licenses/LICENSE +21 -0
- ntsa-0.1.0.dist-info/top_level.txt +1 -0
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)')
|