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.
Files changed (104) hide show
  1. SFI/__init__.py +64 -0
  2. SFI/bases/__init__.py +85 -0
  3. SFI/bases/constants.py +492 -0
  4. SFI/bases/linear.py +325 -0
  5. SFI/bases/monomials.py +218 -0
  6. SFI/bases/pairs.py +998 -0
  7. SFI/bases/spde.py +1537 -0
  8. SFI/diagnostics/__init__.py +60 -0
  9. SFI/diagnostics/assess.py +87 -0
  10. SFI/diagnostics/dynamics_order.py +621 -0
  11. SFI/diagnostics/plotting.py +226 -0
  12. SFI/diagnostics/report.py +238 -0
  13. SFI/diagnostics/residual_tests.py +395 -0
  14. SFI/diagnostics/residuals.py +688 -0
  15. SFI/inference/__init__.py +58 -0
  16. SFI/inference/base.py +1460 -0
  17. SFI/inference/optimizers.py +200 -0
  18. SFI/inference/overdamped.py +1214 -0
  19. SFI/inference/parametric_core/__init__.py +34 -0
  20. SFI/inference/parametric_core/covariance.py +232 -0
  21. SFI/inference/parametric_core/driver.py +272 -0
  22. SFI/inference/parametric_core/flow.py +149 -0
  23. SFI/inference/parametric_core/flow_multi.py +362 -0
  24. SFI/inference/parametric_core/flow_ud.py +168 -0
  25. SFI/inference/parametric_core/jacobians.py +540 -0
  26. SFI/inference/parametric_core/objective.py +286 -0
  27. SFI/inference/parametric_core/objective_ud.py +253 -0
  28. SFI/inference/parametric_core/precision.py +229 -0
  29. SFI/inference/parametric_core/solve.py +763 -0
  30. SFI/inference/result.py +362 -0
  31. SFI/inference/serialization.py +245 -0
  32. SFI/inference/sparse/__init__.py +67 -0
  33. SFI/inference/sparse/base.py +43 -0
  34. SFI/inference/sparse/beam.py +303 -0
  35. SFI/inference/sparse/greedy.py +151 -0
  36. SFI/inference/sparse/hillclimb.py +307 -0
  37. SFI/inference/sparse/lasso.py +178 -0
  38. SFI/inference/sparse/metrics.py +78 -0
  39. SFI/inference/sparse/result.py +278 -0
  40. SFI/inference/sparse/scorer.py +323 -0
  41. SFI/inference/sparse/stlsq.py +165 -0
  42. SFI/inference/sparsity.py +40 -0
  43. SFI/inference/underdamped.py +1355 -0
  44. SFI/integrate/__init__.py +38 -0
  45. SFI/integrate/api.py +920 -0
  46. SFI/integrate/integrand.py +402 -0
  47. SFI/integrate/rk4.py +156 -0
  48. SFI/integrate/timeops.py +174 -0
  49. SFI/langevin/__init__.py +29 -0
  50. SFI/langevin/base.py +863 -0
  51. SFI/langevin/chunked.py +225 -0
  52. SFI/langevin/noise.py +446 -0
  53. SFI/langevin/overdamped.py +560 -0
  54. SFI/langevin/underdamped.py +448 -0
  55. SFI/statefunc/__init__.py +49 -0
  56. SFI/statefunc/basis.py +87 -0
  57. SFI/statefunc/core/runtime.py +47 -0
  58. SFI/statefunc/factory.py +346 -0
  59. SFI/statefunc/interactor.py +90 -0
  60. SFI/statefunc/layout/__init__.py +29 -0
  61. SFI/statefunc/layout/_base.py +186 -0
  62. SFI/statefunc/layout/_eval_compiler.py +453 -0
  63. SFI/statefunc/layout/_fd_atoms.py +196 -0
  64. SFI/statefunc/layout/_grid.py +878 -0
  65. SFI/statefunc/layout/_sectors.py +166 -0
  66. SFI/statefunc/memhint.py +222 -0
  67. SFI/statefunc/nodes/__init__.py +56 -0
  68. SFI/statefunc/nodes/base.py +318 -0
  69. SFI/statefunc/nodes/contract.py +422 -0
  70. SFI/statefunc/nodes/interactions/__init__.py +23 -0
  71. SFI/statefunc/nodes/interactions/dispatcher.py +1365 -0
  72. SFI/statefunc/nodes/interactions/prepare.py +161 -0
  73. SFI/statefunc/nodes/interactions/specs.py +362 -0
  74. SFI/statefunc/nodes/interactions/stencils.py +718 -0
  75. SFI/statefunc/nodes/leaf.py +530 -0
  76. SFI/statefunc/nodes/ops/__init__.py +27 -0
  77. SFI/statefunc/nodes/ops/concat.py +28 -0
  78. SFI/statefunc/nodes/ops/derivative.py +447 -0
  79. SFI/statefunc/nodes/ops/einsum.py +120 -0
  80. SFI/statefunc/nodes/ops/linear.py +153 -0
  81. SFI/statefunc/nodes/ops/mapn.py +138 -0
  82. SFI/statefunc/nodes/ops/reshape_rank.py +158 -0
  83. SFI/statefunc/nodes/ops/slice.py +83 -0
  84. SFI/statefunc/params.py +309 -0
  85. SFI/statefunc/psf.py +112 -0
  86. SFI/statefunc/sf.py +104 -0
  87. SFI/statefunc/stateexpr.py +1243 -0
  88. SFI/statefunc/structexpr.py +1003 -0
  89. SFI/trajectory/__init__.py +31 -0
  90. SFI/trajectory/collection.py +1122 -0
  91. SFI/trajectory/dataset.py +1164 -0
  92. SFI/trajectory/degrade.py +1108 -0
  93. SFI/trajectory/io.py +1027 -0
  94. SFI/trajectory/reserved_extras.py +129 -0
  95. SFI/utils/__init__.py +17 -0
  96. SFI/utils/formatting.py +308 -0
  97. SFI/utils/maths.py +185 -0
  98. SFI/utils/neighbors.py +162 -0
  99. SFI/utils/plotting.py +1936 -0
  100. stochasticforceinference-2.0.0.dist-info/METADATA +203 -0
  101. stochasticforceinference-2.0.0.dist-info/RECORD +104 -0
  102. stochasticforceinference-2.0.0.dist-info/WHEEL +5 -0
  103. stochasticforceinference-2.0.0.dist-info/licenses/LICENSE +21 -0
  104. 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