StochasticForceInference 2.0.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.
- SFI/__init__.py +64 -0
- SFI/bases/__init__.py +85 -0
- SFI/bases/constants.py +492 -0
- SFI/bases/linear.py +325 -0
- SFI/bases/monomials.py +218 -0
- SFI/bases/pairs.py +998 -0
- SFI/bases/spde.py +1537 -0
- SFI/diagnostics/__init__.py +60 -0
- SFI/diagnostics/assess.py +87 -0
- SFI/diagnostics/dynamics_order.py +621 -0
- SFI/diagnostics/plotting.py +226 -0
- SFI/diagnostics/report.py +238 -0
- SFI/diagnostics/residual_tests.py +395 -0
- SFI/diagnostics/residuals.py +688 -0
- SFI/inference/__init__.py +58 -0
- SFI/inference/base.py +1460 -0
- SFI/inference/optimizers.py +200 -0
- SFI/inference/overdamped.py +1214 -0
- SFI/inference/parametric_core/__init__.py +34 -0
- SFI/inference/parametric_core/covariance.py +232 -0
- SFI/inference/parametric_core/driver.py +272 -0
- SFI/inference/parametric_core/flow.py +149 -0
- SFI/inference/parametric_core/flow_multi.py +362 -0
- SFI/inference/parametric_core/flow_ud.py +168 -0
- SFI/inference/parametric_core/jacobians.py +540 -0
- SFI/inference/parametric_core/objective.py +286 -0
- SFI/inference/parametric_core/objective_ud.py +253 -0
- SFI/inference/parametric_core/precision.py +229 -0
- SFI/inference/parametric_core/solve.py +763 -0
- SFI/inference/result.py +362 -0
- SFI/inference/serialization.py +245 -0
- SFI/inference/sparse/__init__.py +67 -0
- SFI/inference/sparse/base.py +43 -0
- SFI/inference/sparse/beam.py +303 -0
- SFI/inference/sparse/greedy.py +151 -0
- SFI/inference/sparse/hillclimb.py +307 -0
- SFI/inference/sparse/lasso.py +178 -0
- SFI/inference/sparse/metrics.py +78 -0
- SFI/inference/sparse/result.py +278 -0
- SFI/inference/sparse/scorer.py +323 -0
- SFI/inference/sparse/stlsq.py +165 -0
- SFI/inference/sparsity.py +40 -0
- SFI/inference/underdamped.py +1355 -0
- SFI/integrate/__init__.py +38 -0
- SFI/integrate/api.py +920 -0
- SFI/integrate/integrand.py +402 -0
- SFI/integrate/rk4.py +156 -0
- SFI/integrate/timeops.py +174 -0
- SFI/langevin/__init__.py +29 -0
- SFI/langevin/base.py +863 -0
- SFI/langevin/chunked.py +225 -0
- SFI/langevin/noise.py +446 -0
- SFI/langevin/overdamped.py +560 -0
- SFI/langevin/underdamped.py +448 -0
- SFI/statefunc/__init__.py +49 -0
- SFI/statefunc/basis.py +87 -0
- SFI/statefunc/core/runtime.py +47 -0
- SFI/statefunc/factory.py +346 -0
- SFI/statefunc/interactor.py +90 -0
- SFI/statefunc/layout/__init__.py +29 -0
- SFI/statefunc/layout/_base.py +186 -0
- SFI/statefunc/layout/_eval_compiler.py +453 -0
- SFI/statefunc/layout/_fd_atoms.py +196 -0
- SFI/statefunc/layout/_grid.py +878 -0
- SFI/statefunc/layout/_sectors.py +166 -0
- SFI/statefunc/memhint.py +222 -0
- SFI/statefunc/nodes/__init__.py +56 -0
- SFI/statefunc/nodes/base.py +318 -0
- SFI/statefunc/nodes/contract.py +422 -0
- SFI/statefunc/nodes/interactions/__init__.py +23 -0
- SFI/statefunc/nodes/interactions/dispatcher.py +1365 -0
- SFI/statefunc/nodes/interactions/prepare.py +161 -0
- SFI/statefunc/nodes/interactions/specs.py +362 -0
- SFI/statefunc/nodes/interactions/stencils.py +718 -0
- SFI/statefunc/nodes/leaf.py +530 -0
- SFI/statefunc/nodes/ops/__init__.py +27 -0
- SFI/statefunc/nodes/ops/concat.py +28 -0
- SFI/statefunc/nodes/ops/derivative.py +447 -0
- SFI/statefunc/nodes/ops/einsum.py +120 -0
- SFI/statefunc/nodes/ops/linear.py +153 -0
- SFI/statefunc/nodes/ops/mapn.py +138 -0
- SFI/statefunc/nodes/ops/reshape_rank.py +158 -0
- SFI/statefunc/nodes/ops/slice.py +83 -0
- SFI/statefunc/params.py +309 -0
- SFI/statefunc/psf.py +112 -0
- SFI/statefunc/sf.py +104 -0
- SFI/statefunc/stateexpr.py +1243 -0
- SFI/statefunc/structexpr.py +1003 -0
- SFI/trajectory/__init__.py +31 -0
- SFI/trajectory/collection.py +1122 -0
- SFI/trajectory/dataset.py +1164 -0
- SFI/trajectory/degrade.py +1108 -0
- SFI/trajectory/io.py +1027 -0
- SFI/trajectory/reserved_extras.py +129 -0
- SFI/utils/__init__.py +17 -0
- SFI/utils/formatting.py +308 -0
- SFI/utils/maths.py +185 -0
- SFI/utils/neighbors.py +162 -0
- SFI/utils/plotting.py +1936 -0
- stochasticforceinference-2.0.0.dist-info/METADATA +203 -0
- stochasticforceinference-2.0.0.dist-info/RECORD +104 -0
- stochasticforceinference-2.0.0.dist-info/WHEEL +5 -0
- stochasticforceinference-2.0.0.dist-info/licenses/LICENSE +21 -0
- stochasticforceinference-2.0.0.dist-info/top_level.txt +1 -0
SFI/utils/plotting.py
ADDED
|
@@ -0,0 +1,1936 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
import jax.numpy as jnp
|
|
4
|
+
import matplotlib.pyplot as plt
|
|
5
|
+
import numpy as np
|
|
6
|
+
from matplotlib.collections import LineCollection
|
|
7
|
+
from matplotlib.colors import Normalize
|
|
8
|
+
from scipy.stats import pearsonr
|
|
9
|
+
|
|
10
|
+
from SFI.trajectory.collection import TrajectoryCollection
|
|
11
|
+
from SFI.trajectory.dataset import TrajectoryDataset
|
|
12
|
+
|
|
13
|
+
""" Utility classes for plotting the results of SFI, used only to
|
|
14
|
+
display the results of the example files.
|
|
15
|
+
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Gallery colour palette
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
#: Named colour palette for consistent plotting across gallery examples
|
|
23
|
+
#: and user notebooks. Adjusted for readability on both dark (#131416)
|
|
24
|
+
#: and light backgrounds.
|
|
25
|
+
SFI_COLORS = dict(
|
|
26
|
+
data="#3B9EFF", # bright blue
|
|
27
|
+
inferred="#FFC20A", # gold
|
|
28
|
+
exact="#FF7A1A", # bright orange
|
|
29
|
+
bootstrap="#5D3A9B", # purple
|
|
30
|
+
highlight="#40B0A6", # teal
|
|
31
|
+
error="#FF2D6F", # bright pink
|
|
32
|
+
secondary="#1A85FF", # lighter blue
|
|
33
|
+
tertiary="#D35FB7", # pink/magenta
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Figure timestamp
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def stamp_output():
|
|
43
|
+
"""Print a discreet generation timestamp to stdout.
|
|
44
|
+
|
|
45
|
+
Call once per script so the timestamp appears in terminal output
|
|
46
|
+
blocks on gallery pages and in notebook cells.
|
|
47
|
+
"""
|
|
48
|
+
print(f"[Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}]")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def stamp_fig(fig=None):
|
|
52
|
+
"""Add a discreet generation timestamp to the bottom-right of a figure.
|
|
53
|
+
|
|
54
|
+
Useful for tracking when a cached or gallery figure was last rendered.
|
|
55
|
+
Idempotent: a figure is only stamped once.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
fig : matplotlib.figure.Figure, optional
|
|
60
|
+
Figure to stamp. Defaults to ``plt.gcf()``.
|
|
61
|
+
"""
|
|
62
|
+
if fig is None:
|
|
63
|
+
fig = plt.gcf()
|
|
64
|
+
if getattr(fig, "_sfi_stamped", False):
|
|
65
|
+
return
|
|
66
|
+
fig._sfi_stamped = True
|
|
67
|
+
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
68
|
+
fig.text(
|
|
69
|
+
0.99,
|
|
70
|
+
0.005,
|
|
71
|
+
now,
|
|
72
|
+
fontsize=5,
|
|
73
|
+
color="#808080",
|
|
74
|
+
alpha=0.4,
|
|
75
|
+
ha="right",
|
|
76
|
+
va="bottom",
|
|
77
|
+
transform=fig.transFigure,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Dark-theme figure helpers (reusable across gallery demos & notebooks)
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def dark_ax(ax):
|
|
87
|
+
"""Style a matplotlib Axes for a dark background.
|
|
88
|
+
|
|
89
|
+
Sets black face colour, white ticks and labels, and dark-grey spines.
|
|
90
|
+
"""
|
|
91
|
+
ax.set_facecolor("black")
|
|
92
|
+
ax.tick_params(colors="white", which="both")
|
|
93
|
+
for spine in ax.spines.values():
|
|
94
|
+
spine.set_edgecolor("0.3")
|
|
95
|
+
ax.xaxis.label.set_color("white")
|
|
96
|
+
ax.yaxis.label.set_color("white")
|
|
97
|
+
ax.title.set_color("white")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def dark_fig(nrows=1, ncols=1, **kw):
|
|
101
|
+
"""Create a figure and axes with a black background.
|
|
102
|
+
|
|
103
|
+
All arguments are forwarded to ``plt.subplots``.
|
|
104
|
+
Returns ``(fig, axes)`` like ``plt.subplots``.
|
|
105
|
+
"""
|
|
106
|
+
fig, axes = plt.subplots(nrows, ncols, **kw)
|
|
107
|
+
fig.patch.set_facecolor("black")
|
|
108
|
+
for ax in np.atleast_1d(axes).flat if hasattr(axes, "flat") else [axes]:
|
|
109
|
+
dark_ax(ax)
|
|
110
|
+
return fig, axes
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def wrap_positions(X, box):
|
|
114
|
+
"""Wrap positions into a periodic box ``[0, box_i)`` per axis.
|
|
115
|
+
|
|
116
|
+
Parameters
|
|
117
|
+
----------
|
|
118
|
+
X : array_like, shape (..., d)
|
|
119
|
+
Position array.
|
|
120
|
+
box : array_like, shape (d,) or (2,)
|
|
121
|
+
Box dimensions.
|
|
122
|
+
|
|
123
|
+
Returns
|
|
124
|
+
-------
|
|
125
|
+
X_wrapped : ndarray
|
|
126
|
+
Copy of *X* with each column wrapped modulo the corresponding
|
|
127
|
+
box dimension.
|
|
128
|
+
"""
|
|
129
|
+
X = np.array(X, copy=True, dtype=float)
|
|
130
|
+
box = np.asarray(box)
|
|
131
|
+
for i in range(len(box)):
|
|
132
|
+
X[..., i] = X[..., i] % box[i]
|
|
133
|
+
return X
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _equal_aspect(ax):
|
|
137
|
+
"""Set an equal aspect ratio, choosing an adjustable that is legal here.
|
|
138
|
+
|
|
139
|
+
``adjustable="datalim"`` (and ``axis("equal")``) raise at draw time when
|
|
140
|
+
the axes shares its x or y with a sibling (e.g. ``sharex=True`` subplots),
|
|
141
|
+
so fall back to ``adjustable="box"`` in that case.
|
|
142
|
+
"""
|
|
143
|
+
shared = (
|
|
144
|
+
len(ax.get_shared_x_axes().get_siblings(ax)) > 1
|
|
145
|
+
or len(ax.get_shared_y_axes().get_siblings(ax)) > 1
|
|
146
|
+
)
|
|
147
|
+
ax.set_aspect("equal", adjustable="box" if shared else "datalim")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# Sparse-model diagnostics
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def plot_pareto_front(result, *, criteria=("PASTIS", "BIC", "AIC"), ax=None):
|
|
156
|
+
"""Plot the Pareto front: information gain vs model size, with IC optima.
|
|
157
|
+
|
|
158
|
+
Parameters
|
|
159
|
+
----------
|
|
160
|
+
result : SparsityResult
|
|
161
|
+
As returned by ``inf.sparsify_force()``.
|
|
162
|
+
criteria : tuple of str
|
|
163
|
+
Information-criterion names to mark on the plot.
|
|
164
|
+
ax : matplotlib Axes, optional
|
|
165
|
+
If *None*, a new figure is created.
|
|
166
|
+
|
|
167
|
+
Returns
|
|
168
|
+
-------
|
|
169
|
+
ax : matplotlib Axes
|
|
170
|
+
"""
|
|
171
|
+
if ax is None:
|
|
172
|
+
_, ax = plt.subplots()
|
|
173
|
+
|
|
174
|
+
infos = result.best_info_by_k
|
|
175
|
+
ks = list(range(len(infos)))
|
|
176
|
+
valid = [(k, float(info)) for k, info in zip(ks, infos) if float(info) > -1e30]
|
|
177
|
+
if valid:
|
|
178
|
+
ks_v, infos_v = zip(*valid)
|
|
179
|
+
else:
|
|
180
|
+
ks_v, infos_v = [], []
|
|
181
|
+
|
|
182
|
+
ax.plot(ks_v, infos_v, ".-", color="#B0B0B0", lw=1.5, label="Pareto front")
|
|
183
|
+
|
|
184
|
+
colors = [SFI_COLORS["inferred"], SFI_COLORS["exact"], SFI_COLORS["highlight"]]
|
|
185
|
+
for ic_name, c in zip(criteria, colors):
|
|
186
|
+
try:
|
|
187
|
+
k_sel, _, score, _ = result.select_by_ic(ic_name, p_param=1e-3)
|
|
188
|
+
info_at_k = float(infos[k_sel]) if k_sel < len(infos) else None
|
|
189
|
+
if info_at_k is not None and info_at_k > -1e30:
|
|
190
|
+
ax.axvline(k_sel, color=c, ls="--", alpha=0.7, label=f"{ic_name} (k={k_sel})")
|
|
191
|
+
ax.plot(k_sel, info_at_k, "o", color=c, ms=8, zorder=5)
|
|
192
|
+
except Exception: # ic_name absent or result schema mismatch
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
ax.set_xlabel("Model size k")
|
|
196
|
+
ax.set_ylabel("Information gain")
|
|
197
|
+
ax.legend(fontsize=8)
|
|
198
|
+
ax.set_title("Sparse model selection")
|
|
199
|
+
return ax
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def plot_recovery_bar(
|
|
203
|
+
coeffs_inferred,
|
|
204
|
+
support_inferred,
|
|
205
|
+
*,
|
|
206
|
+
coeffs_true=None,
|
|
207
|
+
support_true=None,
|
|
208
|
+
labels=None,
|
|
209
|
+
stderr=None,
|
|
210
|
+
yscale: str = "linear",
|
|
211
|
+
sort: bool = False,
|
|
212
|
+
show_pruned: bool = False,
|
|
213
|
+
ax=None,
|
|
214
|
+
):
|
|
215
|
+
"""Bar chart of inferred sparse coefficients vs ground truth.
|
|
216
|
+
|
|
217
|
+
Parameters
|
|
218
|
+
----------
|
|
219
|
+
coeffs_inferred : array_like
|
|
220
|
+
Coefficient values for the inferred support.
|
|
221
|
+
support_inferred : array_like
|
|
222
|
+
Indices of the selected basis functions.
|
|
223
|
+
coeffs_true, support_true : array_like, optional
|
|
224
|
+
Ground-truth coefficients / support (paired comparison).
|
|
225
|
+
labels : list of str, optional
|
|
226
|
+
Tick labels for basis functions.
|
|
227
|
+
stderr : array_like, optional
|
|
228
|
+
Standard errors for the inferred coefficients (drawn as error caps).
|
|
229
|
+
yscale : str
|
|
230
|
+
Matplotlib y-scale (``"linear"`` or ``"log"``; use ``"log"`` for
|
|
231
|
+
magnitude bars with widely varying scales).
|
|
232
|
+
sort : bool
|
|
233
|
+
If True, order bars by descending ``|coefficient|``.
|
|
234
|
+
show_pruned : bool
|
|
235
|
+
If True (and ``labels`` given), append faded zero-bars for basis
|
|
236
|
+
functions outside the inferred support.
|
|
237
|
+
ax : matplotlib Axes, optional
|
|
238
|
+
If *None*, a new figure is created.
|
|
239
|
+
|
|
240
|
+
Returns
|
|
241
|
+
-------
|
|
242
|
+
ax : matplotlib Axes
|
|
243
|
+
"""
|
|
244
|
+
if ax is None:
|
|
245
|
+
_, ax = plt.subplots()
|
|
246
|
+
|
|
247
|
+
coeffs_inferred = np.asarray(coeffs_inferred, dtype=float)
|
|
248
|
+
support_inferred = list(support_inferred)
|
|
249
|
+
stderr = np.asarray(stderr, dtype=float) if stderr is not None else None
|
|
250
|
+
|
|
251
|
+
if sort:
|
|
252
|
+
order = np.argsort(-np.abs(coeffs_inferred))
|
|
253
|
+
coeffs_inferred = coeffs_inferred[order]
|
|
254
|
+
support_inferred = [support_inferred[k] for k in order]
|
|
255
|
+
if stderr is not None:
|
|
256
|
+
stderr = stderr[order]
|
|
257
|
+
|
|
258
|
+
n = len(support_inferred)
|
|
259
|
+
x = np.arange(n)
|
|
260
|
+
bar_width = min(0.6, 6.0 / n) if n >= 5 else 0.8
|
|
261
|
+
paired = coeffs_true is not None and support_true is not None
|
|
262
|
+
offset = bar_width / 2 if paired else 0.0
|
|
263
|
+
|
|
264
|
+
ax.bar(
|
|
265
|
+
x - offset,
|
|
266
|
+
coeffs_inferred,
|
|
267
|
+
width=bar_width,
|
|
268
|
+
color=SFI_COLORS["inferred"],
|
|
269
|
+
alpha=0.8,
|
|
270
|
+
yerr=stderr,
|
|
271
|
+
capsize=4 if stderr is not None else 0,
|
|
272
|
+
ecolor="#B0B0B0",
|
|
273
|
+
label="inferred",
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
if paired:
|
|
277
|
+
support_true_l = list(support_true)
|
|
278
|
+
true_mapped = np.zeros(n)
|
|
279
|
+
for i, s in enumerate(support_inferred):
|
|
280
|
+
if s in support_true_l:
|
|
281
|
+
true_mapped[i] = float(np.asarray(coeffs_true)[support_true_l.index(s)])
|
|
282
|
+
ax.bar(
|
|
283
|
+
x + offset,
|
|
284
|
+
true_mapped,
|
|
285
|
+
width=bar_width,
|
|
286
|
+
fill=False,
|
|
287
|
+
edgecolor=SFI_COLORS["exact"],
|
|
288
|
+
lw=2,
|
|
289
|
+
label="exact",
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
extra_ticks, extra_labels = [], []
|
|
293
|
+
if show_pruned and labels is not None:
|
|
294
|
+
pruned = [k for k in range(len(labels)) if k not in set(support_inferred)]
|
|
295
|
+
for off, s in enumerate(pruned):
|
|
296
|
+
xp = n + off
|
|
297
|
+
ax.bar(xp, 0.0, width=bar_width, color="#808080", alpha=0.3)
|
|
298
|
+
extra_ticks.append(xp)
|
|
299
|
+
extra_labels.append(labels[s])
|
|
300
|
+
|
|
301
|
+
if labels is not None:
|
|
302
|
+
tick_labels = [labels[s] if s < len(labels) else str(s) for s in support_inferred] + extra_labels
|
|
303
|
+
ax.set_xticks(list(x) + extra_ticks)
|
|
304
|
+
ax.set_xticklabels(tick_labels, rotation=45, ha="right", fontsize=8 if n < 8 else 7)
|
|
305
|
+
else:
|
|
306
|
+
ax.set_xticks(x)
|
|
307
|
+
ax.set_xticklabels([str(s) for s in support_inferred])
|
|
308
|
+
|
|
309
|
+
if n < 5 and not extra_ticks:
|
|
310
|
+
ax.set_xlim(-0.8, n - 0.2)
|
|
311
|
+
|
|
312
|
+
ax.set_yscale(yscale)
|
|
313
|
+
ax.set_ylabel("Coefficient value")
|
|
314
|
+
ax.legend()
|
|
315
|
+
ax.set_title("Sparse model coefficients")
|
|
316
|
+
return ax
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def plot_recovery_bar_multi(coeffs_list, labels, *, coeffs_true=None, group_names=None, ax=None):
|
|
320
|
+
"""Grouped bar chart comparing coefficients across several models.
|
|
321
|
+
|
|
322
|
+
``coeffs_list`` is a list of coefficient vectors (one per regime /
|
|
323
|
+
solver), each aligned to ``labels``. An optional ``coeffs_true`` is
|
|
324
|
+
overlaid as a step reference.
|
|
325
|
+
"""
|
|
326
|
+
if ax is None:
|
|
327
|
+
_, ax = plt.subplots()
|
|
328
|
+
coeffs_list = [np.asarray(c, dtype=float) for c in coeffs_list]
|
|
329
|
+
G = len(coeffs_list)
|
|
330
|
+
m = len(labels)
|
|
331
|
+
x = np.arange(m)
|
|
332
|
+
group_names = group_names if group_names is not None else [f"model {i + 1}" for i in range(G)]
|
|
333
|
+
width = 0.8 / max(G, 1)
|
|
334
|
+
palette = list(SFI_COLORS.values())
|
|
335
|
+
for gi, (c, name) in enumerate(zip(coeffs_list, group_names)):
|
|
336
|
+
ax.bar(x + (gi - (G - 1) / 2) * width, c, width=width, color=palette[gi % len(palette)], alpha=0.85, label=name)
|
|
337
|
+
if coeffs_true is not None:
|
|
338
|
+
ct = np.asarray(coeffs_true, dtype=float)
|
|
339
|
+
ax.step(np.concatenate([x - 0.5, [x[-1] + 0.5]]), np.concatenate([ct, ct[-1:]]),
|
|
340
|
+
where="post", color=SFI_COLORS["exact"], lw=1.5, label="exact")
|
|
341
|
+
ax.set_xticks(x)
|
|
342
|
+
ax.set_xticklabels(list(labels), rotation=45, ha="right", fontsize=8 if m < 8 else 7)
|
|
343
|
+
ax.set_ylabel("Coefficient value")
|
|
344
|
+
ax.axhline(0, color="#808080", lw=0.5)
|
|
345
|
+
ax.legend(fontsize=8)
|
|
346
|
+
return ax
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def plot_recovery_matrix(true, inferred, *, row_labels=None, col_labels=None, cmap="RdBu_r", vmax=None, axes=None):
|
|
350
|
+
"""Side-by-side ``imshow`` of a true vs inferred parameter matrix."""
|
|
351
|
+
true = np.asarray(true, dtype=float)
|
|
352
|
+
inferred = np.asarray(inferred, dtype=float)
|
|
353
|
+
if axes is None:
|
|
354
|
+
_, axes = plt.subplots(1, 2, figsize=(8, 4))
|
|
355
|
+
if vmax is None:
|
|
356
|
+
vmax = float(max(np.abs(true).max(), np.abs(inferred).max()))
|
|
357
|
+
im = None
|
|
358
|
+
for ax, mat, title in zip(axes, [true, inferred], ["True", "Inferred"]):
|
|
359
|
+
im = ax.imshow(mat, cmap=cmap, vmin=-vmax, vmax=vmax, aspect="auto")
|
|
360
|
+
ax.set_title(title)
|
|
361
|
+
if col_labels is not None:
|
|
362
|
+
ax.set_xticks(range(mat.shape[1]))
|
|
363
|
+
ax.set_xticklabels(col_labels, rotation=45, ha="right", fontsize=8)
|
|
364
|
+
if row_labels is not None:
|
|
365
|
+
ax.set_yticks(range(mat.shape[0]))
|
|
366
|
+
ax.set_yticklabels(row_labels, fontsize=8)
|
|
367
|
+
if im is not None:
|
|
368
|
+
plt.colorbar(im, ax=list(axes), shrink=0.8)
|
|
369
|
+
return axes
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _collection_to_arrays(
|
|
373
|
+
coll: TrajectoryCollection,
|
|
374
|
+
*,
|
|
375
|
+
dataset: int = 0,
|
|
376
|
+
):
|
|
377
|
+
"""
|
|
378
|
+
Extract (t, X, mask) from a TrajectoryCollection as NumPy arrays.
|
|
379
|
+
|
|
380
|
+
Parameters
|
|
381
|
+
----------
|
|
382
|
+
coll :
|
|
383
|
+
TrajectoryCollection object.
|
|
384
|
+
dataset :
|
|
385
|
+
Dataset index inside the collection.
|
|
386
|
+
|
|
387
|
+
Returns
|
|
388
|
+
-------
|
|
389
|
+
t : ndarray, shape (T,)
|
|
390
|
+
Absolute times.
|
|
391
|
+
X : ndarray, shape (T, N, d)
|
|
392
|
+
State array.
|
|
393
|
+
mask : ndarray, shape (T, N)
|
|
394
|
+
Boolean validity mask.
|
|
395
|
+
"""
|
|
396
|
+
if not isinstance(coll, TrajectoryCollection):
|
|
397
|
+
raise TypeError(f"Expected TrajectoryCollection, got {type(coll)!r}")
|
|
398
|
+
if not coll.datasets:
|
|
399
|
+
raise ValueError("Empty TrajectoryCollection")
|
|
400
|
+
|
|
401
|
+
if not (0 <= dataset < len(coll.datasets)):
|
|
402
|
+
raise IndexError(f"dataset index {dataset} out of range for D={len(coll.datasets)}")
|
|
403
|
+
|
|
404
|
+
ds: TrajectoryDataset = coll.datasets[dataset]
|
|
405
|
+
|
|
406
|
+
# Positions, always as (T, N, d)
|
|
407
|
+
X = np.asarray(ds._X3d())
|
|
408
|
+
|
|
409
|
+
# Mask
|
|
410
|
+
try:
|
|
411
|
+
M = np.asarray(ds._M2d())
|
|
412
|
+
except Exception:
|
|
413
|
+
M = np.ones(X.shape[:2], dtype=bool)
|
|
414
|
+
|
|
415
|
+
T = X.shape[0]
|
|
416
|
+
|
|
417
|
+
# Time axis
|
|
418
|
+
if ds.t is not None:
|
|
419
|
+
t = np.asarray(ds.t)
|
|
420
|
+
else:
|
|
421
|
+
dt = ds.dt
|
|
422
|
+
if dt is None:
|
|
423
|
+
t = np.arange(T, dtype=float)
|
|
424
|
+
else:
|
|
425
|
+
dt_arr = np.asarray(dt)
|
|
426
|
+
if dt_arr.ndim == 0:
|
|
427
|
+
t = np.arange(T, dtype=float) * float(dt_arr)
|
|
428
|
+
elif dt_arr.ndim == 1:
|
|
429
|
+
if T == 0:
|
|
430
|
+
t = np.zeros((0,), dtype=float)
|
|
431
|
+
else:
|
|
432
|
+
# t[0] = 0; t[1:] = cumsum(dt[:-1])
|
|
433
|
+
t = np.concatenate([[0.0], np.cumsum(dt_arr[:-1])])
|
|
434
|
+
else:
|
|
435
|
+
raise ValueError("dt must be scalar or (T,) to build a time axis.")
|
|
436
|
+
|
|
437
|
+
return t, X, M
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def axisvector(index, dim):
|
|
441
|
+
"""d-dimensional unit vector pointing in direction `index`."""
|
|
442
|
+
e = np.zeros(dim, dtype=float)
|
|
443
|
+
e[index] = 1.0
|
|
444
|
+
return e
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def comparison_scatter(
|
|
448
|
+
Xexact,
|
|
449
|
+
Xinferred,
|
|
450
|
+
error=None,
|
|
451
|
+
maxpoints=10000,
|
|
452
|
+
vmax=None,
|
|
453
|
+
color=None,
|
|
454
|
+
alpha=0.05,
|
|
455
|
+
y=0.8,
|
|
456
|
+
mode="both",
|
|
457
|
+
fontsize=9,
|
|
458
|
+
):
|
|
459
|
+
"""This method is used to compare inferred components to the
|
|
460
|
+
exact ones along the trajectory, in a graphical way.
|
|
461
|
+
|
|
462
|
+
Xexact, Xinferred: jnp arrays (Nsteps,...); must have the same shape.
|
|
463
|
+
|
|
464
|
+
error: predicted standard deviation for X_inferred.
|
|
465
|
+
|
|
466
|
+
maxpoints: if Nsteps / 2 * maxpoints, data will be subsampled.
|
|
467
|
+
|
|
468
|
+
"""
|
|
469
|
+
|
|
470
|
+
subsample = max(1, Xexact.shape[0] // maxpoints)
|
|
471
|
+
# Flatten the data:
|
|
472
|
+
Xe = np.array(Xexact)[::subsample].reshape(-1)
|
|
473
|
+
Xi = np.array(Xinferred)[::subsample].reshape(-1)
|
|
474
|
+
|
|
475
|
+
MSE = sum((Xe - Xi) ** 2) / sum(Xe**2 + Xi**2)
|
|
476
|
+
|
|
477
|
+
if vmax is None:
|
|
478
|
+
vmax = max(abs(Xe).max(), abs(Xi).max())
|
|
479
|
+
plt.scatter(Xe, Xi, alpha=alpha, linewidth=0, c=color)
|
|
480
|
+
|
|
481
|
+
if error is not None:
|
|
482
|
+
xvals = np.array([-vmax, vmax])
|
|
483
|
+
confidence_interval = 2 * error**0.5 * Xi.std()
|
|
484
|
+
plt.plot(xvals, xvals + confidence_interval, ls=":", color="#808080")
|
|
485
|
+
plt.plot(xvals, xvals - confidence_interval, ls=":", color="#808080")
|
|
486
|
+
(r, p) = pearsonr(Xe, Xi)
|
|
487
|
+
plt.plot([-1e10, 1e10], [-1e10, 1e10], ls="-", color="#808080")
|
|
488
|
+
plt.grid(True)
|
|
489
|
+
_equal_aspect(plt.gca())
|
|
490
|
+
plt.xlabel("exact")
|
|
491
|
+
plt.ylabel("inferred")
|
|
492
|
+
|
|
493
|
+
titlestring = ""
|
|
494
|
+
if mode == "r" or mode == "both":
|
|
495
|
+
titlestring += r"$r=" + str(round(r, 2 if r < 0.98 else 3 if r < 0.999 else 4 if r < 0.9999 else 5)) + "$"
|
|
496
|
+
if mode == "both":
|
|
497
|
+
titlestring += "\n"
|
|
498
|
+
if mode == "MSE" or mode == "both":
|
|
499
|
+
titlestring += "MSE=" + str(round(MSE, 3))
|
|
500
|
+
plt.title(titlestring, loc="left", y=y, x=0.05, fontsize=fontsize)
|
|
501
|
+
plt.xticks([0.0])
|
|
502
|
+
plt.yticks([0.0])
|
|
503
|
+
plt.xlim(-vmax, vmax)
|
|
504
|
+
plt.ylim(-vmax, vmax)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def timeseries(
|
|
508
|
+
coll: TrajectoryCollection,
|
|
509
|
+
*,
|
|
510
|
+
dims=None,
|
|
511
|
+
dataset: int = 0,
|
|
512
|
+
particles=None,
|
|
513
|
+
transform=None,
|
|
514
|
+
ax=None,
|
|
515
|
+
**plot_kw,
|
|
516
|
+
):
|
|
517
|
+
"""
|
|
518
|
+
Plot x[dim](t) for one or many particles from a TrajectoryCollection.
|
|
519
|
+
|
|
520
|
+
By default, plot all dimensions for the selected particles.
|
|
521
|
+
|
|
522
|
+
Parameters
|
|
523
|
+
----------
|
|
524
|
+
coll :
|
|
525
|
+
TrajectoryCollection containing the data.
|
|
526
|
+
dims :
|
|
527
|
+
Iterable of state dimensions to plot. If None, plot all dims.
|
|
528
|
+
dataset :
|
|
529
|
+
Dataset index inside the collection (default 0).
|
|
530
|
+
particles :
|
|
531
|
+
Iterable of particle indices to include. If None, include all particles.
|
|
532
|
+
transform :
|
|
533
|
+
Optional callable applied elementwise to the plotted values (e.g.
|
|
534
|
+
``np.exp`` to map a log-space coordinate back to population space).
|
|
535
|
+
ax :
|
|
536
|
+
Optional Matplotlib Axes. If None, use ``plt.gca()``.
|
|
537
|
+
**plot_kw :
|
|
538
|
+
Forwarded to ``ax.plot``.
|
|
539
|
+
|
|
540
|
+
Returns
|
|
541
|
+
-------
|
|
542
|
+
ax :
|
|
543
|
+
Matplotlib Axes used.
|
|
544
|
+
"""
|
|
545
|
+
if ax is None:
|
|
546
|
+
ax = plt.gca()
|
|
547
|
+
|
|
548
|
+
t, X, M = _collection_to_arrays(coll, dataset=dataset) # X: (T, N, D)
|
|
549
|
+
T, N, D = X.shape
|
|
550
|
+
|
|
551
|
+
# Select particles
|
|
552
|
+
if particles is None:
|
|
553
|
+
Xp = X
|
|
554
|
+
Mp = M
|
|
555
|
+
Nsel = N
|
|
556
|
+
else:
|
|
557
|
+
idx = np.asarray(particles, dtype=int)
|
|
558
|
+
Xp = X[:, idx, :]
|
|
559
|
+
Mp = M[:, idx]
|
|
560
|
+
Nsel = Xp.shape[1]
|
|
561
|
+
|
|
562
|
+
# Select dims
|
|
563
|
+
if dims is None:
|
|
564
|
+
dims = range(D)
|
|
565
|
+
dims = list(dims)
|
|
566
|
+
|
|
567
|
+
# Mask-aware plotting: one line per particle per dimension
|
|
568
|
+
for n in range(Nsel):
|
|
569
|
+
mask_n = Mp[:, n].astype(bool)
|
|
570
|
+
for d in dims:
|
|
571
|
+
vals = Xp[mask_n, n, d]
|
|
572
|
+
if transform is not None:
|
|
573
|
+
vals = transform(vals)
|
|
574
|
+
ax.plot(t[mask_n], vals, **plot_kw)
|
|
575
|
+
|
|
576
|
+
ax.set_xlabel("t")
|
|
577
|
+
if len(dims) == 1:
|
|
578
|
+
ax.set_ylabel(f"x[{dims[0]}]")
|
|
579
|
+
else:
|
|
580
|
+
ax.set_ylabel("x[d]")
|
|
581
|
+
return ax
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def phase2d(
|
|
585
|
+
coll: TrajectoryCollection,
|
|
586
|
+
*,
|
|
587
|
+
dataset: int = 0,
|
|
588
|
+
dims=None,
|
|
589
|
+
dir1=None,
|
|
590
|
+
dir2=None,
|
|
591
|
+
shift=(0.0, 0.0),
|
|
592
|
+
tmin=None,
|
|
593
|
+
tmax=None,
|
|
594
|
+
particles=None,
|
|
595
|
+
cmap="viridis",
|
|
596
|
+
linewidth: float = 1.5,
|
|
597
|
+
alpha: float = 1.0,
|
|
598
|
+
plot_colorbar: bool = False,
|
|
599
|
+
box=None,
|
|
600
|
+
transform=None,
|
|
601
|
+
color=None,
|
|
602
|
+
ax=None,
|
|
603
|
+
drop_masked: bool = True,
|
|
604
|
+
) -> LineCollection:
|
|
605
|
+
"""
|
|
606
|
+
2D phase-space plot with connected line segments colored along the trajectory.
|
|
607
|
+
|
|
608
|
+
Parameters
|
|
609
|
+
----------
|
|
610
|
+
coll :
|
|
611
|
+
TrajectoryCollection containing the data.
|
|
612
|
+
dataset :
|
|
613
|
+
Dataset index inside the collection.
|
|
614
|
+
dims :
|
|
615
|
+
Pair of coordinate indices (i, j) to plot. Ignored if ``dir1``/``dir2``
|
|
616
|
+
are provided. If None and no directions are given, defaults to (0, 1).
|
|
617
|
+
dir1, dir2 :
|
|
618
|
+
Optional projection directions in R^d. If given, positions are projected
|
|
619
|
+
onto these directions instead of using coordinate axes.
|
|
620
|
+
shift :
|
|
621
|
+
(xshift, yshift) added to all positions.
|
|
622
|
+
tmin, tmax :
|
|
623
|
+
Integer time-index bounds. If None, full range is used.
|
|
624
|
+
Negative ``tmax`` is interpreted as from the end.
|
|
625
|
+
particles :
|
|
626
|
+
Sequence of particle indices to include. If None, include all.
|
|
627
|
+
cmap :
|
|
628
|
+
Colormap for the time-coloring.
|
|
629
|
+
linewidth :
|
|
630
|
+
Line width of the trajectory segments.
|
|
631
|
+
alpha :
|
|
632
|
+
Global alpha for the line collection.
|
|
633
|
+
plot_colorbar :
|
|
634
|
+
If True, add a colorbar for the time coloring.
|
|
635
|
+
box :
|
|
636
|
+
Optional periodic box ``(Lx, Ly)``. If given, positions are wrapped
|
|
637
|
+
modulo the box and segments that cross a boundary are dropped, so
|
|
638
|
+
trajectories render correctly in a periodic domain.
|
|
639
|
+
transform :
|
|
640
|
+
Optional callable applied elementwise to the projected ``x`` and
|
|
641
|
+
``y`` (e.g. ``np.exp`` to map a log-space coordinate back to
|
|
642
|
+
population space). Applied after projection/shift/box-wrap.
|
|
643
|
+
color :
|
|
644
|
+
Optional single color. If given, draw the trajectory in this solid
|
|
645
|
+
color instead of the time-colored gradient (incompatible with
|
|
646
|
+
``plot_colorbar``).
|
|
647
|
+
ax :
|
|
648
|
+
Optional Matplotlib Axes. If None, use ``plt.gca()``.
|
|
649
|
+
drop_masked :
|
|
650
|
+
If True, drop segments where either endpoint is masked.
|
|
651
|
+
|
|
652
|
+
Returns
|
|
653
|
+
-------
|
|
654
|
+
lc :
|
|
655
|
+
The created :class:`matplotlib.collections.LineCollection`.
|
|
656
|
+
"""
|
|
657
|
+
if ax is None:
|
|
658
|
+
ax = plt.gca()
|
|
659
|
+
|
|
660
|
+
t, X, M = _collection_to_arrays(coll, dataset=dataset) # X: (T, N, d)
|
|
661
|
+
T, N, d = X.shape
|
|
662
|
+
|
|
663
|
+
# Time window
|
|
664
|
+
if tmin is None:
|
|
665
|
+
tmin = 0
|
|
666
|
+
if tmax is None:
|
|
667
|
+
tmax = T
|
|
668
|
+
if tmax < 0:
|
|
669
|
+
tmax = T + tmax
|
|
670
|
+
tmin = max(0, int(tmin))
|
|
671
|
+
tmax = min(T, int(tmax))
|
|
672
|
+
if tmax <= tmin:
|
|
673
|
+
raise ValueError("Empty time window in phase2d")
|
|
674
|
+
|
|
675
|
+
X = X[tmin:tmax] # (T', N, d)
|
|
676
|
+
M = M[tmin:tmax] # (T', N)
|
|
677
|
+
t_slice = t[tmin:tmax] # (T',)
|
|
678
|
+
|
|
679
|
+
# Particle selection
|
|
680
|
+
if particles is None:
|
|
681
|
+
X_sel = X
|
|
682
|
+
M_sel = M
|
|
683
|
+
else:
|
|
684
|
+
idx = np.asarray(particles, dtype=int)
|
|
685
|
+
X_sel = X[:, idx, :]
|
|
686
|
+
M_sel = M[:, idx]
|
|
687
|
+
|
|
688
|
+
# Projection
|
|
689
|
+
if dir1 is not None or dir2 is not None:
|
|
690
|
+
if dir1 is None or dir2 is None:
|
|
691
|
+
raise ValueError("Either provide both dir1 and dir2, or neither.")
|
|
692
|
+
dir1 = np.asarray(dir1, dtype=float)
|
|
693
|
+
dir2 = np.asarray(dir2, dtype=float)
|
|
694
|
+
if dir1.shape != (d,) or dir2.shape != (d,):
|
|
695
|
+
raise ValueError(f"dir1/dir2 must have shape ({d},)")
|
|
696
|
+
x = np.tensordot(X_sel, dir1, axes=([-1], [0])) # (T', N_sel)
|
|
697
|
+
y = np.tensordot(X_sel, dir2, axes=([-1], [0])) # (T', N_sel)
|
|
698
|
+
else:
|
|
699
|
+
if dims is None:
|
|
700
|
+
dims = (0, 1)
|
|
701
|
+
i, j = dims
|
|
702
|
+
if i >= d or j >= d:
|
|
703
|
+
raise IndexError(f"dims {dims} out of range for state dimension d={d}")
|
|
704
|
+
x = X_sel[..., i]
|
|
705
|
+
y = X_sel[..., j]
|
|
706
|
+
|
|
707
|
+
x = x + float(shift[0])
|
|
708
|
+
y = y + float(shift[1])
|
|
709
|
+
|
|
710
|
+
# Periodic wrap: fold into [0, L) and flag boundary-crossing segments.
|
|
711
|
+
jump = None
|
|
712
|
+
if box is not None:
|
|
713
|
+
box = np.asarray(box, dtype=float)
|
|
714
|
+
x = x % box[0]
|
|
715
|
+
y = y % box[1]
|
|
716
|
+
jump = (np.abs(np.diff(x, axis=0)) > 0.5 * box[0]) | (
|
|
717
|
+
np.abs(np.diff(y, axis=0)) > 0.5 * box[1]
|
|
718
|
+
) # (T'-1, N_sel)
|
|
719
|
+
|
|
720
|
+
if transform is not None:
|
|
721
|
+
x = transform(x)
|
|
722
|
+
y = transform(y)
|
|
723
|
+
|
|
724
|
+
# Build segments
|
|
725
|
+
XY = np.stack([x, y], axis=-1) # (T', N_sel, 2)
|
|
726
|
+
XY_start = XY[:-1] # (T'-1, N_sel, 2)
|
|
727
|
+
XY_end = XY[1:] # (T'-1, N_sel, 2)
|
|
728
|
+
seg_valid = M_sel[:-1] & M_sel[1:] # (T'-1, N_sel)
|
|
729
|
+
if jump is not None:
|
|
730
|
+
seg_valid = seg_valid & ~jump
|
|
731
|
+
|
|
732
|
+
segs = np.stack([XY_start, XY_end], axis=2) # (T'-1, N_sel, 2, 2)
|
|
733
|
+
segs_flat = segs.reshape(-1, 2, 2)
|
|
734
|
+
valid_flat = seg_valid.reshape(-1)
|
|
735
|
+
if drop_masked:
|
|
736
|
+
segs_flat = segs_flat[valid_flat]
|
|
737
|
+
|
|
738
|
+
if color is not None:
|
|
739
|
+
if plot_colorbar:
|
|
740
|
+
raise ValueError("phase2d: `color` and `plot_colorbar` are mutually exclusive.")
|
|
741
|
+
lc = LineCollection(segs_flat, colors=color, linewidth=linewidth, alpha=alpha)
|
|
742
|
+
ax.add_collection(lc)
|
|
743
|
+
else:
|
|
744
|
+
# Color by mid-time
|
|
745
|
+
t_mid = 0.5 * (t_slice[:-1] + t_slice[1:]) # (T'-1,)
|
|
746
|
+
t_mid_2d = np.broadcast_to(t_mid[:, None], seg_valid.shape) # (T'-1, N_sel)
|
|
747
|
+
c_flat = t_mid_2d.reshape(-1)
|
|
748
|
+
if drop_masked:
|
|
749
|
+
c_flat = c_flat[valid_flat]
|
|
750
|
+
|
|
751
|
+
norm = Normalize(vmin=float(c_flat.min()), vmax=float(c_flat.max()))
|
|
752
|
+
lc = LineCollection(segs_flat, cmap=cmap, norm=norm, linewidth=linewidth, alpha=alpha)
|
|
753
|
+
lc.set_array(c_flat)
|
|
754
|
+
ax.add_collection(lc)
|
|
755
|
+
|
|
756
|
+
# Limits
|
|
757
|
+
xy_valid = XY.reshape(-1, 2)
|
|
758
|
+
if drop_masked:
|
|
759
|
+
mask_flat = M_sel.reshape(-1)
|
|
760
|
+
xy_valid = xy_valid[mask_flat]
|
|
761
|
+
if xy_valid.size:
|
|
762
|
+
ax.set_xlim(xy_valid[:, 0].min(), xy_valid[:, 0].max())
|
|
763
|
+
ax.set_ylim(xy_valid[:, 1].min(), xy_valid[:, 1].max())
|
|
764
|
+
|
|
765
|
+
_equal_aspect(ax)
|
|
766
|
+
ax.set_xlabel("x")
|
|
767
|
+
ax.set_ylabel("y")
|
|
768
|
+
|
|
769
|
+
if plot_colorbar:
|
|
770
|
+
plt.colorbar(lc, ax=ax, label="t")
|
|
771
|
+
|
|
772
|
+
return lc
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def _time_window(T, tmin, tmax):
|
|
776
|
+
"""Resolve integer (tmin, tmax) bounds, with negative tmax from the end."""
|
|
777
|
+
lo = 0 if tmin is None else max(0, int(tmin))
|
|
778
|
+
if tmax is None:
|
|
779
|
+
hi = T
|
|
780
|
+
elif tmax < 0:
|
|
781
|
+
hi = T + int(tmax)
|
|
782
|
+
else:
|
|
783
|
+
hi = min(T, int(tmax))
|
|
784
|
+
if hi <= lo:
|
|
785
|
+
raise ValueError("Empty time window.")
|
|
786
|
+
return lo, hi
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def phase2d_scalar(
|
|
790
|
+
coll: TrajectoryCollection,
|
|
791
|
+
*,
|
|
792
|
+
color_fn,
|
|
793
|
+
dataset: int = 0,
|
|
794
|
+
dims=(0, 1),
|
|
795
|
+
tmin=None,
|
|
796
|
+
tmax=None,
|
|
797
|
+
particles=None,
|
|
798
|
+
cmap="plasma",
|
|
799
|
+
linewidth: float = 1.5,
|
|
800
|
+
alpha: float = 1.0,
|
|
801
|
+
plot_colorbar: bool = True,
|
|
802
|
+
colorbar_label: str = "",
|
|
803
|
+
ax=None,
|
|
804
|
+
drop_masked: bool = True,
|
|
805
|
+
) -> LineCollection:
|
|
806
|
+
"""2D phase-space plot colored by a scalar field of the coordinate.
|
|
807
|
+
|
|
808
|
+
Like :func:`phase2d`, but each segment is colored by
|
|
809
|
+
``color_fn(midpoint)`` rather than by time — e.g. the local diffusivity
|
|
810
|
+
``D(x)``, the speed, or a potential. ``color_fn`` receives the
|
|
811
|
+
**full d-dimensional** segment midpoints, shape ``(n_segments, d)``,
|
|
812
|
+
and must return a scalar per segment, shape ``(n_segments,)``.
|
|
813
|
+
"""
|
|
814
|
+
if ax is None:
|
|
815
|
+
ax = plt.gca()
|
|
816
|
+
t, X, M = _collection_to_arrays(coll, dataset=dataset)
|
|
817
|
+
T, N, d = X.shape
|
|
818
|
+
lo, hi = _time_window(T, tmin, tmax)
|
|
819
|
+
X, M = X[lo:hi], M[lo:hi]
|
|
820
|
+
if particles is not None:
|
|
821
|
+
idx = np.asarray(particles, dtype=int)
|
|
822
|
+
X, M = X[:, idx], M[:, idx]
|
|
823
|
+
i, j = dims
|
|
824
|
+
XY = np.stack([X[..., i], X[..., j]], axis=-1) # (T', N_sel, 2)
|
|
825
|
+
Xmid = 0.5 * (X[:-1] + X[1:]) # (T'-1, N_sel, d)
|
|
826
|
+
seg_valid = (M[:-1] & M[1:]).reshape(-1)
|
|
827
|
+
segs = np.stack([XY[:-1], XY[1:]], axis=2).reshape(-1, 2, 2)
|
|
828
|
+
cvals = np.asarray(color_fn(Xmid.reshape(-1, d))).reshape(-1)
|
|
829
|
+
if drop_masked:
|
|
830
|
+
segs, cvals = segs[seg_valid], cvals[seg_valid]
|
|
831
|
+
norm = Normalize(vmin=float(cvals.min()), vmax=float(cvals.max()))
|
|
832
|
+
lc = LineCollection(segs, cmap=cmap, norm=norm, linewidth=linewidth, alpha=alpha)
|
|
833
|
+
lc.set_array(cvals)
|
|
834
|
+
ax.add_collection(lc)
|
|
835
|
+
xy = XY.reshape(-1, 2)
|
|
836
|
+
if drop_masked:
|
|
837
|
+
xy = xy[M.reshape(-1)]
|
|
838
|
+
if xy.size:
|
|
839
|
+
ax.set_xlim(xy[:, 0].min(), xy[:, 0].max())
|
|
840
|
+
ax.set_ylim(xy[:, 1].min(), xy[:, 1].max())
|
|
841
|
+
_equal_aspect(ax)
|
|
842
|
+
ax.set_xlabel("x")
|
|
843
|
+
ax.set_ylabel("y")
|
|
844
|
+
if plot_colorbar:
|
|
845
|
+
plt.colorbar(lc, ax=ax, label=colorbar_label)
|
|
846
|
+
return lc
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def timeseries_colored(
|
|
850
|
+
coll: TrajectoryCollection,
|
|
851
|
+
*,
|
|
852
|
+
color_fn,
|
|
853
|
+
dataset: int = 0,
|
|
854
|
+
dims=None,
|
|
855
|
+
particles=None,
|
|
856
|
+
cmap="plasma",
|
|
857
|
+
colorbar_label: str = "",
|
|
858
|
+
plot_colorbar: bool = True,
|
|
859
|
+
ax=None,
|
|
860
|
+
s: float = 4.0,
|
|
861
|
+
alpha: float = 1.0,
|
|
862
|
+
rasterized: bool = True,
|
|
863
|
+
**scatter_kw,
|
|
864
|
+
):
|
|
865
|
+
"""Plot ``x[dim](t)`` as a scatter colored by a scalar field.
|
|
866
|
+
|
|
867
|
+
Mask-aware time series in which each point is colored by
|
|
868
|
+
``color_fn(X)`` — e.g. the local diffusivity along the trajectory.
|
|
869
|
+
``color_fn`` receives points of shape ``(n_points, d)`` and returns a
|
|
870
|
+
scalar per point. A single shared colorbar spans all series.
|
|
871
|
+
"""
|
|
872
|
+
if ax is None:
|
|
873
|
+
ax = plt.gca()
|
|
874
|
+
t, X, M = _collection_to_arrays(coll, dataset=dataset)
|
|
875
|
+
T, N, D = X.shape
|
|
876
|
+
if particles is not None:
|
|
877
|
+
idx = np.asarray(particles, dtype=int)
|
|
878
|
+
X, M = X[:, idx], M[:, idx]
|
|
879
|
+
Nsel = X.shape[1]
|
|
880
|
+
dims = range(D) if dims is None else list(dims)
|
|
881
|
+
cvals = np.asarray(color_fn(X.reshape(-1, D))).reshape(T, Nsel)
|
|
882
|
+
norm = Normalize(vmin=float(np.nanmin(cvals)), vmax=float(np.nanmax(cvals)))
|
|
883
|
+
sm = None
|
|
884
|
+
for n in range(Nsel):
|
|
885
|
+
mask_n = M[:, n].astype(bool)
|
|
886
|
+
for dd in dims:
|
|
887
|
+
sm = ax.scatter(
|
|
888
|
+
t[mask_n], X[mask_n, n, dd], c=cvals[mask_n, n], cmap=cmap, norm=norm,
|
|
889
|
+
s=s, alpha=alpha, rasterized=rasterized, **scatter_kw,
|
|
890
|
+
)
|
|
891
|
+
ax.set_xlabel("t")
|
|
892
|
+
ax.set_ylabel(f"x[{list(dims)[0]}]" if len(list(dims)) == 1 else "x[d]")
|
|
893
|
+
if plot_colorbar and sm is not None:
|
|
894
|
+
plt.colorbar(sm, ax=ax, label=colorbar_label)
|
|
895
|
+
return ax
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def phase3d(
|
|
899
|
+
coll: TrajectoryCollection,
|
|
900
|
+
*,
|
|
901
|
+
dataset: int = 0,
|
|
902
|
+
dims=(0, 1, 2),
|
|
903
|
+
tmin=None,
|
|
904
|
+
tmax=None,
|
|
905
|
+
particles=None,
|
|
906
|
+
cmap="viridis",
|
|
907
|
+
linewidth: float = 1.5,
|
|
908
|
+
alpha: float = 1.0,
|
|
909
|
+
scatter_endpoints: bool = True,
|
|
910
|
+
scatter_size: float = 30.0,
|
|
911
|
+
ax=None,
|
|
912
|
+
drop_masked: bool = True,
|
|
913
|
+
):
|
|
914
|
+
"""3D trajectory plot with segments colored along time.
|
|
915
|
+
|
|
916
|
+
The 3D analog of :func:`phase2d`: draws each particle's path as a
|
|
917
|
+
time-colored :class:`Line3DCollection`. Pass a 3D axes via ``ax`` or a
|
|
918
|
+
new one is created (``projection="3d"``).
|
|
919
|
+
"""
|
|
920
|
+
from mpl_toolkits.mplot3d.art3d import Line3DCollection # noqa: WPS433
|
|
921
|
+
|
|
922
|
+
if ax is None:
|
|
923
|
+
ax = plt.gcf().add_subplot(111, projection="3d")
|
|
924
|
+
t, X, M = _collection_to_arrays(coll, dataset=dataset)
|
|
925
|
+
T, N, d = X.shape
|
|
926
|
+
lo, hi = _time_window(T, tmin, tmax)
|
|
927
|
+
X, M, tt = X[lo:hi], M[lo:hi], t[lo:hi]
|
|
928
|
+
if particles is not None:
|
|
929
|
+
idx = np.asarray(particles, dtype=int)
|
|
930
|
+
X, M = X[:, idx], M[:, idx]
|
|
931
|
+
i, j, k = dims
|
|
932
|
+
norm = Normalize(vmin=float(tt.min()), vmax=float(tt.max()))
|
|
933
|
+
allpts = []
|
|
934
|
+
for n in range(X.shape[1]):
|
|
935
|
+
m = M[:, n].astype(bool)
|
|
936
|
+
pts = np.stack([X[m, n, i], X[m, n, j], X[m, n, k]], axis=-1) # (Tm, 3)
|
|
937
|
+
if len(pts) < 2:
|
|
938
|
+
continue
|
|
939
|
+
allpts.append(pts)
|
|
940
|
+
segs = np.stack([pts[:-1], pts[1:]], axis=1) # (Tm-1, 2, 3)
|
|
941
|
+
t_mid = 0.5 * (tt[m][:-1] + tt[m][1:])
|
|
942
|
+
lc = Line3DCollection(segs, cmap=cmap, norm=norm, linewidth=linewidth, alpha=alpha)
|
|
943
|
+
lc.set_array(t_mid)
|
|
944
|
+
ax.add_collection3d(lc)
|
|
945
|
+
if scatter_endpoints:
|
|
946
|
+
ax.scatter(*pts[-1], s=scatter_size, color=plt.get_cmap(cmap)(1.0))
|
|
947
|
+
if allpts:
|
|
948
|
+
P = np.concatenate(allpts, axis=0)
|
|
949
|
+
ax.set_xlim(P[:, 0].min(), P[:, 0].max())
|
|
950
|
+
ax.set_ylim(P[:, 1].min(), P[:, 1].max())
|
|
951
|
+
ax.set_zlim(P[:, 2].min(), P[:, 2].max())
|
|
952
|
+
ax.set_xlabel("x")
|
|
953
|
+
ax.set_ylabel("y")
|
|
954
|
+
ax.set_zlabel("z")
|
|
955
|
+
return ax
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def trajectory_scatter(
|
|
959
|
+
coll: TrajectoryCollection,
|
|
960
|
+
*,
|
|
961
|
+
dataset: int = 0,
|
|
962
|
+
dims=(0, 1),
|
|
963
|
+
particles=None,
|
|
964
|
+
tmin=None,
|
|
965
|
+
tmax=None,
|
|
966
|
+
cmap=None,
|
|
967
|
+
s: float = 2.0,
|
|
968
|
+
alpha: float = 0.1,
|
|
969
|
+
ax=None,
|
|
970
|
+
drop_masked: bool = True,
|
|
971
|
+
**scatter_kw,
|
|
972
|
+
):
|
|
973
|
+
"""All-frames density scatter cloud of a 2D projection.
|
|
974
|
+
|
|
975
|
+
Unlike :func:`phase2d` (connected lines) or :func:`plot_particles`
|
|
976
|
+
(one frame), this scatters every valid ``(particle, frame)`` position —
|
|
977
|
+
useful for occupancy / home-range visualisations. With ``cmap`` set,
|
|
978
|
+
points are colored by time.
|
|
979
|
+
"""
|
|
980
|
+
if ax is None:
|
|
981
|
+
ax = plt.gca()
|
|
982
|
+
t, X, M = _collection_to_arrays(coll, dataset=dataset)
|
|
983
|
+
T, N, d = X.shape
|
|
984
|
+
lo, hi = _time_window(T, tmin, tmax)
|
|
985
|
+
X, M, tt = X[lo:hi], M[lo:hi], t[lo:hi]
|
|
986
|
+
if particles is not None:
|
|
987
|
+
idx = np.asarray(particles, dtype=int)
|
|
988
|
+
X, M = X[:, idx], M[:, idx]
|
|
989
|
+
i, j = dims
|
|
990
|
+
xf = X[..., i].reshape(-1)
|
|
991
|
+
yf = X[..., j].reshape(-1)
|
|
992
|
+
maskf = M.reshape(-1).astype(bool)
|
|
993
|
+
cf = np.broadcast_to(tt[:, None], X.shape[:2]).reshape(-1) if cmap is not None else None
|
|
994
|
+
if drop_masked:
|
|
995
|
+
xf, yf = xf[maskf], yf[maskf]
|
|
996
|
+
if cf is not None:
|
|
997
|
+
cf = cf[maskf]
|
|
998
|
+
if cf is not None:
|
|
999
|
+
ax.scatter(xf, yf, c=cf, cmap=cmap, s=s, alpha=alpha, **scatter_kw)
|
|
1000
|
+
else:
|
|
1001
|
+
ax.scatter(xf, yf, color=SFI_COLORS["data"], s=s, alpha=alpha, **scatter_kw)
|
|
1002
|
+
_equal_aspect(ax)
|
|
1003
|
+
ax.set_xlabel("x")
|
|
1004
|
+
ax.set_ylabel("y")
|
|
1005
|
+
return ax
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def plot_field(
|
|
1009
|
+
coll: TrajectoryCollection,
|
|
1010
|
+
field,
|
|
1011
|
+
*,
|
|
1012
|
+
dataset: int = 0,
|
|
1013
|
+
dir1=None,
|
|
1014
|
+
dir2=None,
|
|
1015
|
+
center=None,
|
|
1016
|
+
N: int = 10,
|
|
1017
|
+
scale: float = 1.0,
|
|
1018
|
+
autoscale: bool = False,
|
|
1019
|
+
color="g",
|
|
1020
|
+
radius=None,
|
|
1021
|
+
positions=None,
|
|
1022
|
+
powernorm: float = 0.0,
|
|
1023
|
+
mask_unvisited: bool = False,
|
|
1024
|
+
clip_magnitude=None,
|
|
1025
|
+
**kwargs,
|
|
1026
|
+
):
|
|
1027
|
+
"""Plot a 2D vector field (or a 2D slice of a higher-dimensional field).
|
|
1028
|
+
|
|
1029
|
+
Parameters
|
|
1030
|
+
----------
|
|
1031
|
+
coll :
|
|
1032
|
+
TrajectoryCollection providing typical positions for scaling/centering.
|
|
1033
|
+
field :
|
|
1034
|
+
Callable ``field(X) -> F`` with X of shape (n_points, d) and F of same shape.
|
|
1035
|
+
dataset :
|
|
1036
|
+
Dataset index inside the collection used to estimate center/radius.
|
|
1037
|
+
dir1, dir2 :
|
|
1038
|
+
Projection directions in R^d. If None, use coordinate axes 0 and 1.
|
|
1039
|
+
center, N, scale, autoscale, color, radius, positions, powernorm, **kwargs :
|
|
1040
|
+
Grid / scaling controls.
|
|
1041
|
+
mask_unvisited :
|
|
1042
|
+
If True, drop arrows in grid cells with no trajectory data within one
|
|
1043
|
+
grid spacing (keeps the quiver legible in sparsely-sampled regions).
|
|
1044
|
+
clip_magnitude :
|
|
1045
|
+
If set, clip each arrow's magnitude to this value (long arrows in
|
|
1046
|
+
high-force regions no longer dominate the plot).
|
|
1047
|
+
"""
|
|
1048
|
+
t, X, M = _collection_to_arrays(coll, dataset=dataset)
|
|
1049
|
+
d = X.shape[-1]
|
|
1050
|
+
|
|
1051
|
+
if dir1 is None:
|
|
1052
|
+
dir1 = axisvector(0, d)
|
|
1053
|
+
if dir2 is None:
|
|
1054
|
+
dir2 = axisvector(1, d)
|
|
1055
|
+
if center is None:
|
|
1056
|
+
center = X.mean(axis=(0, 1))
|
|
1057
|
+
if radius is None:
|
|
1058
|
+
radius = 0.5 * (X.max(axis=(0, 1)) - X.min(axis=(0, 1))).max()
|
|
1059
|
+
|
|
1060
|
+
if positions is None:
|
|
1061
|
+
positions = []
|
|
1062
|
+
for a in np.linspace(-radius, radius, N):
|
|
1063
|
+
for b in np.linspace(-radius, radius, N):
|
|
1064
|
+
positions.append(center + a * dir1 + b * dir2)
|
|
1065
|
+
|
|
1066
|
+
gridX, gridY = [], []
|
|
1067
|
+
vX, vY = [], []
|
|
1068
|
+
for pos in positions:
|
|
1069
|
+
x = dir1.dot(pos)
|
|
1070
|
+
y = dir2.dot(pos)
|
|
1071
|
+
gridX.append(x)
|
|
1072
|
+
gridY.append(y)
|
|
1073
|
+
v = field(pos.reshape((1, d)))
|
|
1074
|
+
if powernorm != 0:
|
|
1075
|
+
v /= np.linalg.norm(v) ** powernorm
|
|
1076
|
+
vX.append(dir1.dot(v[0, :]))
|
|
1077
|
+
vY.append(dir2.dot(v[0, :]))
|
|
1078
|
+
|
|
1079
|
+
vX = np.array(vX)
|
|
1080
|
+
vY = np.array(vY)
|
|
1081
|
+
|
|
1082
|
+
if clip_magnitude is not None:
|
|
1083
|
+
mag = np.sqrt(vX**2 + vY**2)
|
|
1084
|
+
factor = np.where(mag > clip_magnitude, clip_magnitude / np.maximum(mag, 1e-12), 1.0)
|
|
1085
|
+
vX = vX * factor
|
|
1086
|
+
vY = vY * factor
|
|
1087
|
+
|
|
1088
|
+
if autoscale:
|
|
1089
|
+
scale /= float(np.nanmax(np.sqrt(vX**2 + vY**2)))
|
|
1090
|
+
|
|
1091
|
+
if mask_unvisited:
|
|
1092
|
+
data = X.reshape(-1, d)
|
|
1093
|
+
if data.shape[0] > 5000:
|
|
1094
|
+
data = data[:: max(1, data.shape[0] // 5000)]
|
|
1095
|
+
pos_arr = np.asarray([np.asarray(p) for p in positions])
|
|
1096
|
+
spacing = (2.0 * radius) / max(N - 1, 1)
|
|
1097
|
+
covered = np.zeros(len(pos_arr), dtype=bool)
|
|
1098
|
+
for ci in range(0, len(pos_arr), 256):
|
|
1099
|
+
chunk = pos_arr[ci : ci + 256]
|
|
1100
|
+
dmin = np.sqrt(((chunk[:, None, :] - data[None, :, :]) ** 2).sum(-1)).min(axis=1)
|
|
1101
|
+
covered[ci : ci + 256] = dmin <= spacing
|
|
1102
|
+
vX = np.where(covered, vX, np.nan)
|
|
1103
|
+
vY = np.where(covered, vY, np.nan)
|
|
1104
|
+
|
|
1105
|
+
plt.quiver(
|
|
1106
|
+
gridX,
|
|
1107
|
+
gridY,
|
|
1108
|
+
scale * vX,
|
|
1109
|
+
scale * vY,
|
|
1110
|
+
scale=1.0,
|
|
1111
|
+
units="xy",
|
|
1112
|
+
color=color,
|
|
1113
|
+
minlength=0.0,
|
|
1114
|
+
**kwargs,
|
|
1115
|
+
)
|
|
1116
|
+
plt.ylim(-radius + dir2.dot(center), radius + dir2.dot(center))
|
|
1117
|
+
plt.xlim(-radius + dir1.dot(center), radius + dir1.dot(center))
|
|
1118
|
+
_equal_aspect(plt.gca())
|
|
1119
|
+
plt.xticks([])
|
|
1120
|
+
plt.yticks([])
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
def plot_tensor_field(
|
|
1124
|
+
coll: TrajectoryCollection,
|
|
1125
|
+
field,
|
|
1126
|
+
*,
|
|
1127
|
+
dataset: int = 0,
|
|
1128
|
+
center=None,
|
|
1129
|
+
N: int = 10,
|
|
1130
|
+
scale: float = 1.0,
|
|
1131
|
+
autoscale: bool = False,
|
|
1132
|
+
color="g",
|
|
1133
|
+
radius=None,
|
|
1134
|
+
positions=None,
|
|
1135
|
+
mode: str = "eigencross",
|
|
1136
|
+
**kwargs,
|
|
1137
|
+
):
|
|
1138
|
+
"""Plot a tensor field for 2D processes from a TrajectoryCollection.
|
|
1139
|
+
|
|
1140
|
+
``mode="eigencross"`` (default) draws each tensor as a pair of
|
|
1141
|
+
eigen-axis arrows; ``mode="ellipse"`` draws an eigen-aligned ellipse
|
|
1142
|
+
glyph (axis lengths ``∝ sqrt(eigenvalue)``), a clearer rendering for
|
|
1143
|
+
anisotropic diffusion fields.
|
|
1144
|
+
"""
|
|
1145
|
+
if mode not in ("eigencross", "ellipse"):
|
|
1146
|
+
raise ValueError(f"Unknown mode {mode!r}; expected 'eigencross' or 'ellipse'.")
|
|
1147
|
+
t, X, M = _collection_to_arrays(coll, dataset=dataset)
|
|
1148
|
+
d = X.shape[-1]
|
|
1149
|
+
if d != 2:
|
|
1150
|
+
raise ValueError(f"plot_tensor_field expects d=2, got d={d}")
|
|
1151
|
+
|
|
1152
|
+
if center is None:
|
|
1153
|
+
center = X.mean(axis=(0, 1))
|
|
1154
|
+
if radius is None:
|
|
1155
|
+
radius = 0.5 * (X.max(axis=(0, 1)) - X.min(axis=(0, 1))).max()
|
|
1156
|
+
|
|
1157
|
+
if positions is None:
|
|
1158
|
+
positions = []
|
|
1159
|
+
for a in np.linspace(-radius, radius, N):
|
|
1160
|
+
for b in np.linspace(-radius, radius, N):
|
|
1161
|
+
positions.append(center + np.array([a, b]))
|
|
1162
|
+
|
|
1163
|
+
if mode == "ellipse":
|
|
1164
|
+
from matplotlib.patches import Ellipse
|
|
1165
|
+
|
|
1166
|
+
ax = plt.gca()
|
|
1167
|
+
for pos in positions:
|
|
1168
|
+
tensor = np.asarray(field(pos.reshape((1, d)))).reshape(2, 2)
|
|
1169
|
+
w, Vv = np.linalg.eigh(0.5 * (tensor + tensor.T))
|
|
1170
|
+
w = np.clip(w, 1e-4, None)
|
|
1171
|
+
ang = np.degrees(np.arctan2(Vv[1, 1], Vv[0, 1]))
|
|
1172
|
+
ax.add_patch(
|
|
1173
|
+
Ellipse(
|
|
1174
|
+
(pos[0], pos[1]),
|
|
1175
|
+
width=scale * float(np.sqrt(w[1])),
|
|
1176
|
+
height=scale * float(np.sqrt(w[0])),
|
|
1177
|
+
angle=ang,
|
|
1178
|
+
fill=False,
|
|
1179
|
+
lw=1.2,
|
|
1180
|
+
color=color,
|
|
1181
|
+
**kwargs,
|
|
1182
|
+
)
|
|
1183
|
+
)
|
|
1184
|
+
ax.set_xlim(center[0] - radius, center[0] + radius)
|
|
1185
|
+
ax.set_ylim(center[1] - radius, center[1] + radius)
|
|
1186
|
+
_equal_aspect(plt.gca())
|
|
1187
|
+
plt.xticks([])
|
|
1188
|
+
plt.yticks([])
|
|
1189
|
+
return
|
|
1190
|
+
|
|
1191
|
+
Xp, Yp, U, V = [], [], [], []
|
|
1192
|
+
for pos in positions:
|
|
1193
|
+
posr = pos.reshape((1, d))
|
|
1194
|
+
tensor = field(posr)
|
|
1195
|
+
eigvals, eigvecs = np.linalg.eigh(tensor.reshape((2, 2)))
|
|
1196
|
+
for j in range(2):
|
|
1197
|
+
Xp.append(pos[0])
|
|
1198
|
+
Yp.append(pos[1])
|
|
1199
|
+
U.append(eigvals[j] * eigvecs[0, j])
|
|
1200
|
+
V.append(eigvals[j] * eigvecs[1, j])
|
|
1201
|
+
|
|
1202
|
+
if autoscale:
|
|
1203
|
+
scale /= max(np.array(U) ** 2 + np.array(V) ** 2) ** 0.5
|
|
1204
|
+
|
|
1205
|
+
Xp = np.array(Xp)
|
|
1206
|
+
Yp = np.array(Yp)
|
|
1207
|
+
dX = 0.5 * scale * np.array(U)
|
|
1208
|
+
dY = 0.5 * scale * np.array(V)
|
|
1209
|
+
|
|
1210
|
+
plt.quiver(
|
|
1211
|
+
Xp - dX,
|
|
1212
|
+
Yp - dY,
|
|
1213
|
+
2 * dX,
|
|
1214
|
+
2 * dY,
|
|
1215
|
+
scale=1.0,
|
|
1216
|
+
units="xy",
|
|
1217
|
+
color=color,
|
|
1218
|
+
minlength=0.0,
|
|
1219
|
+
headwidth=1.0,
|
|
1220
|
+
headlength=0.0,
|
|
1221
|
+
**kwargs,
|
|
1222
|
+
)
|
|
1223
|
+
_equal_aspect(plt.gca())
|
|
1224
|
+
plt.xticks([])
|
|
1225
|
+
plt.yticks([])
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def plot_profile_1d(
|
|
1229
|
+
coll: TrajectoryCollection,
|
|
1230
|
+
field,
|
|
1231
|
+
*,
|
|
1232
|
+
exact_field=None,
|
|
1233
|
+
dataset: int = 0,
|
|
1234
|
+
dim: int = 0,
|
|
1235
|
+
component=None,
|
|
1236
|
+
N: int = 200,
|
|
1237
|
+
ci=None,
|
|
1238
|
+
samples: bool = False,
|
|
1239
|
+
ax=None,
|
|
1240
|
+
margin: float = 0.05,
|
|
1241
|
+
label_exact: str = "Exact",
|
|
1242
|
+
label_inferred: str = "Inferred",
|
|
1243
|
+
):
|
|
1244
|
+
"""1D profile of an inferred field, optionally overlaid on the exact one.
|
|
1245
|
+
|
|
1246
|
+
Evaluates ``field`` on a grid spanning the data range along ``dim`` and
|
|
1247
|
+
plots the inferred profile (gold), an optional exact overlay (orange
|
|
1248
|
+
dashes), an optional confidence band, and an optional sample histogram
|
|
1249
|
+
backdrop. The 1D analog of :func:`plot_field` for forces ``F(x)`` and
|
|
1250
|
+
scalar diffusion profiles ``D(x)`` / ``D(v)``.
|
|
1251
|
+
|
|
1252
|
+
Parameters
|
|
1253
|
+
----------
|
|
1254
|
+
field, exact_field :
|
|
1255
|
+
Callables ``f(X) -> array`` (vector ``(N, d)`` or tensor
|
|
1256
|
+
``(N, d, d)``). ``exact_field`` is optional.
|
|
1257
|
+
dim :
|
|
1258
|
+
Coordinate axis to sweep.
|
|
1259
|
+
component :
|
|
1260
|
+
Which output component to plot. Defaults to ``dim`` for a vector
|
|
1261
|
+
field and ``(dim, dim)`` for a tensor field.
|
|
1262
|
+
ci :
|
|
1263
|
+
Optional dict with ``"lower"``/``"upper"`` arrays (same length as the
|
|
1264
|
+
grid) for a confidence band.
|
|
1265
|
+
samples :
|
|
1266
|
+
If True, draw a faint histogram of the data along ``dim`` behind the
|
|
1267
|
+
curves.
|
|
1268
|
+
"""
|
|
1269
|
+
if ax is None:
|
|
1270
|
+
ax = plt.gca()
|
|
1271
|
+
t, X, M = _collection_to_arrays(coll, dataset=dataset)
|
|
1272
|
+
d = X.shape[-1]
|
|
1273
|
+
xmin = float(X[..., dim].min())
|
|
1274
|
+
xmax = float(X[..., dim].max())
|
|
1275
|
+
pad = margin * (xmax - xmin)
|
|
1276
|
+
grid = np.linspace(xmin - pad, xmax + pad, N)
|
|
1277
|
+
pts = np.zeros((N, d))
|
|
1278
|
+
pts[:, dim] = grid
|
|
1279
|
+
|
|
1280
|
+
def _val(f):
|
|
1281
|
+
out = np.asarray(f(jnp.asarray(pts)))
|
|
1282
|
+
if out.ndim == 1:
|
|
1283
|
+
return out
|
|
1284
|
+
if out.ndim == 2:
|
|
1285
|
+
return out[:, component if component is not None else dim]
|
|
1286
|
+
if out.ndim == 3:
|
|
1287
|
+
c = component if component is not None else dim
|
|
1288
|
+
return out[:, c, c]
|
|
1289
|
+
return out.reshape(N, -1)[:, 0]
|
|
1290
|
+
|
|
1291
|
+
if samples:
|
|
1292
|
+
data1 = X[..., dim].reshape(-1)[M.reshape(-1).astype(bool)]
|
|
1293
|
+
axh = ax.twinx()
|
|
1294
|
+
axh.hist(data1, bins=60, density=True, alpha=0.18, color=SFI_COLORS["data"])
|
|
1295
|
+
axh.set_yticks([])
|
|
1296
|
+
|
|
1297
|
+
if exact_field is not None:
|
|
1298
|
+
ax.plot(grid, _val(exact_field), "--", lw=2, color=SFI_COLORS["exact"], label=label_exact)
|
|
1299
|
+
ax.plot(grid, _val(field), lw=2, color=SFI_COLORS["inferred"], label=label_inferred)
|
|
1300
|
+
if ci is not None:
|
|
1301
|
+
ax.fill_between(
|
|
1302
|
+
grid, np.asarray(ci["lower"]), np.asarray(ci["upper"]),
|
|
1303
|
+
color=SFI_COLORS["inferred"], alpha=0.2,
|
|
1304
|
+
)
|
|
1305
|
+
ax.axhline(0, color="#808080", lw=0.5)
|
|
1306
|
+
ax.set_xlabel(f"x[{dim}]")
|
|
1307
|
+
ax.legend()
|
|
1308
|
+
return ax
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
def plot_field_error(
|
|
1312
|
+
coll: TrajectoryCollection,
|
|
1313
|
+
field_inferred,
|
|
1314
|
+
field_exact,
|
|
1315
|
+
*,
|
|
1316
|
+
dataset: int = 0,
|
|
1317
|
+
N: int = 60,
|
|
1318
|
+
norm: str = "l2",
|
|
1319
|
+
cmap: str = "inferno",
|
|
1320
|
+
vmax=None,
|
|
1321
|
+
ax=None,
|
|
1322
|
+
):
|
|
1323
|
+
"""2D heatmap of the pointwise force-reconstruction error.
|
|
1324
|
+
|
|
1325
|
+
Evaluates both fields on a grid spanning the (masked) data bounding box
|
|
1326
|
+
and renders ``||F_exact - F_inferred||`` as a ``pcolormesh``. The
|
|
1327
|
+
spatial complement to :func:`comparison_scatter`.
|
|
1328
|
+
"""
|
|
1329
|
+
if ax is None:
|
|
1330
|
+
ax = plt.gca()
|
|
1331
|
+
t, X, M = _collection_to_arrays(coll, dataset=dataset)
|
|
1332
|
+
d = X.shape[-1]
|
|
1333
|
+
if d != 2:
|
|
1334
|
+
raise ValueError(f"plot_field_error requires 2D data, got d={d}")
|
|
1335
|
+
flat = X.reshape(-1, d)[M.reshape(-1).astype(bool)]
|
|
1336
|
+
xmin, ymin = flat.min(axis=0)
|
|
1337
|
+
xmax, ymax = flat.max(axis=0)
|
|
1338
|
+
GX, GY = np.meshgrid(np.linspace(xmin, xmax, N), np.linspace(ymin, ymax, N))
|
|
1339
|
+
pts = np.stack([GX.ravel(), GY.ravel()], axis=-1)
|
|
1340
|
+
Fi = np.asarray(field_inferred(jnp.asarray(pts)))
|
|
1341
|
+
Fe = np.asarray(field_exact(jnp.asarray(pts)))
|
|
1342
|
+
ordmap = {"l2": 2, "l1": 1, "linf": np.inf}
|
|
1343
|
+
err = np.linalg.norm(Fe - Fi, ord=ordmap.get(norm, 2), axis=-1).reshape(GX.shape)
|
|
1344
|
+
im = ax.pcolormesh(GX, GY, err, cmap=cmap, vmax=vmax, shading="auto")
|
|
1345
|
+
plt.colorbar(im, ax=ax, label=f"||F_exact - F_inferred|| ({norm})")
|
|
1346
|
+
ax.set_aspect("equal")
|
|
1347
|
+
ax.set_xlabel("x")
|
|
1348
|
+
ax.set_ylabel("y")
|
|
1349
|
+
return ax
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def stream_field(
|
|
1353
|
+
coll: TrajectoryCollection,
|
|
1354
|
+
field,
|
|
1355
|
+
*,
|
|
1356
|
+
dataset: int = 0,
|
|
1357
|
+
dir1=None,
|
|
1358
|
+
dir2=None,
|
|
1359
|
+
center=None,
|
|
1360
|
+
radius=None,
|
|
1361
|
+
N: int = 20,
|
|
1362
|
+
density: float = 1.0,
|
|
1363
|
+
color: str = "#808080",
|
|
1364
|
+
ax=None,
|
|
1365
|
+
**streamplot_kw,
|
|
1366
|
+
):
|
|
1367
|
+
"""Streamplot of a callable vector field over the data domain.
|
|
1368
|
+
|
|
1369
|
+
The streamline counterpart to :func:`plot_field` (quiver): integrates
|
|
1370
|
+
``field(X) -> F`` into flow lines on a grid spanning the data bounding
|
|
1371
|
+
box. Useful for visualising the topology of an inferred or analytic
|
|
1372
|
+
drift field.
|
|
1373
|
+
"""
|
|
1374
|
+
if ax is None:
|
|
1375
|
+
ax = plt.gca()
|
|
1376
|
+
t, X, M = _collection_to_arrays(coll, dataset=dataset)
|
|
1377
|
+
d = X.shape[-1]
|
|
1378
|
+
if dir1 is None:
|
|
1379
|
+
dir1 = axisvector(0, d)
|
|
1380
|
+
if dir2 is None:
|
|
1381
|
+
dir2 = axisvector(1, d)
|
|
1382
|
+
if center is None:
|
|
1383
|
+
center = X.mean(axis=(0, 1))
|
|
1384
|
+
if radius is None:
|
|
1385
|
+
radius = 0.5 * (X.max(axis=(0, 1)) - X.min(axis=(0, 1))).max()
|
|
1386
|
+
g = np.linspace(-radius, radius, N)
|
|
1387
|
+
GX, GY = np.meshgrid(g, g)
|
|
1388
|
+
pts = (
|
|
1389
|
+
center[None, :]
|
|
1390
|
+
+ GX.ravel()[:, None] * dir1[None, :]
|
|
1391
|
+
+ GY.ravel()[:, None] * dir2[None, :]
|
|
1392
|
+
)
|
|
1393
|
+
F = np.asarray(field(jnp.asarray(pts)))
|
|
1394
|
+
U = F.dot(dir1).reshape(N, N)
|
|
1395
|
+
V = F.dot(dir2).reshape(N, N)
|
|
1396
|
+
ax.streamplot(g + float(dir1.dot(center)), g + float(dir2.dot(center)), U, V, density=density, color=color, **streamplot_kw)
|
|
1397
|
+
ax.set_aspect("equal")
|
|
1398
|
+
ax.set_xlabel("x")
|
|
1399
|
+
ax.set_ylabel("y")
|
|
1400
|
+
return ax
|
|
1401
|
+
|
|
1402
|
+
|
|
1403
|
+
def plot_particles(
|
|
1404
|
+
coll: TrajectoryCollection,
|
|
1405
|
+
a: int = 0,
|
|
1406
|
+
b: int = 1,
|
|
1407
|
+
t_index: int = -1,
|
|
1408
|
+
colored: bool = True,
|
|
1409
|
+
active: bool = False,
|
|
1410
|
+
u: float = 0.35,
|
|
1411
|
+
*,
|
|
1412
|
+
dataset: int = 0,
|
|
1413
|
+
color_dim=None,
|
|
1414
|
+
cmap=None,
|
|
1415
|
+
vmin=None,
|
|
1416
|
+
vmax=None,
|
|
1417
|
+
quiver: bool = False,
|
|
1418
|
+
heading_dim=None,
|
|
1419
|
+
box=None,
|
|
1420
|
+
s: float = 100.0,
|
|
1421
|
+
ax=None,
|
|
1422
|
+
quiver_kw=None,
|
|
1423
|
+
**kwargs,
|
|
1424
|
+
):
|
|
1425
|
+
"""Display all particles at time index ``t_index`` from a collection.
|
|
1426
|
+
|
|
1427
|
+
Parameters
|
|
1428
|
+
----------
|
|
1429
|
+
a, b :
|
|
1430
|
+
State dimensions for the x/y axes of the snapshot.
|
|
1431
|
+
t_index :
|
|
1432
|
+
Frame index (negative counts from the end).
|
|
1433
|
+
colored :
|
|
1434
|
+
If True (and no ``color_dim``), color particles by index (magma).
|
|
1435
|
+
color_dim :
|
|
1436
|
+
Color particles by the value of this state dimension instead of by
|
|
1437
|
+
index — e.g. heading angle (default colormap ``"hsv"``).
|
|
1438
|
+
cmap, vmin, vmax :
|
|
1439
|
+
Colormap / normalisation for the coloring.
|
|
1440
|
+
quiver, heading_dim :
|
|
1441
|
+
If ``quiver=True``, overlay heading arrows from
|
|
1442
|
+
``cos/sin(X[:, heading_dim])`` (``heading_dim`` defaults to 2, the
|
|
1443
|
+
orientation channel of an active particle).
|
|
1444
|
+
box :
|
|
1445
|
+
Optional periodic box ``(Lx, Ly)``; positions are wrapped into it.
|
|
1446
|
+
active, u :
|
|
1447
|
+
Legacy orientation marker (a dot at ``u·(cosθ, sinθ)``); superseded
|
|
1448
|
+
by ``quiver`` for active-matter snapshots.
|
|
1449
|
+
ax :
|
|
1450
|
+
Target axes (default: current axes).
|
|
1451
|
+
"""
|
|
1452
|
+
if ax is None:
|
|
1453
|
+
ax = plt.gca()
|
|
1454
|
+
t, X, M = _collection_to_arrays(coll, dataset=dataset) # X: (T, N, d)
|
|
1455
|
+
T = X.shape[0]
|
|
1456
|
+
if t_index < 0:
|
|
1457
|
+
t_index = T + t_index
|
|
1458
|
+
if not (0 <= t_index < T):
|
|
1459
|
+
raise IndexError(f"t_index {t_index} out of range for T={T}")
|
|
1460
|
+
|
|
1461
|
+
X_t = X[t_index] # (N, d)
|
|
1462
|
+
xy = X_t[:, [a, b]].astype(float)
|
|
1463
|
+
if box is not None:
|
|
1464
|
+
xy = wrap_positions(xy, np.asarray(box)[:2])
|
|
1465
|
+
x, y = xy[:, 0], xy[:, 1]
|
|
1466
|
+
|
|
1467
|
+
if color_dim is not None:
|
|
1468
|
+
ax.scatter(x, y, c=X_t[:, color_dim], cmap=cmap or "hsv", vmin=vmin, vmax=vmax, s=s, **kwargs)
|
|
1469
|
+
elif colored:
|
|
1470
|
+
ax.scatter(x, y, cmap=cmap or "magma", s=s, c=np.linspace(0, 1, len(X_t)), vmin=vmin, vmax=vmax, **kwargs)
|
|
1471
|
+
else:
|
|
1472
|
+
ax.scatter(x, y, s=s, c="w", edgecolor="#808080", **kwargs)
|
|
1473
|
+
|
|
1474
|
+
hd = heading_dim if heading_dim is not None else (2 if X_t.shape[1] >= 3 else None)
|
|
1475
|
+
if quiver and hd is not None:
|
|
1476
|
+
qkw = dict(color="#B0B0B0", pivot="mid", units="xy")
|
|
1477
|
+
qkw.update(quiver_kw or {})
|
|
1478
|
+
ax.quiver(x, y, np.cos(X_t[:, hd]), np.sin(X_t[:, hd]), **qkw)
|
|
1479
|
+
elif active and X_t.shape[1] >= 3:
|
|
1480
|
+
xa = x + u * np.cos(X_t[:, 2])
|
|
1481
|
+
ya = y + u * np.sin(X_t[:, 2])
|
|
1482
|
+
ax.scatter(xa, ya, c="#B0B0B0", s=20)
|
|
1483
|
+
|
|
1484
|
+
ax.set_aspect("equal")
|
|
1485
|
+
ax.set_xticks([])
|
|
1486
|
+
ax.set_yticks([])
|
|
1487
|
+
return ax
|
|
1488
|
+
|
|
1489
|
+
|
|
1490
|
+
def plot_particles_field(
|
|
1491
|
+
coll: TrajectoryCollection,
|
|
1492
|
+
field,
|
|
1493
|
+
*,
|
|
1494
|
+
dataset: int = 0,
|
|
1495
|
+
t_index: int = -1,
|
|
1496
|
+
dir1=None,
|
|
1497
|
+
dir2=None,
|
|
1498
|
+
center=None,
|
|
1499
|
+
radius=None,
|
|
1500
|
+
scale: float = 1.0,
|
|
1501
|
+
autoscale: bool = False,
|
|
1502
|
+
color="g",
|
|
1503
|
+
**kwargs,
|
|
1504
|
+
):
|
|
1505
|
+
"""
|
|
1506
|
+
Plot a 2D vector field evaluated at particle positions at a given time.
|
|
1507
|
+
"""
|
|
1508
|
+
t, X, M = _collection_to_arrays(coll, dataset=dataset)
|
|
1509
|
+
T, N, d = X.shape
|
|
1510
|
+
|
|
1511
|
+
if t_index < 0:
|
|
1512
|
+
t_index = T + t_index
|
|
1513
|
+
if not (0 <= t_index < T):
|
|
1514
|
+
raise IndexError(f"t_index {t_index} out of range for T={T}")
|
|
1515
|
+
|
|
1516
|
+
X_t = X[t_index]
|
|
1517
|
+
|
|
1518
|
+
if dir1 is None:
|
|
1519
|
+
dir1 = axisvector(0, d)
|
|
1520
|
+
if dir2 is None:
|
|
1521
|
+
dir2 = axisvector(1, d)
|
|
1522
|
+
|
|
1523
|
+
F = field(X_t)
|
|
1524
|
+
if center is None:
|
|
1525
|
+
center = X_t.mean(axis=0)
|
|
1526
|
+
if radius is None:
|
|
1527
|
+
radius = 0.5 * (X_t.max(axis=0) - X_t.min(axis=0)).max()
|
|
1528
|
+
|
|
1529
|
+
gridX, gridY, vX, vY = [], [], [], []
|
|
1530
|
+
for ind, pos in enumerate(X_t):
|
|
1531
|
+
x = dir1.dot(pos)
|
|
1532
|
+
y = dir2.dot(pos)
|
|
1533
|
+
gridX.append(x)
|
|
1534
|
+
gridY.append(y)
|
|
1535
|
+
vX.append(dir1.dot(F[ind, :]))
|
|
1536
|
+
vY.append(dir2.dot(F[ind, :]))
|
|
1537
|
+
|
|
1538
|
+
if autoscale:
|
|
1539
|
+
scale /= max(np.array(vX) ** 2 + np.array(vY) ** 2) ** 0.5
|
|
1540
|
+
|
|
1541
|
+
plt.quiver(
|
|
1542
|
+
gridX,
|
|
1543
|
+
gridY,
|
|
1544
|
+
scale * np.array(vX),
|
|
1545
|
+
scale * np.array(vY),
|
|
1546
|
+
scale=1.0,
|
|
1547
|
+
units="xy",
|
|
1548
|
+
color=color,
|
|
1549
|
+
minlength=0.0,
|
|
1550
|
+
**kwargs,
|
|
1551
|
+
)
|
|
1552
|
+
plt.ylim(-radius + dir2.dot(center), radius + dir2.dot(center))
|
|
1553
|
+
plt.xlim(-radius + dir1.dot(center), radius + dir1.dot(center))
|
|
1554
|
+
_equal_aspect(plt.gca())
|
|
1555
|
+
plt.xticks([])
|
|
1556
|
+
plt.yticks([])
|
|
1557
|
+
|
|
1558
|
+
|
|
1559
|
+
def plot_nematic_director(
|
|
1560
|
+
ax,
|
|
1561
|
+
Qxx,
|
|
1562
|
+
Qxy,
|
|
1563
|
+
rho,
|
|
1564
|
+
*,
|
|
1565
|
+
skip: int = 2,
|
|
1566
|
+
scale: float = 2.5,
|
|
1567
|
+
color: str = "white",
|
|
1568
|
+
alpha: float = 0.55,
|
|
1569
|
+
linewidth: float = 0.45,
|
|
1570
|
+
**quiver_kw,
|
|
1571
|
+
):
|
|
1572
|
+
"""Overlay the nematic director field of a Q-tensor on an image axes.
|
|
1573
|
+
|
|
1574
|
+
Given the order-parameter fields ``Qxx``, ``Qxy`` and density ``rho``
|
|
1575
|
+
(each a 2D grid), draws a *headless* quiver of the director
|
|
1576
|
+
``ψ = ½·atan2(Qxy, Qxx)`` on a subsampled grid — the canonical
|
|
1577
|
+
active-nematic overlay.
|
|
1578
|
+
|
|
1579
|
+
Parameters
|
|
1580
|
+
----------
|
|
1581
|
+
ax :
|
|
1582
|
+
Target axes (typically holding an ``imshow`` of the density).
|
|
1583
|
+
Qxx, Qxy, rho :
|
|
1584
|
+
2D arrays of equal shape.
|
|
1585
|
+
skip :
|
|
1586
|
+
Subsampling stride for the director glyphs.
|
|
1587
|
+
scale :
|
|
1588
|
+
Glyph length (passed through to ``quiver`` ``scale``).
|
|
1589
|
+
"""
|
|
1590
|
+
Qxx = np.asarray(Qxx)
|
|
1591
|
+
Qxy = np.asarray(Qxy)
|
|
1592
|
+
rho = np.maximum(np.asarray(rho), 1e-3)
|
|
1593
|
+
psi = 0.5 * np.arctan2(Qxy / rho, Qxx / rho)
|
|
1594
|
+
ny, nx = psi.shape
|
|
1595
|
+
iy, ix = np.meshgrid(
|
|
1596
|
+
np.arange(skip // 2, ny, skip), np.arange(skip // 2, nx, skip), indexing="ij"
|
|
1597
|
+
)
|
|
1598
|
+
th = psi[iy, ix]
|
|
1599
|
+
qkw = dict(
|
|
1600
|
+
scale=scale, color=color, alpha=alpha, linewidth=linewidth,
|
|
1601
|
+
headwidth=0, headlength=0, headaxislength=0, pivot="mid", units="xy",
|
|
1602
|
+
)
|
|
1603
|
+
qkw.update(quiver_kw)
|
|
1604
|
+
# Return the Quiver artist so callers can update it per animation frame
|
|
1605
|
+
# (set_offsets / set_UVC); for static use just ignore the return value.
|
|
1606
|
+
return ax.quiver(ix, iy, np.cos(th), np.sin(th), **qkw)
|
|
1607
|
+
|
|
1608
|
+
|
|
1609
|
+
def plot_rods(
|
|
1610
|
+
ax,
|
|
1611
|
+
X_frame,
|
|
1612
|
+
*,
|
|
1613
|
+
angle_index: int = 2,
|
|
1614
|
+
length: float = 0.85,
|
|
1615
|
+
color: str = "#d4a96a",
|
|
1616
|
+
linewidth: float = 2.8,
|
|
1617
|
+
capstyle: str = "round",
|
|
1618
|
+
**kwargs,
|
|
1619
|
+
):
|
|
1620
|
+
"""Draw oriented rods (active-matter particles) as a ``LineCollection``.
|
|
1621
|
+
|
|
1622
|
+
Each particle in ``X_frame`` (rows ``(x, y, …, θ, …)``) becomes a short
|
|
1623
|
+
segment of length ``length`` centred on ``(x, y)`` and oriented at
|
|
1624
|
+
``θ = X_frame[:, angle_index]``.
|
|
1625
|
+
"""
|
|
1626
|
+
X_frame = np.asarray(X_frame)
|
|
1627
|
+
x, y = X_frame[:, 0], X_frame[:, 1]
|
|
1628
|
+
th = X_frame[:, angle_index]
|
|
1629
|
+
h = 0.5 * length
|
|
1630
|
+
dx, dy = h * np.cos(th), h * np.sin(th)
|
|
1631
|
+
starts = np.stack([x - dx, y - dy], axis=-1)
|
|
1632
|
+
ends = np.stack([x + dx, y + dy], axis=-1)
|
|
1633
|
+
segs = np.stack([starts, ends], axis=1) # (N, 2, 2)
|
|
1634
|
+
lc = LineCollection(segs, colors=color, linewidths=linewidth, capstyle=capstyle, **kwargs)
|
|
1635
|
+
ax.add_collection(lc)
|
|
1636
|
+
ax.set_aspect("equal")
|
|
1637
|
+
return lc
|
|
1638
|
+
|
|
1639
|
+
|
|
1640
|
+
def plot_spde_snapshot(
|
|
1641
|
+
coll: TrajectoryCollection,
|
|
1642
|
+
t_indices,
|
|
1643
|
+
*,
|
|
1644
|
+
dataset: int = 0,
|
|
1645
|
+
scalar_channel: int = 0,
|
|
1646
|
+
vector_channels=None,
|
|
1647
|
+
grid_shape=None,
|
|
1648
|
+
dx=None,
|
|
1649
|
+
render: str = "imshow",
|
|
1650
|
+
streamplot_kw=None,
|
|
1651
|
+
quiver_kw=None,
|
|
1652
|
+
axes=None,
|
|
1653
|
+
vmin=None,
|
|
1654
|
+
vmax=None,
|
|
1655
|
+
cmap: str = "magma",
|
|
1656
|
+
):
|
|
1657
|
+
"""Render SPDE field snapshots from a gridded TrajectoryCollection.
|
|
1658
|
+
|
|
1659
|
+
Reshapes each requested frame ``X[t]`` of shape ``(N, n_channels)`` to
|
|
1660
|
+
``(*grid_shape, n_channels)`` and draws ``scalar_channel`` as an
|
|
1661
|
+
``imshow`` image, optionally overlaying ``vector_channels`` as
|
|
1662
|
+
streamlines (``render="streamplot"``) or arrows (``render="quiver"``).
|
|
1663
|
+
|
|
1664
|
+
Parameters
|
|
1665
|
+
----------
|
|
1666
|
+
t_indices :
|
|
1667
|
+
A single frame index, or a sequence of indices (one panel each).
|
|
1668
|
+
grid_shape :
|
|
1669
|
+
``(nx, ny)`` of the field grid. Inferred as a square grid when
|
|
1670
|
+
omitted.
|
|
1671
|
+
dx :
|
|
1672
|
+
Physical grid spacing (default 1.0).
|
|
1673
|
+
vmin, vmax :
|
|
1674
|
+
Color limits; default to the 0.5/99.5 percentiles of the field.
|
|
1675
|
+
"""
|
|
1676
|
+
t, X, M = _collection_to_arrays(coll, dataset=dataset)
|
|
1677
|
+
T, N, C = X.shape
|
|
1678
|
+
if grid_shape is None:
|
|
1679
|
+
s = int(round(np.sqrt(N)))
|
|
1680
|
+
if s * s != N:
|
|
1681
|
+
raise ValueError("Provide grid_shape; cannot infer a non-square grid.")
|
|
1682
|
+
grid_shape = (s, s)
|
|
1683
|
+
dx = 1.0 if dx is None else float(dx)
|
|
1684
|
+
|
|
1685
|
+
single = np.isscalar(t_indices)
|
|
1686
|
+
tis = [int(t_indices)] if single else [int(i) for i in t_indices]
|
|
1687
|
+
if axes is None:
|
|
1688
|
+
_, axes = plt.subplots(1, len(tis), figsize=(4 * len(tis), 4), squeeze=False)
|
|
1689
|
+
axes = axes[0]
|
|
1690
|
+
axes = np.atleast_1d(axes)
|
|
1691
|
+
|
|
1692
|
+
gx = (np.arange(grid_shape[0]) + 0.5) * dx
|
|
1693
|
+
gy = (np.arange(grid_shape[1]) + 0.5) * dx
|
|
1694
|
+
for ax, ti in zip(axes, tis):
|
|
1695
|
+
field = X[ti].reshape(*grid_shape, C)
|
|
1696
|
+
scal = field[..., scalar_channel]
|
|
1697
|
+
vlo = float(np.percentile(scal, 0.5)) if vmin is None else vmin
|
|
1698
|
+
vhi = float(np.percentile(scal, 99.5)) if vmax is None else vmax
|
|
1699
|
+
ax.imshow(
|
|
1700
|
+
scal.T, origin="lower", cmap=cmap, vmin=vlo, vmax=vhi,
|
|
1701
|
+
extent=[0, grid_shape[0] * dx, 0, grid_shape[1] * dx],
|
|
1702
|
+
)
|
|
1703
|
+
if vector_channels is not None:
|
|
1704
|
+
vx = field[..., vector_channels[0]]
|
|
1705
|
+
vy = field[..., vector_channels[1]]
|
|
1706
|
+
if render == "streamplot":
|
|
1707
|
+
ax.streamplot(gx, gy, vx.T, vy.T, **(streamplot_kw or {}))
|
|
1708
|
+
else:
|
|
1709
|
+
GX, GY = np.meshgrid(gx, gy, indexing="ij")
|
|
1710
|
+
qkw = dict(color="#B0B0B0")
|
|
1711
|
+
qkw.update(quiver_kw or {})
|
|
1712
|
+
ax.quiver(GX, GY, vx, vy, **qkw)
|
|
1713
|
+
ax.set_xticks([])
|
|
1714
|
+
ax.set_yticks([])
|
|
1715
|
+
return axes[0] if single else axes
|
|
1716
|
+
|
|
1717
|
+
|
|
1718
|
+
def spatial_acorr2d(field_2d, *, dx: float = 1.0, n_bins=None, normalize: bool = True):
|
|
1719
|
+
"""Radially-averaged 2D spatial autocorrelation via FFT (periodic).
|
|
1720
|
+
|
|
1721
|
+
Returns ``(r, C)`` where ``C(r)`` is the angle-averaged autocorrelation
|
|
1722
|
+
of ``field_2d`` (mean removed) at radial separation ``r``. Assumes a
|
|
1723
|
+
periodic grid.
|
|
1724
|
+
"""
|
|
1725
|
+
f = np.asarray(field_2d, dtype=float)
|
|
1726
|
+
f = f - f.mean()
|
|
1727
|
+
F = np.fft.rfft2(f)
|
|
1728
|
+
C = np.fft.irfft2(F * np.conj(F), s=f.shape) / f.size
|
|
1729
|
+
if normalize and C[0, 0] != 0:
|
|
1730
|
+
C = C / C[0, 0]
|
|
1731
|
+
nx, ny = f.shape
|
|
1732
|
+
ix = (np.arange(nx) + nx // 2) % nx - nx // 2
|
|
1733
|
+
iy = (np.arange(ny) + ny // 2) % ny - ny // 2
|
|
1734
|
+
GX, GY = np.meshgrid(ix, iy, indexing="ij")
|
|
1735
|
+
r = np.sqrt(GX**2 + GY**2) * dx
|
|
1736
|
+
if n_bins is None:
|
|
1737
|
+
n_bins = min(nx, ny) // 2
|
|
1738
|
+
edges = np.linspace(0.0, float(r.max()), n_bins + 1)
|
|
1739
|
+
which = np.clip(np.digitize(r.ravel(), edges) - 1, 0, n_bins - 1)
|
|
1740
|
+
cvals = C.ravel()
|
|
1741
|
+
radial = np.array(
|
|
1742
|
+
[cvals[which == b].mean() if np.any(which == b) else np.nan for b in range(n_bins)]
|
|
1743
|
+
)
|
|
1744
|
+
centers = 0.5 * (edges[:-1] + edges[1:])
|
|
1745
|
+
return centers, radial
|
|
1746
|
+
|
|
1747
|
+
|
|
1748
|
+
def animate_particles(
|
|
1749
|
+
coll: TrajectoryCollection,
|
|
1750
|
+
*,
|
|
1751
|
+
dataset: int = 0,
|
|
1752
|
+
dims=(0, 1),
|
|
1753
|
+
trail: int = 0,
|
|
1754
|
+
overlay_fn=None,
|
|
1755
|
+
skip: int = 1,
|
|
1756
|
+
cmap: str = "magma",
|
|
1757
|
+
s: float = 100.0,
|
|
1758
|
+
color_dim=None,
|
|
1759
|
+
vmin=None,
|
|
1760
|
+
vmax=None,
|
|
1761
|
+
quiver: bool = False,
|
|
1762
|
+
heading_dim=None,
|
|
1763
|
+
box=None,
|
|
1764
|
+
interval: int = 50,
|
|
1765
|
+
ax=None,
|
|
1766
|
+
fig=None,
|
|
1767
|
+
blit: bool = False,
|
|
1768
|
+
**anim_kw,
|
|
1769
|
+
):
|
|
1770
|
+
"""Animate particle positions over time (frames read via the collection).
|
|
1771
|
+
|
|
1772
|
+
Returns a :class:`matplotlib.animation.FuncAnimation`. With ``trail>0``
|
|
1773
|
+
each particle leaves a fading tail; ``overlay_fn(ax, t_index, X_t)`` is
|
|
1774
|
+
called per frame for custom overlays. For active matter, color points by
|
|
1775
|
+
a state dimension (``color_dim``, e.g. heading angle), overlay heading
|
|
1776
|
+
arrows (``quiver=True`` + ``heading_dim``), and wrap into a periodic box
|
|
1777
|
+
(``box=(Lx, Ly)``) — mirroring :func:`plot_particles`.
|
|
1778
|
+
"""
|
|
1779
|
+
from matplotlib.animation import FuncAnimation
|
|
1780
|
+
|
|
1781
|
+
t, X, M = _collection_to_arrays(coll, dataset=dataset)
|
|
1782
|
+
T, N, d = X.shape
|
|
1783
|
+
frames = list(range(0, T, skip))
|
|
1784
|
+
if fig is None and ax is None:
|
|
1785
|
+
fig, ax = plt.subplots()
|
|
1786
|
+
elif ax is None:
|
|
1787
|
+
ax = fig.gca()
|
|
1788
|
+
elif fig is None:
|
|
1789
|
+
fig = ax.figure
|
|
1790
|
+
i, j = dims
|
|
1791
|
+
box = np.asarray(box, dtype=float) if box is not None else None
|
|
1792
|
+
|
|
1793
|
+
def _xy(ti):
|
|
1794
|
+
xi, yi = X[ti, :, i].copy(), X[ti, :, j].copy()
|
|
1795
|
+
if box is not None:
|
|
1796
|
+
xi, yi = xi % box[0], yi % box[1]
|
|
1797
|
+
return xi, yi
|
|
1798
|
+
|
|
1799
|
+
if box is not None:
|
|
1800
|
+
ax.set_xlim(0.0, float(box[0]))
|
|
1801
|
+
ax.set_ylim(0.0, float(box[1]))
|
|
1802
|
+
else:
|
|
1803
|
+
ax.set_xlim(float(X[..., i].min()), float(X[..., i].max()))
|
|
1804
|
+
ax.set_ylim(float(X[..., j].min()), float(X[..., j].max()))
|
|
1805
|
+
ax.set_aspect("equal")
|
|
1806
|
+
|
|
1807
|
+
x0, y0 = _xy(frames[0])
|
|
1808
|
+
if color_dim is not None:
|
|
1809
|
+
cvals = X[:, :, color_dim]
|
|
1810
|
+
vmin = float(np.nanmin(cvals)) if vmin is None else vmin
|
|
1811
|
+
vmax = float(np.nanmax(cvals)) if vmax is None else vmax
|
|
1812
|
+
scat = ax.scatter(x0, y0, c=X[frames[0], :, color_dim], cmap=cmap or "hsv", vmin=vmin, vmax=vmax, s=s)
|
|
1813
|
+
else:
|
|
1814
|
+
scat = ax.scatter(x0, y0, c=np.linspace(0, 1, N), cmap=cmap, s=s)
|
|
1815
|
+
|
|
1816
|
+
hd = heading_dim if heading_dim is not None else (2 if d >= 3 else None)
|
|
1817
|
+
quiv = None
|
|
1818
|
+
if quiver and hd is not None:
|
|
1819
|
+
th0 = X[frames[0], :, hd]
|
|
1820
|
+
quiv = ax.quiver(x0, y0, np.cos(th0), np.sin(th0), color="#B0B0B0", pivot="mid", units="xy")
|
|
1821
|
+
|
|
1822
|
+
trails = []
|
|
1823
|
+
if trail > 0:
|
|
1824
|
+
for _ in range(N):
|
|
1825
|
+
(ln,) = ax.plot([], [], lw=0.8, alpha=0.5, color="#B0B0B0")
|
|
1826
|
+
trails.append(ln)
|
|
1827
|
+
|
|
1828
|
+
def _update(fr):
|
|
1829
|
+
ti = frames[fr]
|
|
1830
|
+
xi, yi = _xy(ti)
|
|
1831
|
+
off = np.stack([xi, yi], axis=-1)
|
|
1832
|
+
scat.set_offsets(off)
|
|
1833
|
+
if color_dim is not None:
|
|
1834
|
+
scat.set_array(X[ti, :, color_dim])
|
|
1835
|
+
artists = [scat]
|
|
1836
|
+
if quiv is not None:
|
|
1837
|
+
quiv.set_offsets(off)
|
|
1838
|
+
th = X[ti, :, hd]
|
|
1839
|
+
quiv.set_UVC(np.cos(th), np.sin(th))
|
|
1840
|
+
artists.append(quiv)
|
|
1841
|
+
if trail > 0:
|
|
1842
|
+
lo = max(0, ti - trail)
|
|
1843
|
+
for n in range(N):
|
|
1844
|
+
trails[n].set_data(X[lo : ti + 1, n, i], X[lo : ti + 1, n, j])
|
|
1845
|
+
artists += trails
|
|
1846
|
+
if overlay_fn is not None:
|
|
1847
|
+
overlay_fn(ax, ti, X[ti])
|
|
1848
|
+
return artists
|
|
1849
|
+
|
|
1850
|
+
return FuncAnimation(fig, _update, frames=len(frames), interval=interval, blit=blit, **anim_kw)
|
|
1851
|
+
|
|
1852
|
+
|
|
1853
|
+
def animate_spde_comparison(
|
|
1854
|
+
coll_a: TrajectoryCollection,
|
|
1855
|
+
coll_b: TrajectoryCollection,
|
|
1856
|
+
*,
|
|
1857
|
+
dataset: int = 0,
|
|
1858
|
+
field_component: int = 0,
|
|
1859
|
+
grid_shape=None,
|
|
1860
|
+
skip: int = 1,
|
|
1861
|
+
vmin=None,
|
|
1862
|
+
vmax=None,
|
|
1863
|
+
cmap: str = "magma",
|
|
1864
|
+
titles=("A", "B"),
|
|
1865
|
+
interval: int = 50,
|
|
1866
|
+
plot_colorbar: bool = True,
|
|
1867
|
+
blit: bool = False,
|
|
1868
|
+
**anim_kw,
|
|
1869
|
+
):
|
|
1870
|
+
"""Side-by-side animation of one channel of two gridded collections.
|
|
1871
|
+
|
|
1872
|
+
Returns a :class:`matplotlib.animation.FuncAnimation` with two image
|
|
1873
|
+
panels sharing color limits (e.g. data vs bootstrap SPDE fields).
|
|
1874
|
+
"""
|
|
1875
|
+
from matplotlib.animation import FuncAnimation
|
|
1876
|
+
|
|
1877
|
+
_, Xa, _ = _collection_to_arrays(coll_a, dataset=dataset)
|
|
1878
|
+
_, Xb, _ = _collection_to_arrays(coll_b, dataset=dataset)
|
|
1879
|
+
Tn = min(Xa.shape[0], Xb.shape[0])
|
|
1880
|
+
N, C = Xa.shape[1], Xa.shape[2]
|
|
1881
|
+
if grid_shape is None:
|
|
1882
|
+
s = int(round(np.sqrt(N)))
|
|
1883
|
+
if s * s != N:
|
|
1884
|
+
raise ValueError("Provide grid_shape; cannot infer a non-square grid.")
|
|
1885
|
+
grid_shape = (s, s)
|
|
1886
|
+
frames = list(range(0, Tn, skip))
|
|
1887
|
+
|
|
1888
|
+
def _slab(Xarr, ti):
|
|
1889
|
+
return Xarr[ti].reshape(*grid_shape, C)[..., field_component].T
|
|
1890
|
+
|
|
1891
|
+
if vmin is None:
|
|
1892
|
+
vmin = float(min(Xa[..., field_component].min(), Xb[..., field_component].min()))
|
|
1893
|
+
if vmax is None:
|
|
1894
|
+
vmax = float(max(Xa[..., field_component].max(), Xb[..., field_component].max()))
|
|
1895
|
+
|
|
1896
|
+
fig, axes = plt.subplots(1, 2, figsize=(9, 4.5))
|
|
1897
|
+
im_a = axes[0].imshow(_slab(Xa, 0), origin="lower", cmap=cmap, vmin=vmin, vmax=vmax)
|
|
1898
|
+
im_b = axes[1].imshow(_slab(Xb, 0), origin="lower", cmap=cmap, vmin=vmin, vmax=vmax)
|
|
1899
|
+
for ax, title in zip(axes, titles):
|
|
1900
|
+
ax.set_title(title)
|
|
1901
|
+
ax.set_xticks([])
|
|
1902
|
+
ax.set_yticks([])
|
|
1903
|
+
if plot_colorbar:
|
|
1904
|
+
fig.colorbar(im_b, ax=axes.tolist(), shrink=0.8)
|
|
1905
|
+
|
|
1906
|
+
def _update(fr):
|
|
1907
|
+
ti = frames[fr]
|
|
1908
|
+
im_a.set_data(_slab(Xa, ti))
|
|
1909
|
+
im_b.set_data(_slab(Xb, ti))
|
|
1910
|
+
return [im_a, im_b]
|
|
1911
|
+
|
|
1912
|
+
return FuncAnimation(fig, _update, frames=len(frames), interval=interval, blit=blit, **anim_kw)
|
|
1913
|
+
|
|
1914
|
+
|
|
1915
|
+
def plot_time_profile_comparison(t, true_profiles, inferred_profiles, *, labels=None, axes=None):
|
|
1916
|
+
"""Plot true vs inferred time-dependent profiles (e.g. k(t), a(t)).
|
|
1917
|
+
|
|
1918
|
+
``true_profiles`` and ``inferred_profiles`` are sequences of 1D arrays
|
|
1919
|
+
(one per panel); ``true_profiles`` entries may be ``None`` to skip the
|
|
1920
|
+
reference. ``labels`` gives a title per panel.
|
|
1921
|
+
"""
|
|
1922
|
+
n = len(inferred_profiles)
|
|
1923
|
+
if axes is None:
|
|
1924
|
+
_, axes = plt.subplots(1, n, figsize=(4 * n, 3.5), squeeze=False)
|
|
1925
|
+
axes = axes[0]
|
|
1926
|
+
axes = np.atleast_1d(axes)
|
|
1927
|
+
for k in range(n):
|
|
1928
|
+
ax = axes[k]
|
|
1929
|
+
if true_profiles is not None and true_profiles[k] is not None:
|
|
1930
|
+
ax.plot(t, true_profiles[k], color=SFI_COLORS["exact"], lw=2.4, label="true")
|
|
1931
|
+
ax.plot(t, inferred_profiles[k], color=SFI_COLORS["inferred"], lw=1.6, ls="--", label="inferred")
|
|
1932
|
+
if labels is not None:
|
|
1933
|
+
ax.set_title(labels[k])
|
|
1934
|
+
ax.set_xlabel("time")
|
|
1935
|
+
ax.legend(loc="upper right")
|
|
1936
|
+
return axes
|